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,2335 @@
1
+ # Effects slideover overhaul - implementation plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the page-editor's flat Effects dropdown with a unified `E.slideover` UI that exposes a parameter form, live shortcode preview, and (where applicable) a live render preview for every Domma effect, plus extends the row-reveal injection pattern to `[card]`, `[hero]`, `[banner]`, `[col]`, `[button]`.
6
+
7
+ **Architecture:** Two new admin lib modules (`effect-defs.js`, `effects-builder.js`) drive the slideover; one shared server-side helper (`extractInjectedEffects`) is wired into five existing host processors in `markdown.js`. The existing `public/js/effects.js` runtime initialiser is reused unchanged - class-based selection means injected effects work for free on the published site.
8
+
9
+ **Tech Stack:** Fastify 5 (ESM), Domma SPA, `node:test` for server-side unit tests, manual browser verification for slideover UI.
10
+
11
+ **Special conventions for this project:**
12
+ - **No commits during implementation.** The user commits separately. This plan does **not** include `git commit` steps; instead each phase ends with a User Checkpoint where the user can review/test/commit.
13
+ - **Browser verification is mandatory** for any UI claim. Server-side passing tests do **not** count as "the slideover works". Each phase that ships UI behaviour ends with explicit browser-verification steps to be performed at `http://192.168.1.4:4096/admin`.
14
+ - **Cache-buster discipline.** When a module imported via `import {…} from "../lib/<module>.js?v=N"` is modified, the importer's `?v=N` must be bumped in the same change.
15
+ - **Server is always running on port 4096.** Never kill or start it. To verify endpoints, just `curl`/visit.
16
+
17
+ ---
18
+
19
+ ## File Structure
20
+
21
+ **New files:**
22
+ - `admin/js/lib/effect-defs.js` - declarative effect catalogue. Pure data export; one entry per effect with `{ name, label, category, description, attrs, hosts, previewable }`. Single source of truth.
23
+ - `admin/js/lib/effects-builder.js` - slideover module. Exports `EffectsBuilder.open(textarea)`. Internally handles cursor inspection, scope detection, form rendering, shortcode generation, edit-mode handling, both preview panes.
24
+ - `tests/markdown/effect-injection.test.js` - `node:test` suite covering host-shortcode injection rendering for all five hosts × applicable effects.
25
+
26
+ **Modified files:**
27
+ - `server/services/markdown.js` - add `extractInjectedEffects` shared helper; wire into `processCardBlocks`, `processHeroBlocks`, `processBannerBlocks`, `processButtonBlocks`, `[col]` arm of `processGridBlocks`. Migrate the existing ad-hoc `twinkle`/`blobs` injection in `processHeroBlocks` to use the helper. (Existing `[row reveal …]` logic is left untouched - it predates this design and uses `data-reveal-*` rather than `data-fx-*` because of its multi-child stagger semantics.)
28
+ - `admin/js/lib/markdown-toolbar.js` - delete `effectsMenuItems` constant, delete `openEffectsDropdown` function, delete the `else if (action === 'effects')` branch's fallback to the dropdown so the registered `handlers.effects(ta)` callback fires directly.
29
+ - `admin/js/views/page-editor.js` - register `toolbar.onEffects(ta => EffectsBuilder.open(ta))`. Add new imports for `effects-builder.js?v=1`. Bump `markdown-toolbar.js` cache-buster from `?v=12` to `?v=13`.
30
+
31
+ **Unchanged:**
32
+ - `public/js/effects.js` - class-based selection finds injected effects automatically; no changes needed.
33
+ - `admin/js/templates/effects.html` - kept as the hand-syntax reference cheatsheet.
34
+ - `admin/js/views/effects.js` - settings tab unaffected.
35
+
36
+ ---
37
+
38
+ ## Phase A - Server-side effect injection (TDD)
39
+
40
+ ### Task A1: Test scaffolding
41
+
42
+ **Files:**
43
+ - Create: `tests/markdown/effect-injection.test.js`
44
+
45
+ - [ ] **Step 1: Create the test file with the imports and a sanity test.**
46
+
47
+ ```js
48
+ import {test} from 'node:test';
49
+ import assert from 'node:assert/strict';
50
+ import {parseMarkdown} from '../../server/services/markdown.js';
51
+
52
+ test('parseMarkdown is reachable from the test runner', async () => {
53
+ const {html} = await parseMarkdown('# hello');
54
+ assert.match(html, /<h1>hello<\/h1>/);
55
+ });
56
+ ```
57
+
58
+ - [ ] **Step 2: Run the sanity test.**
59
+
60
+ Run: `node --test tests/markdown/effect-injection.test.js`
61
+ Expected: 1 passing test, no failures.
62
+
63
+ ---
64
+
65
+ ### Task A2: `extractInjectedEffects` helper
66
+
67
+ **Files:**
68
+ - Modify: `server/services/markdown.js` - add helper near the existing `escapeAttr` export (line ~1111)
69
+ - Test: `tests/markdown/effect-injection.test.js`
70
+
71
+ The helper takes a parsed `attrs` object (output of `parseShortcodeAttrs`) and a list of *allowed* effect names for the host, and returns:
72
+ - `effectClasses` - string array of `dm-fx-<effect>` classes to add
73
+ - `effectDataAttrs` - `data-fx-<attr>="value"` string fragment, ready to splice into the host's opening tag
74
+ - `consumedKeys` - Set of attribute keys that were claimed (so the host can skip them when emitting other attrs)
75
+
76
+ This is what every host will call. Only effects in `allowed` participate.
77
+
78
+ - [ ] **Step 1: Write the failing tests.**
79
+
80
+ Add to `tests/markdown/effect-injection.test.js`:
81
+
82
+ ```js
83
+ import {extractInjectedEffects, parseShortcodeAttrs} from '../../server/services/markdown.js';
84
+
85
+ test('extractInjectedEffects: no flags → empty result', () => {
86
+ const attrs = parseShortcodeAttrs('title="Hi"');
87
+ const out = extractInjectedEffects(attrs, ['reveal', 'pulse']);
88
+ assert.deepEqual(out.effectClasses, []);
89
+ assert.equal(out.effectDataAttrs, '');
90
+ assert.equal(out.consumedKeys.size, 0);
91
+ });
92
+
93
+ test('extractInjectedEffects: flag with no params → class only', () => {
94
+ const attrs = parseShortcodeAttrs('title="Hi" reveal');
95
+ const out = extractInjectedEffects(attrs, ['reveal']);
96
+ assert.deepEqual(out.effectClasses, ['dm-fx-reveal']);
97
+ assert.equal(out.effectDataAttrs, '');
98
+ assert.ok(out.consumedKeys.has('reveal'));
99
+ });
100
+
101
+ test('extractInjectedEffects: flag with prefixed params → class + data attrs', () => {
102
+ const attrs = parseShortcodeAttrs(
103
+ 'title="Hi" reveal reveal-animation="slide-up" reveal-duration="600"'
104
+ );
105
+ const out = extractInjectedEffects(attrs, ['reveal']);
106
+ assert.deepEqual(out.effectClasses, ['dm-fx-reveal']);
107
+ assert.match(out.effectDataAttrs, / data-fx-animation="slide-up"/);
108
+ assert.match(out.effectDataAttrs, / data-fx-duration="600"/);
109
+ assert.ok(out.consumedKeys.has('reveal'));
110
+ assert.ok(out.consumedKeys.has('reveal-animation'));
111
+ assert.ok(out.consumedKeys.has('reveal-duration'));
112
+ });
113
+
114
+ test('extractInjectedEffects: disallowed effect ignored', () => {
115
+ const attrs = parseShortcodeAttrs('title="Hi" pulse');
116
+ const out = extractInjectedEffects(attrs, ['reveal']); // pulse not allowed
117
+ assert.deepEqual(out.effectClasses, []);
118
+ assert.equal(out.consumedKeys.size, 0);
119
+ });
120
+
121
+ test('extractInjectedEffects: multiple flags compose', () => {
122
+ const attrs = parseShortcodeAttrs('title="Hi" reveal pulse pulse-scale="1.05"');
123
+ const out = extractInjectedEffects(attrs, ['reveal', 'pulse']);
124
+ assert.deepEqual(out.effectClasses.sort(), ['dm-fx-pulse', 'dm-fx-reveal']);
125
+ assert.match(out.effectDataAttrs, / data-fx-scale="1.05"/);
126
+ // Note: when two effects share an attr namespace we still emit data-fx-<attr>
127
+ // - the runtime initialisers each read what they need; collisions are author error.
128
+ });
129
+
130
+ test('extractInjectedEffects: HTML-escapes attribute values', () => {
131
+ const attrs = parseShortcodeAttrs('twinkle twinkle-colour=\'red"&\'');
132
+ const out = extractInjectedEffects(attrs, ['twinkle']);
133
+ assert.match(out.effectDataAttrs, /data-fx-colour="red&quot;&amp;"/);
134
+ });
135
+ ```
136
+
137
+ Run: `node --test tests/markdown/effect-injection.test.js`
138
+ Expected: all six new tests fail with `extractInjectedEffects is not a function` (it's not exported yet).
139
+
140
+ - [ ] **Step 2: Implement the helper.**
141
+
142
+ Open `server/services/markdown.js`. Find the `escapeAttr` export (line ~1111). Immediately after it, add:
143
+
144
+ ```js
145
+ /**
146
+ * Extract injected-effect flag attributes from a parsed shortcode attrs object.
147
+ *
148
+ * For each effect in `allowed` whose flag attribute is present in `attrs`,
149
+ * emit a `dm-fx-<effect>` class and convert all `<effect>-<attr>` keys to
150
+ * `data-fx-<attr>="<escaped-value>"` fragments. Returns the consumed keys
151
+ * so the host processor can skip them when emitting its own attributes.
152
+ *
153
+ * The flag attribute itself is presence-only (parseShortcodeAttrs returns
154
+ * an empty string for bare flags). Prefixed attributes carry the value.
155
+ *
156
+ * @param {Object<string,string>} attrs - parsed shortcode attribute map
157
+ * @param {string[]} allowed - effect names this host accepts (e.g. ['reveal','pulse'])
158
+ * @returns {{effectClasses: string[], effectDataAttrs: string, consumedKeys: Set<string>}}
159
+ */
160
+ export function extractInjectedEffects(attrs, allowed) {
161
+ const effectClasses = [];
162
+ const dataAttrParts = [];
163
+ const consumedKeys = new Set();
164
+
165
+ for (const effect of allowed) {
166
+ if (!(effect in attrs)) continue;
167
+ effectClasses.push(`dm-fx-${effect}`);
168
+ consumedKeys.add(effect);
169
+
170
+ const prefix = `${effect}-`;
171
+ for (const key of Object.keys(attrs)) {
172
+ if (!key.startsWith(prefix)) continue;
173
+ const param = key.slice(prefix.length);
174
+ dataAttrParts.push(` data-fx-${param}="${escapeAttr(String(attrs[key]))}"`);
175
+ consumedKeys.add(key);
176
+ }
177
+ }
178
+
179
+ return {
180
+ effectClasses,
181
+ effectDataAttrs: dataAttrParts.join(''),
182
+ consumedKeys,
183
+ };
184
+ }
185
+ ```
186
+
187
+ - [ ] **Step 3: Run tests.**
188
+
189
+ Run: `node --test tests/markdown/effect-injection.test.js`
190
+ Expected: all 7 tests pass.
191
+
192
+ ---
193
+
194
+ ### Task A3: Inject effects into `[card]`
195
+
196
+ **Files:**
197
+ - Modify: `server/services/markdown.js` - `processCardBlocks` (function starts at line ~1730)
198
+ - Test: `tests/markdown/effect-injection.test.js`
199
+
200
+ Allowed effects on `[card]`: `reveal`, `breathe`, `pulse`, `shake`, `twinkle`, `ambient`, `animate`, `ripple`.
201
+
202
+ - [ ] **Step 1: Write the failing tests.**
203
+
204
+ Add to `tests/markdown/effect-injection.test.js`:
205
+
206
+ ```js
207
+ test('[card] + reveal injection emits dm-fx-reveal + data-fx-* on card', async () => {
208
+ const md = `[card title="Welcome" reveal reveal-animation="slide-up" reveal-duration="600"]
209
+ Body content.
210
+ [/card]`;
211
+ const {html} = await parseMarkdown(md);
212
+ assert.match(html, /class="[^"]*\bdm-fx-reveal\b[^"]*"/);
213
+ assert.match(html, /data-fx-animation="slide-up"/);
214
+ assert.match(html, /data-fx-duration="600"/);
215
+ // The card's own classes/title still render.
216
+ assert.match(html, /Welcome/);
217
+ // The flag itself didn't leak into output as a raw HTML attribute.
218
+ assert.doesNotMatch(html, /\breveal=/);
219
+ });
220
+
221
+ test('[card] + pulse injection works alongside title', async () => {
222
+ const md = `[card title="Welcome" pulse pulse-scale="1.05"]
223
+ Body.
224
+ [/card]`;
225
+ const {html} = await parseMarkdown(md);
226
+ assert.match(html, /class="[^"]*\bdm-fx-pulse\b[^"]*"/);
227
+ assert.match(html, /data-fx-scale="1.05"/);
228
+ });
229
+
230
+ test('[card] without effects is unchanged', async () => {
231
+ const md = `[card title="Plain"]
232
+ Body.
233
+ [/card]`;
234
+ const {html} = await parseMarkdown(md);
235
+ assert.doesNotMatch(html, /dm-fx-/);
236
+ assert.doesNotMatch(html, /data-fx-/);
237
+ });
238
+ ```
239
+
240
+ Run: `node --test tests/markdown/effect-injection.test.js`
241
+ Expected: card-injection tests fail (no injection logic in `processCardBlocks` yet); existing tests still pass.
242
+
243
+ - [ ] **Step 2: Locate the card emit site.**
244
+
245
+ In `server/services/markdown.js`, `processCardBlocks` is large but its emit point is where it builds the final `<div class="card …">…</div>` string. Read the function and find the line that assembles the `class` array and the line that emits the opening `<div`. Make a note of the variable names - typically `classes` for the class array and a template string with `class="${classes.join(' ')}"`.
246
+
247
+ - [ ] **Step 3: Wire injection into card emit.**
248
+
249
+ Inside `processCardBlocks`'s replace callback, near the top after `const attrs = parseShortcodeAttrs(attrStr);`, add:
250
+
251
+ ```js
252
+ const CARD_INJECTABLE = ['reveal', 'breathe', 'pulse', 'shake', 'twinkle', 'ambient', 'animate', 'ripple'];
253
+ const injected = extractInjectedEffects(attrs, CARD_INJECTABLE);
254
+ ```
255
+
256
+ Then where the card's `classes` array is built, append `...injected.effectClasses`:
257
+
258
+ ```js
259
+ classes.push(...injected.effectClasses);
260
+ ```
261
+
262
+ Then where the opening `<div` tag is emitted with class/id/etc, splice `${injected.effectDataAttrs}` into the attribute slot. For example:
263
+
264
+ ```js
265
+ return `<div class="${classes.join(' ')}"${idAttr}${injected.effectDataAttrs}…>…</div>`;
266
+ ```
267
+
268
+ (Place `${injected.effectDataAttrs}` after the `class` and `id` attributes but before any `style` or other host-specific attrs - order doesn't matter functionally but keep it readable.)
269
+
270
+ - [ ] **Step 4: Run tests.**
271
+
272
+ Run: `node --test tests/markdown/effect-injection.test.js`
273
+ Expected: all card tests now pass; previous tests still pass.
274
+
275
+ ---
276
+
277
+ ### Task A4: Inject effects into `[hero]` (refactor)
278
+
279
+ **Files:**
280
+ - Modify: `server/services/markdown.js` - `processHeroBlocks` (line ~2586)
281
+ - Test: `tests/markdown/effect-injection.test.js`
282
+
283
+ Allowed effects on `[hero]`: `reveal`, `breathe`, `pulse`, `twinkle`, `ticker-tape`, `ambient`, `animate`. Note: `[hero]` already has bespoke twinkle and blobs handling - replace the twinkle bit with the generic helper, keep blobs as-is.
284
+
285
+ - [ ] **Step 1: Write the failing tests.**
286
+
287
+ Add to `tests/markdown/effect-injection.test.js`:
288
+
289
+ ```js
290
+ test('[hero] + reveal injection', async () => {
291
+ const md = `[hero title="Welcome" reveal reveal-animation="zoom"]
292
+ Tagline
293
+ [/hero]`;
294
+ const {html} = await parseMarkdown(md);
295
+ assert.match(html, /class="[^"]*\bdm-fx-reveal\b[^"]*"/);
296
+ assert.match(html, /data-fx-animation="zoom"/);
297
+ });
298
+
299
+ test('[hero] + twinkle still works after refactor (regression)', async () => {
300
+ const md = `[hero title="Stars" twinkle twinkle-count="100" twinkle-colour="#fff"]
301
+ Hero body.
302
+ [/hero]`;
303
+ const {html} = await parseMarkdown(md);
304
+ assert.match(html, /class="[^"]*\bdm-fx-twinkle\b[^"]*"/);
305
+ assert.match(html, /data-fx-count="100"/);
306
+ assert.match(html, /data-fx-colour="#fff"/);
307
+ });
308
+
309
+ test('[hero] + ticker-tape injection', async () => {
310
+ const md = `[hero title="Party" ticker-tape ticker-tape-palette="festive"]
311
+ Body.
312
+ [/hero]`;
313
+ const {html} = await parseMarkdown(md);
314
+ assert.match(html, /class="[^"]*\bdm-fx-ticker-tape\b[^"]*"/);
315
+ assert.match(html, /data-fx-palette="festive"/);
316
+ });
317
+ ```
318
+
319
+ Run: `node --test tests/markdown/effect-injection.test.js`
320
+ Expected: hero-reveal test fails; hero-twinkle test currently passes (existing bespoke logic); ticker-tape test fails.
321
+
322
+ - [ ] **Step 2: Refactor `processHeroBlocks`.**
323
+
324
+ In `processHeroBlocks`, find the existing twinkle handling block (around line 2606-2635). Replace it with the generic helper.
325
+
326
+ Before:
327
+
328
+ ```js
329
+ const twinkle = 'twinkle' in attrs;
330
+ const twinkleCount = attrs['twinkle-count'] || '';
331
+ const twinkleColour = attrs['twinkle-colour'] || '';
332
+ const blobs = 'blobs' in attrs;
333
+ const blobsType = attrs['blobs-type'] || 'float-blobs';
334
+ …
335
+ if (twinkle) classes.push('dm-fx-twinkle');
336
+ if (blobs) classes.push(`bg-ambient-${blobsType}`);
337
+ …
338
+ const twinkleAttrs = twinkle
339
+ ? (twinkleCount ? ` data-fx-count="${escapeAttr(twinkleCount)}"` : '') +
340
+ (twinkleColour ? ` data-fx-colour="${escapeAttr(twinkleColour)}"` : '')
341
+ : '';
342
+ …
343
+ return `<div class="${classes.join(' ')}"${idAttr}${style}${twinkleAttrs}>${inner}</div>\n`;
344
+ ```
345
+
346
+ After:
347
+
348
+ ```js
349
+ const HERO_INJECTABLE = ['reveal', 'breathe', 'pulse', 'twinkle', 'ticker-tape', 'ambient', 'animate'];
350
+ const injected = extractInjectedEffects(attrs, HERO_INJECTABLE);
351
+
352
+ // Legacy ad-hoc 'blobs' flag - keep for backward compat. Different keyspace
353
+ // (bg-ambient-* CSS class), not folded into the generic helper.
354
+ const blobs = 'blobs' in attrs;
355
+ const blobsType = attrs['blobs-type'] || 'float-blobs';
356
+ …
357
+ classes.push(...injected.effectClasses);
358
+ if (blobs) classes.push(`bg-ambient-${blobsType}`);
359
+ …
360
+ return `<div class="${classes.join(' ')}"${idAttr}${style}${injected.effectDataAttrs}>${inner}</div>\n`;
361
+ ```
362
+
363
+ The `twinkleAttrs` variable goes away entirely.
364
+
365
+ - [ ] **Step 3: Run tests.**
366
+
367
+ Run: `node --test tests/markdown/effect-injection.test.js`
368
+ Expected: all hero tests pass; card tests still pass.
369
+
370
+ ---
371
+
372
+ ### Task A5: Inject effects into `[banner]`
373
+
374
+ **Files:**
375
+ - Modify: `server/services/markdown.js` - `processBannerBlocks` (line ~2690)
376
+ - Test: `tests/markdown/effect-injection.test.js`
377
+
378
+ Allowed effects on `[banner]`: `reveal`, `pulse`, `shake`, `ambient`, `animate`.
379
+
380
+ - [ ] **Step 1: Write the failing tests.**
381
+
382
+ Add to `tests/markdown/effect-injection.test.js`:
383
+
384
+ ```js
385
+ test('[banner] + reveal injection', async () => {
386
+ const md = `[banner type="info" reveal reveal-animation="fade"]
387
+ Heads up.
388
+ [/banner]`;
389
+ const {html} = await parseMarkdown(md);
390
+ assert.match(html, /class="[^"]*\bdm-fx-reveal\b[^"]*"/);
391
+ assert.match(html, /data-fx-animation="fade"/);
392
+ });
393
+
394
+ test('[banner] + pulse injection', async () => {
395
+ const md = `[banner type="warning" pulse]
396
+ Important.
397
+ [/banner]`;
398
+ const {html} = await parseMarkdown(md);
399
+ assert.match(html, /class="[^"]*\bdm-fx-pulse\b[^"]*"/);
400
+ });
401
+
402
+ test('[banner] disallowed effect (twinkle) is ignored', async () => {
403
+ const md = `[banner type="info" twinkle]
404
+ Body.
405
+ [/banner]`;
406
+ const {html} = await parseMarkdown(md);
407
+ assert.doesNotMatch(html, /dm-fx-twinkle/);
408
+ });
409
+ ```
410
+
411
+ Run: `node --test tests/markdown/effect-injection.test.js`
412
+ Expected: banner injection tests fail; the disallowed test currently passes by coincidence (no injection logic at all yet).
413
+
414
+ - [ ] **Step 2: Wire injection into banner emit.**
415
+
416
+ In `processBannerBlocks`, after `const attrs = parseShortcodeAttrs(attrStr);` add:
417
+
418
+ ```js
419
+ const BANNER_INJECTABLE = ['reveal', 'pulse', 'shake', 'ambient', 'animate'];
420
+ const injected = extractInjectedEffects(attrs, BANNER_INJECTABLE);
421
+ ```
422
+
423
+ Then where the `classes` array is built, append:
424
+
425
+ ```js
426
+ classes.push(...injected.effectClasses);
427
+ ```
428
+
429
+ Then in the final returned template string, splice `${injected.effectDataAttrs}` into the outer `<div class="…">` - change:
430
+
431
+ ```js
432
+ return (
433
+ `<div class="${classes.join(' ')}">\n` +
434
+ ` ${iconHtml}<div class="dm-banner__body">\n` +
435
+ …
436
+ );
437
+ ```
438
+
439
+ to:
440
+
441
+ ```js
442
+ return (
443
+ `<div class="${classes.join(' ')}"${injected.effectDataAttrs}>\n` +
444
+ ` ${iconHtml}<div class="dm-banner__body">\n` +
445
+ …
446
+ );
447
+ ```
448
+
449
+ - [ ] **Step 3: Run tests.**
450
+
451
+ Run: `node --test tests/markdown/effect-injection.test.js`
452
+ Expected: all banner tests pass.
453
+
454
+ ---
455
+
456
+ ### Task A6: Inject effects into `[button]`
457
+
458
+ **Files:**
459
+ - Modify: `server/services/markdown.js` - `processButtonBlocks` (line ~2241)
460
+ - Test: `tests/markdown/effect-injection.test.js`
461
+
462
+ Allowed effects on `[button]`: `breathe`, `pulse`, `shake`, `animate`, `ripple`.
463
+
464
+ - [ ] **Step 1: Write the failing tests.**
465
+
466
+ ```js
467
+ test('[button] + pulse injection (wrapping form)', async () => {
468
+ const md = `[button href="/go" pulse pulse-scale="1.05"]Click[/button]`;
469
+ const {html} = await parseMarkdown(md);
470
+ assert.match(html, /class="[^"]*\bdm-fx-pulse\b[^"]*"/);
471
+ assert.match(html, /data-fx-scale="1.05"/);
472
+ });
473
+
474
+ test('[button] + breathe injection (self-closing form)', async () => {
475
+ const md = `[button href="/go" label="Go" breathe /]`;
476
+ const {html} = await parseMarkdown(md);
477
+ assert.match(html, /class="[^"]*\bdm-fx-breathe\b[^"]*"/);
478
+ });
479
+
480
+ test('[button] without effects is unchanged', async () => {
481
+ const md = `[button href="/go"]Click[/button]`;
482
+ const {html} = await parseMarkdown(md);
483
+ assert.doesNotMatch(html, /dm-fx-/);
484
+ });
485
+ ```
486
+
487
+ Run: `node --test tests/markdown/effect-injection.test.js`
488
+ Expected: button-injection tests fail.
489
+
490
+ - [ ] **Step 2: Wire injection into the button builder.**
491
+
492
+ In `processButtonBlocks`, locate the `buildButton` inner function. After `const attrs = parseShortcodeAttrs(attrStr || '');` add:
493
+
494
+ ```js
495
+ const BUTTON_INJECTABLE = ['breathe', 'pulse', 'shake', 'animate', 'ripple'];
496
+ const injected = extractInjectedEffects(attrs, BUTTON_INJECTABLE);
497
+ ```
498
+
499
+ Then where the `classes` array is built, after the existing pushes, add:
500
+
501
+ ```js
502
+ classes.push(...injected.effectClasses);
503
+ ```
504
+
505
+ Then in the `return` statement that emits the `<a>` tag, splice `${injected.effectDataAttrs}` after `targetAttr`:
506
+
507
+ ```js
508
+ return `<a href="${escapeAttr(href)}" class="${classes.join(' ')}"${idAttr}${targetAttr}${injected.effectDataAttrs}>${icon}${label}${iconAfter}</a>`;
509
+ ```
510
+
511
+ - [ ] **Step 3: Run tests.**
512
+
513
+ Run: `node --test tests/markdown/effect-injection.test.js`
514
+ Expected: all button tests pass.
515
+
516
+ ---
517
+
518
+ ### Task A7: Inject effects into `[col]`
519
+
520
+ **Files:**
521
+ - Modify: `server/services/markdown.js` - `processGridBlocks` (function at line ~969), inside Pass 1 (`[col]…[/col]` replace)
522
+ - Test: `tests/markdown/effect-injection.test.js`
523
+
524
+ Allowed effects on `[col]`: `reveal`, `breathe`, `animate`, `ripple`. (Tighter list - `[col]` is often a layout primitive inside `[row reveal]` so we don't want competing reveal animations; we do allow standalone `reveal` for cases without a parent `[row reveal]`.)
525
+
526
+ - [ ] **Step 1: Write the failing tests.**
527
+
528
+ ```js
529
+ test('[col] + reveal injection inside [grid]', async () => {
530
+ const md = `[grid cols="2"]
531
+ [col reveal reveal-animation="fade"]
532
+ First.
533
+ [/col]
534
+ [col]
535
+ Second.
536
+ [/col]
537
+ [/grid]`;
538
+ const {html} = await parseMarkdown(md);
539
+ // First col gets the dm-fx-reveal class; second col does not.
540
+ assert.match(html, /class="[^"]*\bdm-fx-reveal\b[^"]*"/);
541
+ assert.match(html, /data-fx-animation="fade"/);
542
+ });
543
+
544
+ test('[col] + breathe injection', async () => {
545
+ const md = `[grid cols="1"]
546
+ [col breathe]
547
+ Calm.
548
+ [/col]
549
+ [/grid]`;
550
+ const {html} = await parseMarkdown(md);
551
+ assert.match(html, /class="[^"]*\bdm-fx-breathe\b[^"]*"/);
552
+ });
553
+ ```
554
+
555
+ Run: `node --test tests/markdown/effect-injection.test.js`
556
+ Expected: col-injection tests fail.
557
+
558
+ - [ ] **Step 2: Wire injection into Pass 1 of processGridBlocks.**
559
+
560
+ In `processGridBlocks`, locate Pass 1 (the `\[col([^\]]*)\]…` replace). Inside the callback, after `const attrs = parseShortcodeAttrs(attrStr);` add:
561
+
562
+ ```js
563
+ const COL_INJECTABLE = ['reveal', 'breathe', 'animate', 'ripple'];
564
+ const injected = extractInjectedEffects(attrs, COL_INJECTABLE);
565
+ ```
566
+
567
+ Change the class assignment from:
568
+
569
+ ```js
570
+ const cls = attrs.span ? ` class="col-span-${attrs.span}"` : ' class="col"';
571
+ ```
572
+
573
+ to:
574
+
575
+ ```js
576
+ const baseCls = attrs.span ? `col-span-${attrs.span}` : 'col';
577
+ const allClasses = [baseCls, ...injected.effectClasses].join(' ');
578
+ const cls = ` class="${allClasses}"`;
579
+ ```
580
+
581
+ Then in the return, splice `${injected.effectDataAttrs}` into the opening div:
582
+
583
+ ```js
584
+ return `<div${cls}${id}${injected.effectDataAttrs}>${marked.parse(processCardBlocks(restore(body.trim())))}</div>`;
585
+ ```
586
+
587
+ - [ ] **Step 3: Run tests.**
588
+
589
+ Run: `node --test tests/markdown/effect-injection.test.js`
590
+ Expected: all col tests pass.
591
+
592
+ ---
593
+
594
+ ### Phase A - User Checkpoint
595
+
596
+ Run the full test file once more - `node --test tests/markdown/effect-injection.test.js` - and confirm all tests pass. At this point, server-side injection is feature-complete. You can manually verify by adding to a test page:
597
+
598
+ ```markdown
599
+ [card title="Test" reveal reveal-animation="slide-up"]
600
+ This card should slide up on scroll.
601
+ [/card]
602
+ ```
603
+
604
+ …then load the page in a browser at `http://192.168.1.4:4096/<page-url>` and watch the card slide up. **This is browser-required verification** - server tests do not prove the runtime initialiser fires.
605
+
606
+ User commits at this point if happy.
607
+
608
+ ---
609
+
610
+ ## Phase B - Effect catalogue
611
+
612
+ ### Task B1: Create `effect-defs.js`
613
+
614
+ **Files:**
615
+ - Create: `admin/js/lib/effect-defs.js`
616
+
617
+ This is pure data. No tests required beyond the syntax check at the end - but the *shape* must be right because every consumer downstream pivots off it.
618
+
619
+ - [ ] **Step 1: Write the catalogue.**
620
+
621
+ Create `admin/js/lib/effect-defs.js`:
622
+
623
+ ```js
624
+ /**
625
+ * Declarative catalogue of Domma effects available in the page editor.
626
+ *
627
+ * Each entry drives both the slideover form (parameter UI) and the
628
+ * shortcode generation (which attribute names get emitted).
629
+ *
630
+ * Field shapes used by attrs[]:
631
+ * - { name, label, type: 'text', default?, placeholder?, hint? }
632
+ * - { name, label, type: 'number', default?, min?, max?, step?, hint? }
633
+ * - { name, label, type: 'select', default?, options: [['val','Label'], …], hint? }
634
+ * - { name, label, type: 'checkbox', default?, hint? }
635
+ * - { name, label, type: 'colour', default?, hint? }
636
+ *
637
+ * `hosts`: array of host shortcode names this effect can be injected into.
638
+ * Empty array means standalone-only.
639
+ * `previewable`: whether the slideover's live render preview can host this
640
+ * effect. Full-page-only effects are false (we show a static
641
+ * placeholder instead).
642
+ */
643
+ export const EFFECTS = [
644
+ // ── Entrance ────────────────────────────────────────────────────────────
645
+ {
646
+ name: 'reveal',
647
+ label: 'Reveal',
648
+ category: 'Entrance',
649
+ description: 'Scroll-triggered entrance animation when the element enters the viewport.',
650
+ attrs: [
651
+ {name: 'animation', label: 'Animation', type: 'select', default: 'fade',
652
+ options: [['fade','Fade'],['slide-up','Slide up'],['slide-down','Slide down'],['zoom','Zoom'],['flip','Flip']]},
653
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 600},
654
+ {name: 'delay', label: 'Delay (ms)', type: 'number', default: 0},
655
+ {name: 'threshold', label: 'Threshold', type: 'number', default: 0.1, step: 0.05, min: 0, max: 1},
656
+ {name: 'once', label: 'Animate once', type: 'checkbox', default: true},
657
+ ],
658
+ hosts: ['card', 'hero', 'banner', 'col'],
659
+ previewable: true,
660
+ },
661
+
662
+ // ── Animation ───────────────────────────────────────────────────────────
663
+ {
664
+ name: 'breathe',
665
+ label: 'Breathe',
666
+ category: 'Animation',
667
+ description: 'Continuous gentle scaling - makes elements feel alive.',
668
+ attrs: [
669
+ {name: 'amplitude', label: 'Amplitude (px)', type: 'number', default: 8},
670
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 3000},
671
+ ],
672
+ hosts: ['card', 'hero', 'col', 'button'],
673
+ previewable: true,
674
+ },
675
+ {
676
+ name: 'pulse',
677
+ label: 'Pulse',
678
+ category: 'Animation',
679
+ description: 'A repeating scale pulse - stronger than breathe.',
680
+ attrs: [
681
+ {name: 'scale', label: 'Scale', type: 'number', default: 1.05, step: 0.01},
682
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 1000},
683
+ ],
684
+ hosts: ['card', 'hero', 'banner', 'button'],
685
+ previewable: true,
686
+ },
687
+ {
688
+ name: 'shake',
689
+ label: 'Shake',
690
+ category: 'Animation',
691
+ description: 'One-shot horizontal or vertical shake.',
692
+ attrs: [
693
+ {name: 'intensity', label: 'Intensity (px)', type: 'number', default: 5},
694
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 500},
695
+ {name: 'direction', label: 'Direction', type: 'select', default: 'horizontal',
696
+ options: [['horizontal','Horizontal'],['vertical','Vertical']]},
697
+ ],
698
+ hosts: ['card', 'banner', 'button'],
699
+ previewable: true,
700
+ },
701
+ {
702
+ name: 'animate',
703
+ label: 'CSS Animate',
704
+ category: 'Animation',
705
+ description: 'Applies a Domma CSS animation utility class - no JavaScript.',
706
+ attrs: [
707
+ {name: 'type', label: 'Type', type: 'select', default: 'fade-in-up',
708
+ options: [
709
+ ['fade-in','fade-in'],['fade-in-up','fade-in-up'],['fade-in-down','fade-in-down'],
710
+ ['fade-in-left','fade-in-left'],['fade-in-right','fade-in-right'],
711
+ ['slide-in-up','slide-in-up'],['slide-in-down','slide-in-down'],
712
+ ['zoom-in','zoom-in'],['zoom-out','zoom-out'],
713
+ ['bounce','bounce'],['pulse','pulse'],['shake','shake'],['spin','spin'],['flip','flip'],
714
+ ]},
715
+ {name: 'duration', label: 'Duration', type: 'select', default: 'normal',
716
+ options: [['fast','fast'],['normal','normal'],['slow','slow'],['slower','slower']]},
717
+ {name: 'delay', label: 'Delay (ms)', type: 'number', default: 0},
718
+ {name: 'repeat', label: 'Repeat', type: 'select', default: 'once',
719
+ options: [['once','Once'],['infinite','Infinite']]},
720
+ ],
721
+ hosts: ['card', 'hero', 'banner', 'col', 'button'],
722
+ previewable: true,
723
+ },
724
+
725
+ // ── Text ────────────────────────────────────────────────────────────────
726
+ {
727
+ name: 'scribe',
728
+ label: 'Scribe (typewriter)',
729
+ category: 'Text',
730
+ description: 'Types text character by character. Simple mode only - for the action-script composer use Insert → Scribe.',
731
+ attrs: [
732
+ {name: 'speed', label: 'Speed (ms/char)', type: 'number', default: 50},
733
+ {name: 'cursor', label: 'Show cursor', type: 'checkbox', default: true},
734
+ {name: 'loop', label: 'Loop', type: 'checkbox', default: false},
735
+ ],
736
+ hosts: [],
737
+ previewable: true,
738
+ },
739
+ {
740
+ name: 'scramble',
741
+ label: 'Scramble',
742
+ category: 'Text',
743
+ description: 'Reveals text through a character-scramble animation.',
744
+ attrs: [
745
+ {name: 'speed', label: 'Speed (ms/char)', type: 'number', default: 50},
746
+ {name: 'loop', label: 'Loop', type: 'checkbox', default: false},
747
+ ],
748
+ hosts: [],
749
+ previewable: true,
750
+ },
751
+ {
752
+ name: 'counter',
753
+ label: 'Counter',
754
+ category: 'Text',
755
+ description: 'Animates a number from a starting value to a target. Self-closing shortcode.',
756
+ attrs: [
757
+ {name: 'to', label: 'To', type: 'number', default: 100},
758
+ {name: 'from', label: 'From', type: 'number', default: 0},
759
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 2000},
760
+ {name: 'prefix', label: 'Prefix', type: 'text', default: ''},
761
+ {name: 'suffix', label: 'Suffix', type: 'text', default: ''},
762
+ {name: 'decimals', label: 'Decimal places', type: 'number', default: 0},
763
+ {name: 'separator', label: 'Thousand separator', type: 'text', default: ''},
764
+ ],
765
+ hosts: [],
766
+ previewable: true,
767
+ selfClosing: true, // counter has no body
768
+ },
769
+
770
+ // ── Visual ──────────────────────────────────────────────────────────────
771
+ {
772
+ name: 'ripple',
773
+ label: 'Ripple',
774
+ category: 'Visual',
775
+ description: 'Click-triggered ripple effect on the element.',
776
+ attrs: [
777
+ {name: 'colour', label: 'Colour', type: 'colour', default: '#7c6af7'},
778
+ {name: 'duration', label: 'Duration (ms)', type: 'number', default: 600},
779
+ {name: 'opacity', label: 'Opacity', type: 'number', default: 0.3, step: 0.05, min: 0, max: 1},
780
+ ],
781
+ hosts: ['card', 'col', 'button'],
782
+ previewable: true,
783
+ },
784
+ {
785
+ name: 'twinkle',
786
+ label: 'Twinkle',
787
+ category: 'Visual',
788
+ description: 'Generates animated particle effects inside the element.',
789
+ attrs: [
790
+ {name: 'count', label: 'Particle count', type: 'number', default: 80},
791
+ {name: 'shape', label: 'Shape', type: 'select', default: 'star',
792
+ options: [['star','Star'],['circle','Circle'],['square','Square']]},
793
+ {name: 'colour', label: 'Colour', type: 'colour', default: '#ffffff'},
794
+ {name: 'min-size', label: 'Min size (px)', type: 'number', default: 2},
795
+ {name: 'max-size', label: 'Max size (px)', type: 'number', default: 6},
796
+ ],
797
+ hosts: ['card', 'hero'],
798
+ previewable: true,
799
+ },
800
+ {
801
+ name: 'ambient',
802
+ label: 'Ambient background',
803
+ category: 'Visual',
804
+ description: 'Applies an animated CSS background class to the element.',
805
+ attrs: [
806
+ {name: 'type', label: 'Type', type: 'select', default: 'float-blobs',
807
+ options: [
808
+ ['float-blobs','Float blobs'],['float-orbs','Float orbs'],['float-particles','Float particles'],
809
+ ['rotate-glow','Rotate glow'],['rotate-dual','Rotate dual'],['rotate-spotlight','Rotate spotlight'],
810
+ ['wave','Wave'],['ripple','Ripple'],['aurora','Aurora'],
811
+ ]},
812
+ {name: 'speed', label: 'Speed', type: 'select', default: 'normal',
813
+ options: [['slow','Slow'],['normal','Normal'],['fast','Fast']]},
814
+ {name: 'intensity', label: 'Intensity', type: 'select', default: 'subtle',
815
+ options: [['subtle','Subtle'],['intense','Intense']]},
816
+ ],
817
+ hosts: ['card', 'hero', 'banner'],
818
+ previewable: true,
819
+ },
820
+
821
+ // ── Celebrations ────────────────────────────────────────────────────────
822
+ {
823
+ name: 'firework',
824
+ label: 'Firework',
825
+ category: 'Celebrations',
826
+ description: 'CSS-only firework particle. Self-closing or wrapping form.',
827
+ attrs: [
828
+ {name: 'type', label: 'Type', type: 'select', default: 'burst',
829
+ options: [['burst','Burst'],['sparkle','Sparkle'],['trail','Trail']]},
830
+ {name: 'colour', label: 'Colour theme', type: 'select', default: 'rainbow',
831
+ options: [['rainbow','Rainbow'],['primary','Primary'],['info','Info'],['success','Success'],['warning','Warning'],['danger','Danger']]},
832
+ {name: 'size', label: 'Size', type: 'select', default: 'md',
833
+ options: [['sm','Small'],['md','Medium'],['lg','Large']]},
834
+ {name: 'continuous', label: 'Continuous', type: 'checkbox', default: false},
835
+ {name: 'hover', label: 'On hover only', type: 'checkbox', default: false},
836
+ ],
837
+ hosts: [],
838
+ previewable: true,
839
+ },
840
+ {
841
+ name: 'fireworks',
842
+ label: 'Fireworks show',
843
+ category: 'Celebrations',
844
+ description: 'A container with multiple firework particles inside.',
845
+ attrs: [],
846
+ hosts: [],
847
+ previewable: false, // container of children - preview via individual fireworks
848
+ },
849
+ {
850
+ name: 'celebrate',
851
+ label: 'Celebrate',
852
+ category: 'Celebrations',
853
+ description: 'Canvas-based seasonal particle system. Runs full-page on the published site.',
854
+ attrs: [
855
+ {name: 'theme', label: 'Theme', type: 'select', default: 'auto',
856
+ options: [
857
+ ['auto','Auto (date-detected)'],['christmas','Christmas'],['halloween','Halloween'],
858
+ ['valentines','Valentines'],['guy-fawkes','Guy Fawkes'],['st-patricks',"St Patrick's"],
859
+ ['st-davids',"St David's"],['st-georges',"St George's"],['st-andrews',"St Andrew's"],
860
+ ]},
861
+ {name: 'intensity', label: 'Intensity', type: 'select', default: 'medium',
862
+ options: [['light','Light'],['medium','Medium'],['heavy','Heavy']]},
863
+ ],
864
+ hosts: [],
865
+ previewable: false, // full-page only
866
+ selfClosing: true,
867
+ },
868
+ {
869
+ name: 'ticker-tape',
870
+ label: 'Ticker tape',
871
+ category: 'Celebrations',
872
+ description: 'Coloured rectangular strips fall from the top, sway, rotate, and fade. Self-closing for full-page; wrapping form for container-scoped.',
873
+ attrs: [
874
+ {name: 'palette', label: 'Palette', type: 'select', default: 'theme',
875
+ options: [['theme','Theme'],['rainbow','Rainbow'],['gold','Gold'],['silver','Silver'],['festive','Festive'],['pastel','Pastel'],['mono','Mono']]},
876
+ {name: 'density', label: 'Density', type: 'number', default: 50},
877
+ {name: 'speed', label: 'Speed multiplier', type: 'number', default: 1, step: 0.1},
878
+ {name: 'sway', label: 'Sway (px)', type: 'number', default: 60},
879
+ {name: 'burst', label: 'Burst mode (one-shot)', type: 'checkbox', default: false},
880
+ {name: 'burst-count', label: 'Burst count', type: 'number', default: 150},
881
+ ],
882
+ hosts: ['hero'],
883
+ previewable: true, // container mode previews; page mode shows static placeholder via opt-out check
884
+ },
885
+ ];
886
+
887
+ /**
888
+ * Look up an effect definition by name.
889
+ *
890
+ * @param {string} name
891
+ * @returns {object|null}
892
+ */
893
+ export function getEffect(name) {
894
+ return EFFECTS.find(e => e.name === name) || null;
895
+ }
896
+
897
+ /**
898
+ * Effects grouped by category, in display order.
899
+ *
900
+ * @returns {{category: string, effects: object[]}[]}
901
+ */
902
+ export function effectsByCategory() {
903
+ const order = ['Entrance', 'Animation', 'Text', 'Visual', 'Celebrations'];
904
+ return order.map(cat => ({
905
+ category: cat,
906
+ effects: EFFECTS.filter(e => e.category === cat),
907
+ }));
908
+ }
909
+
910
+ /**
911
+ * The list of effects that may be injected into the given host shortcode.
912
+ *
913
+ * @param {string} hostName - 'card' | 'hero' | 'banner' | 'col' | 'button'
914
+ * @returns {object[]}
915
+ */
916
+ export function effectsInjectableInto(hostName) {
917
+ return EFFECTS.filter(e => e.hosts.includes(hostName));
918
+ }
919
+ ```
920
+
921
+ - [ ] **Step 2: Syntax check.**
922
+
923
+ Run: `node --check admin/js/lib/effect-defs.js`
924
+ Expected: no output (clean parse).
925
+
926
+ - [ ] **Step 3: Smoke test the API.**
927
+
928
+ Run:
929
+
930
+ ```bash
931
+ node -e "import('./admin/js/lib/effect-defs.js').then(m => {
932
+ console.log('total:', m.EFFECTS.length);
933
+ console.log('reveal hosts:', m.getEffect('reveal').hosts);
934
+ console.log('categories:', m.effectsByCategory().map(c => c.category + '=' + c.effects.length).join(', '));
935
+ console.log('card-injectable:', m.effectsInjectableInto('card').map(e => e.name).join(', '));
936
+ })"
937
+ ```
938
+
939
+ Expected output:
940
+ ```
941
+ total: 14
942
+ reveal hosts: [ 'card', 'hero', 'banner', 'col' ]
943
+ categories: Entrance=1, Animation=4, Text=3, Visual=3, Celebrations=4
944
+ card-injectable: reveal, breathe, pulse, shake, animate, ripple, twinkle, ambient
945
+ ```
946
+
947
+ (Ordering of `card-injectable` reflects the EFFECTS array order, not the matrix table order - both are correct.)
948
+
949
+ ---
950
+
951
+ ### Phase B - User Checkpoint
952
+
953
+ The catalogue is data-only - no UI behaviour to verify yet. Move on to Phase C when ready.
954
+
955
+ ---
956
+
957
+ ## Phase C - Slideover skeleton
958
+
959
+ ### Task C1: Create `effects-builder.js` with open/close
960
+
961
+ **Files:**
962
+ - Create: `admin/js/lib/effects-builder.js`
963
+
964
+ This task gives us a working slideover that opens, displays a placeholder, and closes. Everything else builds on top.
965
+
966
+ - [ ] **Step 1: Write the skeleton.**
967
+
968
+ Create `admin/js/lib/effects-builder.js`:
969
+
970
+ ```js
971
+ /**
972
+ * Effects Builder - unified slideover for inserting, editing, and injecting
973
+ * Domma effects in the page editor. Replaces the old flat dropdown.
974
+ *
975
+ * Public API:
976
+ * EffectsBuilder.open(textarea)
977
+ *
978
+ * The slideover detects the cursor context inside `textarea` to decide
979
+ * whether to open in standalone-insert, edit, or inject mode (see the
980
+ * effects-slideover-overhaul-design spec for details).
981
+ */
982
+ import {EFFECTS, getEffect, effectsByCategory, effectsInjectableInto} from './effect-defs.js?v=1';
983
+
984
+ let _current = null;
985
+
986
+ export const EffectsBuilder = {
987
+ open(textarea) {
988
+ if (_current) return;
989
+ const state = {
990
+ textarea,
991
+ activeEffect: S.get('effects-builder-last') || 'reveal',
992
+ scope: 'standalone', // 'standalone' | 'inject'
993
+ host: null, // {name, openTagStart, openTagEnd, closeTagStart, closeTagEnd, attrs} when scope = inject
994
+ editing: null, // {name, blockStart, blockEnd, openTagEnd, attrs} when editing existing effect
995
+ fieldValues: {}, // current form values keyed by attr name
996
+ };
997
+ const slideover = E.slideover({
998
+ title: 'Effects',
999
+ size: 'lg',
1000
+ position: 'right',
1001
+ });
1002
+ const body = _buildLayout(state, slideover);
1003
+ slideover.element.appendChild(body);
1004
+ slideover.open();
1005
+ I.scan(body);
1006
+ _current = {state, slideover};
1007
+ },
1008
+
1009
+ close() {
1010
+ if (_current) {
1011
+ _current.slideover.close();
1012
+ _current = null;
1013
+ }
1014
+ },
1015
+ };
1016
+
1017
+ function _buildLayout(state, slideover) {
1018
+ const root = document.createElement('div');
1019
+ root.style.cssText = 'display:grid;grid-template-columns:170px 1fr;height:100%;min-height:520px;';
1020
+
1021
+ const rail = document.createElement('div');
1022
+ rail.className = 'effects-rail';
1023
+ rail.style.cssText = 'background:var(--dm-surface-subtle,#15152a);border-right:1px solid var(--dm-border,#2a2a40);padding:.5rem .35rem;font-size:.8rem;overflow-y:auto;';
1024
+ rail.textContent = 'Effects rail (filled in C2)';
1025
+
1026
+ const pane = document.createElement('div');
1027
+ pane.className = 'effects-pane';
1028
+ pane.style.cssText = 'padding:1rem;display:flex;flex-direction:column;gap:.75rem;overflow-y:auto;';
1029
+ pane.textContent = 'Effects pane (filled in C3+)';
1030
+
1031
+ root.appendChild(rail);
1032
+ root.appendChild(pane);
1033
+ return root;
1034
+ }
1035
+ ```
1036
+
1037
+ - [ ] **Step 2: Syntax check.**
1038
+
1039
+ Run: `node --check admin/js/lib/effects-builder.js`
1040
+ Expected: no output.
1041
+
1042
+ - [ ] **Step 3: Wire a temporary test trigger.**
1043
+
1044
+ For C1 we just need a quick way to open the slideover. Don't permanently wire the toolbar yet - that's Task H2. Instead, open the browser dev console once C1 is in place and run:
1045
+
1046
+ ```js
1047
+ import('/admin/js/lib/effects-builder.js?v=1').then(m => m.EffectsBuilder.open(document.getElementById('markdown-editor')));
1048
+ ```
1049
+
1050
+ …to verify the skeleton renders.
1051
+
1052
+ - [ ] **Step 4: Browser verification.**
1053
+
1054
+ Open `http://192.168.1.4:4096/admin/#/pages` in a browser, edit any page, then run the dev-console snippet above.
1055
+
1056
+ Expected: a slideover opens on the right side; left rail shows "Effects rail (filled in C2)"; pane shows "Effects pane (filled in C3+)". Closing with the X or Esc dismisses it.
1057
+
1058
+ If the slideover doesn't open: check the dev console for import errors. If it opens but layout is wrong: re-check the inline grid styles. **Do not proceed to C2 until this works in the browser.**
1059
+
1060
+ ---
1061
+
1062
+ ### Task C2: Render category rail + effect list
1063
+
1064
+ **Files:**
1065
+ - Modify: `admin/js/lib/effects-builder.js`
1066
+
1067
+ - [ ] **Step 1: Add a `_renderRail` function.**
1068
+
1069
+ Above `_buildLayout`, add:
1070
+
1071
+ ```js
1072
+ function _renderRail(rail, state, onChange) {
1073
+ rail.replaceChildren();
1074
+ for (const group of effectsByCategory()) {
1075
+ const heading = document.createElement('div');
1076
+ heading.style.cssText = 'color:var(--dm-text-muted,#888);text-transform:uppercase;font-size:.65rem;letter-spacing:.05em;margin:.4rem .35rem .25rem;';
1077
+ heading.textContent = group.category;
1078
+ rail.appendChild(heading);
1079
+
1080
+ for (const effect of group.effects) {
1081
+ const item = document.createElement('button');
1082
+ item.type = 'button';
1083
+ item.dataset.effect = effect.name;
1084
+ item.style.cssText = 'display:block;width:100%;text-align:left;padding:.3rem .5rem;border:none;background:transparent;color:var(--dm-text,#ddd);border-radius:3px;font-size:.8rem;cursor:pointer;margin-bottom:.1rem;';
1085
+ item.textContent = effect.label;
1086
+ if (effect.name === state.activeEffect) {
1087
+ item.style.background = 'var(--dm-primary,#4338ca)';
1088
+ item.style.color = '#fff';
1089
+ }
1090
+ item.addEventListener('click', () => {
1091
+ state.activeEffect = effect.name;
1092
+ S.set('effects-builder-last', effect.name);
1093
+ state.fieldValues = {}; // reset form values when switching
1094
+ _renderRail(rail, state, onChange);
1095
+ onChange();
1096
+ });
1097
+ rail.appendChild(item);
1098
+ }
1099
+ }
1100
+ }
1101
+ ```
1102
+
1103
+ - [ ] **Step 2: Add a stub `_renderPane`.**
1104
+
1105
+ Above `_renderRail`, add:
1106
+
1107
+ ```js
1108
+ function _renderPane(pane, state, slideover) {
1109
+ const effect = getEffect(state.activeEffect);
1110
+ pane.replaceChildren();
1111
+
1112
+ const heading = document.createElement('h3');
1113
+ heading.style.cssText = 'margin:0;font-size:1rem;color:var(--dm-text,#eee);';
1114
+ heading.textContent = effect.label;
1115
+ pane.appendChild(heading);
1116
+
1117
+ const desc = document.createElement('p');
1118
+ desc.style.cssText = 'margin:0;color:var(--dm-text-muted,#999);font-size:.8rem;line-height:1.4;';
1119
+ desc.textContent = effect.description;
1120
+ pane.appendChild(desc);
1121
+
1122
+ const placeholder = document.createElement('div');
1123
+ placeholder.style.cssText = 'padding:1rem;color:var(--dm-text-muted,#888);font-style:italic;font-size:.8rem;';
1124
+ placeholder.textContent = 'Form fields render in D1.';
1125
+ pane.appendChild(placeholder);
1126
+ }
1127
+ ```
1128
+
1129
+ - [ ] **Step 3: Update `_buildLayout` to wire the rail and pane.**
1130
+
1131
+ Replace:
1132
+
1133
+ ```js
1134
+ rail.textContent = 'Effects rail (filled in C2)';
1135
+ …
1136
+ pane.textContent = 'Effects pane (filled in C3+)';
1137
+ ```
1138
+
1139
+ With:
1140
+
1141
+ ```js
1142
+ _renderRail(rail, state, () => _renderPane(pane, state, slideover));
1143
+ _renderPane(pane, state, slideover);
1144
+ ```
1145
+
1146
+ - [ ] **Step 4: Syntax check.**
1147
+
1148
+ Run: `node --check admin/js/lib/effects-builder.js`
1149
+ Expected: clean.
1150
+
1151
+ - [ ] **Step 5: Browser verification.**
1152
+
1153
+ Reload the admin page (hard reload - Cmd/Ctrl+Shift+R). Open the slideover via the dev console snippet from C1.
1154
+
1155
+ Expected:
1156
+ - Left rail shows five categories with their effects underneath.
1157
+ - The active effect (Reveal by default, or whatever's in `S.get('effects-builder-last')`) is highlighted indigo.
1158
+ - Clicking another effect highlights it and updates the right pane's heading and description.
1159
+ - Closing and reopening preserves the last-clicked effect.
1160
+
1161
+ ---
1162
+
1163
+ ### Phase C - User Checkpoint
1164
+
1165
+ The slideover opens, lists every effect in its category, and the user can navigate between them. Form fields, preview, and insert behaviour come next. User commits if happy.
1166
+
1167
+ ---
1168
+
1169
+ ## Phase D - Form fields, shortcode preview, insert
1170
+
1171
+ ### Task D1: Render form fields per active effect
1172
+
1173
+ **Files:**
1174
+ - Modify: `admin/js/lib/effects-builder.js`
1175
+
1176
+ - [ ] **Step 1: Add helpers for form controls.**
1177
+
1178
+ Inside `effects-builder.js`, above `_renderPane`, add:
1179
+
1180
+ ```js
1181
+ function _makeInput(attr, currentValue) {
1182
+ if (attr.type === 'select') {
1183
+ const sel = document.createElement('select');
1184
+ sel.style.cssText = 'width:100%;padding:.35rem .5rem;background:var(--dm-input-bg,#1a1a1a);border:1px solid var(--dm-border,#333);border-radius:3px;color:var(--dm-text,#eee);font-size:.85rem;';
1185
+ for (const [val, label] of attr.options) {
1186
+ const opt = document.createElement('option');
1187
+ opt.value = val;
1188
+ opt.textContent = label;
1189
+ sel.appendChild(opt);
1190
+ }
1191
+ sel.value = currentValue ?? attr.default ?? '';
1192
+ return sel;
1193
+ }
1194
+ if (attr.type === 'checkbox') {
1195
+ const cb = document.createElement('input');
1196
+ cb.type = 'checkbox';
1197
+ cb.checked = currentValue ?? attr.default ?? false;
1198
+ return cb;
1199
+ }
1200
+ const inp = document.createElement('input');
1201
+ inp.type = attr.type === 'number' ? 'number' : (attr.type === 'colour' ? 'color' : 'text');
1202
+ inp.value = currentValue ?? attr.default ?? '';
1203
+ if (attr.placeholder) inp.placeholder = attr.placeholder;
1204
+ if (attr.type === 'number') {
1205
+ if (attr.min != null) inp.min = attr.min;
1206
+ if (attr.max != null) inp.max = attr.max;
1207
+ if (attr.step != null) inp.step = attr.step;
1208
+ }
1209
+ inp.style.cssText = 'width:100%;padding:.35rem .5rem;background:var(--dm-input-bg,#1a1a1a);border:1px solid var(--dm-border,#333);border-radius:3px;color:var(--dm-text,#eee);font-size:.85rem;';
1210
+ return inp;
1211
+ }
1212
+
1213
+ function _readInput(input, attr) {
1214
+ if (attr.type === 'checkbox') return input.checked;
1215
+ if (attr.type === 'number') return input.value === '' ? '' : Number(input.value);
1216
+ return input.value;
1217
+ }
1218
+
1219
+ function _renderForm(container, state, onFieldChange) {
1220
+ const effect = getEffect(state.activeEffect);
1221
+ container.replaceChildren();
1222
+ if (effect.attrs.length === 0) {
1223
+ const empty = document.createElement('div');
1224
+ empty.style.cssText = 'color:var(--dm-text-muted,#888);font-size:.8rem;font-style:italic;';
1225
+ empty.textContent = 'This effect takes no parameters.';
1226
+ container.appendChild(empty);
1227
+ return;
1228
+ }
1229
+ const grid = document.createElement('div');
1230
+ grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:.6rem .7rem;';
1231
+ for (const attr of effect.attrs) {
1232
+ const wrap = document.createElement('div');
1233
+ const lbl = document.createElement('label');
1234
+ lbl.style.cssText = 'display:block;color:var(--dm-text-muted,#888);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.2rem;';
1235
+ lbl.textContent = attr.label;
1236
+ wrap.appendChild(lbl);
1237
+ const input = _makeInput(attr, state.fieldValues[attr.name]);
1238
+ input.addEventListener('input', () => {
1239
+ state.fieldValues[attr.name] = _readInput(input, attr);
1240
+ onFieldChange();
1241
+ });
1242
+ input.addEventListener('change', () => {
1243
+ state.fieldValues[attr.name] = _readInput(input, attr);
1244
+ onFieldChange();
1245
+ });
1246
+ wrap.appendChild(input);
1247
+ if (attr.hint) {
1248
+ const hint = document.createElement('div');
1249
+ hint.style.cssText = 'color:var(--dm-text-muted,#666);font-size:.7rem;margin-top:.15rem;';
1250
+ hint.textContent = attr.hint;
1251
+ wrap.appendChild(hint);
1252
+ }
1253
+ grid.appendChild(wrap);
1254
+ }
1255
+ container.appendChild(grid);
1256
+ }
1257
+ ```
1258
+
1259
+ - [ ] **Step 2: Wire form into `_renderPane`.**
1260
+
1261
+ Replace the placeholder div in `_renderPane` with:
1262
+
1263
+ ```js
1264
+ const formContainer = document.createElement('div');
1265
+ pane.appendChild(formContainer);
1266
+
1267
+ _renderForm(formContainer, state, () => {
1268
+ // Rebuild only the preview when fields change (not the form itself -
1269
+ // we'd lose focus on the input the user is typing into).
1270
+ });
1271
+ ```
1272
+
1273
+ - [ ] **Step 3: Syntax check.**
1274
+
1275
+ Run: `node --check admin/js/lib/effects-builder.js`
1276
+ Expected: clean.
1277
+
1278
+ - [ ] **Step 4: Browser verification.**
1279
+
1280
+ Reload, reopen the slideover. Cycle through every effect in the rail; each should render its parameter form, and the form should reset to defaults when switching effects. Type into a field and verify focus is preserved.
1281
+
1282
+ Expected: every effect's form renders without errors. Console clean.
1283
+
1284
+ ---
1285
+
1286
+ ### Task D2: Live shortcode preview
1287
+
1288
+ **Files:**
1289
+ - Modify: `admin/js/lib/effects-builder.js`
1290
+
1291
+ - [ ] **Step 1: Add `_buildShortcode` and `_renderShortcodePreview`.**
1292
+
1293
+ Above `_renderPane`, add:
1294
+
1295
+ ```js
1296
+ function _serialiseAttrs(state, effect) {
1297
+ const parts = [];
1298
+ for (const attr of effect.attrs) {
1299
+ const v = state.fieldValues[attr.name];
1300
+ // Skip values equal to default (keeps shortcode tidy).
1301
+ if (v === undefined || v === '' || v === null) continue;
1302
+ if (v === attr.default) continue;
1303
+ if (attr.type === 'checkbox') {
1304
+ // Booleans: emit 'true' or 'false' explicitly so the parser sees them.
1305
+ parts.push(`${attr.name}="${v ? 'true' : 'false'}"`);
1306
+ } else {
1307
+ parts.push(`${attr.name}="${String(v).replace(/"/g, '&quot;')}"`);
1308
+ }
1309
+ }
1310
+ return parts.join(' ');
1311
+ }
1312
+
1313
+ function _buildShortcode(state) {
1314
+ const effect = getEffect(state.activeEffect);
1315
+ const attrStr = _serialiseAttrs(state, effect);
1316
+ const space = attrStr ? ' ' : '';
1317
+ if (state.scope === 'inject' && state.host) {
1318
+ // Inject form: rewrite host's opening tag with effect-flag attrs prefixed.
1319
+ // Implemented in F2; preview here just shows the standalone form.
1320
+ }
1321
+ if (effect.selfClosing) {
1322
+ return `[${effect.name}${space}${attrStr} /]`;
1323
+ }
1324
+ return `[${effect.name}${space}${attrStr}]\nContent\n[/${effect.name}]`;
1325
+ }
1326
+
1327
+ function _renderShortcodePreview(container, state) {
1328
+ container.replaceChildren();
1329
+ const lbl = document.createElement('div');
1330
+ lbl.style.cssText = 'color:var(--dm-text-muted,#888);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem;';
1331
+ lbl.textContent = 'Shortcode (live)';
1332
+ container.appendChild(lbl);
1333
+
1334
+ const pre = document.createElement('pre');
1335
+ pre.style.cssText = 'background:var(--dm-surface-deep,#000);border:1px solid var(--dm-border,#333);padding:.5rem .65rem;border-radius:3px;font-family:var(--dm-font-mono,monospace);font-size:.75rem;color:#7c6af7;line-height:1.5;white-space:pre-wrap;word-break:break-all;margin:0;';
1336
+ pre.textContent = _buildShortcode(state);
1337
+ container.appendChild(pre);
1338
+ }
1339
+ ```
1340
+
1341
+ - [ ] **Step 2: Wire shortcode preview into `_renderPane`.**
1342
+
1343
+ After the `formContainer` block, add:
1344
+
1345
+ ```js
1346
+ const shortcodeBox = document.createElement('div');
1347
+ pane.appendChild(shortcodeBox);
1348
+ _renderShortcodePreview(shortcodeBox, state);
1349
+ ```
1350
+
1351
+ And update the form's onChange callback:
1352
+
1353
+ ```js
1354
+ _renderForm(formContainer, state, () => {
1355
+ _renderShortcodePreview(shortcodeBox, state);
1356
+ });
1357
+ ```
1358
+
1359
+ - [ ] **Step 3: Syntax check.**
1360
+
1361
+ Run: `node --check admin/js/lib/effects-builder.js`
1362
+ Expected: clean.
1363
+
1364
+ - [ ] **Step 4: Browser verification.**
1365
+
1366
+ Reload, reopen the slideover. Pick Reveal. Change Animation to "slide-up". Verify the shortcode preview updates live to `[reveal animation="slide-up"]\nContent\n[/reveal]`. Tick "Loop" on Scribe and verify the preview reflects the boolean.
1367
+
1368
+ Expected: every keystroke / change updates the shortcode pane. Defaults are omitted from output.
1369
+
1370
+ ---
1371
+
1372
+ ### Task D3: Insert button
1373
+
1374
+ **Files:**
1375
+ - Modify: `admin/js/lib/effects-builder.js`
1376
+
1377
+ - [ ] **Step 1: Add Insert button + footer to `_renderPane`.**
1378
+
1379
+ Append to the bottom of `_renderPane`:
1380
+
1381
+ ```js
1382
+ const footer = document.createElement('div');
1383
+ footer.style.cssText = 'display:flex;gap:.5rem;margin-top:auto;padding-top:.75rem;border-top:1px solid var(--dm-border,#2a2a40);';
1384
+
1385
+ const cancel = document.createElement('button');
1386
+ cancel.type = 'button';
1387
+ cancel.className = 'btn btn-ghost';
1388
+ cancel.textContent = 'Cancel';
1389
+ cancel.addEventListener('click', () => slideover.close());
1390
+ footer.appendChild(cancel);
1391
+
1392
+ const insert = document.createElement('button');
1393
+ insert.type = 'button';
1394
+ insert.className = 'btn btn-primary';
1395
+ insert.style.flex = '2';
1396
+ insert.textContent = state.editing ? 'Update' : 'Insert effect';
1397
+ insert.addEventListener('click', () => {
1398
+ _doInsert(state, slideover);
1399
+ });
1400
+ footer.appendChild(insert);
1401
+
1402
+ pane.appendChild(footer);
1403
+ ```
1404
+
1405
+ Make sure `pane.style.cssText` includes `flex-direction:column` and a `min-height:520px` (already present from C1).
1406
+
1407
+ - [ ] **Step 2: Add `_doInsert` and import `insertAtCursor`.**
1408
+
1409
+ At the top of `effects-builder.js`, add:
1410
+
1411
+ ```js
1412
+ import {insertAtCursor} from './markdown-toolbar.js?v=13';
1413
+ ```
1414
+
1415
+ (`?v=13` - we're bumping in this commit; see Task H2 below where the importer's `?v=12` becomes `?v=13` for `page-editor.js`.)
1416
+
1417
+ Below `_buildShortcode`, add:
1418
+
1419
+ ```js
1420
+ function _doInsert(state, slideover) {
1421
+ const ta = state.textarea;
1422
+ const shortcode = _buildShortcode(state);
1423
+
1424
+ if (state.editing) {
1425
+ // Replace the existing block (F3) - placeholder for now.
1426
+ // For D3, only the standalone-insert path is needed; the editing
1427
+ // path is implemented in Task F3.
1428
+ slideover.close();
1429
+ return;
1430
+ }
1431
+ if (state.scope === 'inject' && state.host) {
1432
+ // Inject mode (F2) - placeholder. F2 implements the rewrite.
1433
+ slideover.close();
1434
+ return;
1435
+ }
1436
+ insertAtCursor(ta, shortcode);
1437
+ ta.dispatchEvent(new Event('input', {bubbles: true}));
1438
+ slideover.close();
1439
+ }
1440
+ ```
1441
+
1442
+ - [ ] **Step 3: Syntax check.**
1443
+
1444
+ Run: `node --check admin/js/lib/effects-builder.js`
1445
+ Expected: clean.
1446
+
1447
+ - [ ] **Step 4: Browser verification.**
1448
+
1449
+ Reload, reopen the slideover. Pick Reveal, change Animation to "zoom", click Insert. Verify the markdown editor now contains `[reveal animation="zoom"]\nContent\n[/reveal]` at the cursor. Save the page; preview the rendered page; reveal animation should fire on scroll.
1450
+
1451
+ This is the first end-to-end smoke: slideover → write shortcode → server renders → effects.js initialises → animation runs.
1452
+
1453
+ ---
1454
+
1455
+ ### Phase D - User Checkpoint
1456
+
1457
+ The slideover can now insert effects standalone. User commits if happy. The next phase adds cursor awareness (host detection, edit-existing).
1458
+
1459
+ ---
1460
+
1461
+ ## Phase E - Cursor-context awareness
1462
+
1463
+ ### Task E1: Client-side `parseShortcodeAttrs` helper
1464
+
1465
+ **Files:**
1466
+ - Modify: `admin/js/lib/effects-builder.js`
1467
+
1468
+ We need a minimal client-side attribute parser to inspect the shortcode the cursor sits inside. Don't import from server modules - admin code is browser-only.
1469
+
1470
+ - [ ] **Step 1: Add `_parseAttrs`.**
1471
+
1472
+ Above `_serialiseAttrs`, add:
1473
+
1474
+ ```js
1475
+ /**
1476
+ * Minimal shortcode attribute parser. Mirrors the server's
1477
+ * parseShortcodeAttrs for the subset we need.
1478
+ *
1479
+ * _parseAttrs('title="Hi" reveal reveal-animation="slide-up"')
1480
+ * → { title: 'Hi', reveal: '', 'reveal-animation': 'slide-up' }
1481
+ *
1482
+ * @param {string} attrStr
1483
+ * @returns {Object<string,string>}
1484
+ */
1485
+ function _parseAttrs(attrStr) {
1486
+ const out = {};
1487
+ const re = /(\w[\w-]*)(?:\s*=\s*"([^"]*)"|\s*=\s*'([^']*)'|\s*=\s*(\S+))?/g;
1488
+ for (const m of attrStr.matchAll(re)) {
1489
+ const key = m[1];
1490
+ const val = m[2] ?? m[3] ?? m[4] ?? '';
1491
+ out[key] = val;
1492
+ }
1493
+ return out;
1494
+ }
1495
+ ```
1496
+
1497
+ - [ ] **Step 2: Smoke-test the helper.**
1498
+
1499
+ This task has no visible browser change yet - just sets up the helper for E2. Verify the syntax is clean:
1500
+
1501
+ Run: `node --check admin/js/lib/effects-builder.js`
1502
+ Expected: no output.
1503
+
1504
+ ---
1505
+
1506
+ ### Task E2: Detect host shortcode at cursor
1507
+
1508
+ **Files:**
1509
+ - Modify: `admin/js/lib/effects-builder.js`
1510
+
1511
+ - [ ] **Step 1: Add `_findEnclosingHost`.**
1512
+
1513
+ Above `_doInsert`, add:
1514
+
1515
+ ```js
1516
+ const HOST_NAMES = ['card', 'hero', 'banner', 'col', 'button'];
1517
+
1518
+ /**
1519
+ * Inspect textarea content around `cursor` and find the innermost host
1520
+ * shortcode that wraps the cursor position, if any.
1521
+ *
1522
+ * @param {HTMLTextAreaElement} ta
1523
+ * @returns {{name: string, openTagStart: number, openTagEnd: number, closeTagStart: number, closeTagEnd: number, attrs: Object}|null}
1524
+ */
1525
+ function _findEnclosingHost(ta) {
1526
+ const value = ta.value;
1527
+ const cursor = ta.selectionStart;
1528
+
1529
+ // Walk forward through all open/close tags up to the cursor; track depth.
1530
+ for (const name of HOST_NAMES) {
1531
+ const openRe = new RegExp(`\\[${name}([^\\]]*)\\]`, 'g');
1532
+ const closeRe = new RegExp(`\\[/${name}\\]`, 'g');
1533
+ const events = [];
1534
+ for (const mo of value.matchAll(openRe)) {
1535
+ if (mo.index >= cursor) break;
1536
+ events.push({type: 'open', start: mo.index, end: mo.index + mo[0].length, attrStr: mo[1]});
1537
+ }
1538
+ for (const mc of value.matchAll(closeRe)) {
1539
+ if (mc.index >= cursor) break;
1540
+ events.push({type: 'close', start: mc.index, end: mc.index + mc[0].length});
1541
+ }
1542
+ events.sort((a, b) => a.start - b.start);
1543
+ let depth = 0;
1544
+ let lastOpen = null;
1545
+ for (const ev of events) {
1546
+ if (ev.type === 'open') {
1547
+ depth++;
1548
+ lastOpen = ev;
1549
+ } else {
1550
+ depth--;
1551
+ if (depth < 0) { depth = 0; lastOpen = null; }
1552
+ }
1553
+ }
1554
+ if (depth > 0 && lastOpen) {
1555
+ // Find matching close tag *after* cursor.
1556
+ const after = new RegExp(`\\[/${name}\\]`, 'g');
1557
+ const tail = value.slice(cursor);
1558
+ const cm = tail.match(after);
1559
+ if (cm) {
1560
+ const closeStart = cursor + tail.indexOf(cm[0]);
1561
+ return {
1562
+ name,
1563
+ openTagStart: lastOpen.start,
1564
+ openTagEnd: lastOpen.end,
1565
+ closeTagStart: closeStart,
1566
+ closeTagEnd: closeStart + cm[0].length,
1567
+ attrs: _parseAttrs(lastOpen.attrStr),
1568
+ };
1569
+ }
1570
+ }
1571
+ }
1572
+ return null;
1573
+ }
1574
+ ```
1575
+
1576
+ This is intentionally simple. It does not handle pathological cases (unclosed tags, code fences containing shortcode-like text). For pages where it fails, the user just sees scope = Standalone and proceeds. No errors thrown.
1577
+
1578
+ - [ ] **Step 2: Wire detection into `open()`.**
1579
+
1580
+ In `EffectsBuilder.open`, after building `state` and **before** `E.slideover(...)`, add:
1581
+
1582
+ ```js
1583
+ state.host = _findEnclosingHost(textarea);
1584
+ if (state.host) {
1585
+ // Default to inject if cursor is inside a compatible host that
1586
+ // doesn't yet have this effect injected. Otherwise standalone.
1587
+ const compat = effectsInjectableInto(state.host.name).map(e => e.name);
1588
+ if (compat.includes(state.activeEffect) && !(state.activeEffect in state.host.attrs)) {
1589
+ state.scope = 'inject';
1590
+ }
1591
+ }
1592
+ ```
1593
+
1594
+ - [ ] **Step 3: Smoke test in dev console.**
1595
+
1596
+ Reload. Edit a page that contains:
1597
+
1598
+ ```markdown
1599
+ [card title="Hello"]
1600
+ Body content.
1601
+ [/card]
1602
+ ```
1603
+
1604
+ Position the cursor inside the body. Run:
1605
+
1606
+ ```js
1607
+ import('/admin/js/lib/effects-builder.js?v=1').then(m => m.EffectsBuilder.open(document.getElementById('markdown-editor')));
1608
+ ```
1609
+
1610
+ The slideover opens. State is internal; in F1 the Scope toggle UI will visualise this.
1611
+
1612
+ For now, manual verification: temporarily add `console.log('host:', state.host)` to `EffectsBuilder.open` after `_findEnclosingHost`, reload, place cursor inside the card body, reopen, and confirm the console logs `host: { name: 'card', … }`. Place cursor outside the card and confirm `host: null`. Remove the `console.log` once verified.
1613
+
1614
+ ---
1615
+
1616
+ ### Task E3: Detect existing effect at cursor
1617
+
1618
+ **Files:**
1619
+ - Modify: `admin/js/lib/effects-builder.js`
1620
+
1621
+ Same idea as E2 but for effect blocks instead of host shortcodes. When the cursor is inside `[reveal …]…[/reveal]`, we want to populate fields and switch to Update mode.
1622
+
1623
+ - [ ] **Step 1: Add `_findEnclosingEffect`.**
1624
+
1625
+ Above `_findEnclosingHost`, add:
1626
+
1627
+ ```js
1628
+ const EFFECT_BLOCK_NAMES = EFFECTS.filter(e => !e.selfClosing).map(e => e.name);
1629
+
1630
+ /**
1631
+ * Find the effect-block shortcode wrapping the cursor, if any.
1632
+ *
1633
+ * @param {HTMLTextAreaElement} ta
1634
+ * @returns {{name: string, blockStart: number, blockEnd: number, openTagEnd: number, attrs: Object}|null}
1635
+ */
1636
+ function _findEnclosingEffect(ta) {
1637
+ const value = ta.value;
1638
+ const cursor = ta.selectionStart;
1639
+ for (const name of EFFECT_BLOCK_NAMES) {
1640
+ const openRe = new RegExp(`\\[${name}([^\\]]*)\\]`, 'g');
1641
+ const closeRe = new RegExp(`\\[/${name}\\]`, 'g');
1642
+ const events = [];
1643
+ for (const mo of value.matchAll(openRe)) {
1644
+ if (mo.index >= cursor) break;
1645
+ events.push({type: 'open', start: mo.index, end: mo.index + mo[0].length, attrStr: mo[1]});
1646
+ }
1647
+ for (const mc of value.matchAll(closeRe)) {
1648
+ if (mc.index >= cursor) break;
1649
+ events.push({type: 'close', start: mc.index, end: mc.index + mc[0].length});
1650
+ }
1651
+ events.sort((a, b) => a.start - b.start);
1652
+ let depth = 0;
1653
+ let lastOpen = null;
1654
+ for (const ev of events) {
1655
+ if (ev.type === 'open') { depth++; lastOpen = ev; }
1656
+ else { depth--; if (depth < 0) { depth = 0; lastOpen = null; } }
1657
+ }
1658
+ if (depth > 0 && lastOpen) {
1659
+ const after = new RegExp(`\\[/${name}\\]`, 'g');
1660
+ const tail = value.slice(cursor);
1661
+ const cm = tail.match(after);
1662
+ if (cm) {
1663
+ const closeStart = cursor + tail.indexOf(cm[0]);
1664
+ return {
1665
+ name,
1666
+ blockStart: lastOpen.start,
1667
+ blockEnd: closeStart + cm[0].length,
1668
+ openTagEnd: lastOpen.end,
1669
+ attrs: _parseAttrs(lastOpen.attrStr),
1670
+ };
1671
+ }
1672
+ }
1673
+ }
1674
+ return null;
1675
+ }
1676
+ ```
1677
+
1678
+ - [ ] **Step 2: Wire detection into `open()`.**
1679
+
1680
+ After the `state.host = …` block in `EffectsBuilder.open`, add:
1681
+
1682
+ ```js
1683
+ state.editing = _findEnclosingEffect(textarea);
1684
+ if (state.editing) {
1685
+ state.activeEffect = state.editing.name;
1686
+ state.scope = 'standalone';
1687
+ // Populate fieldValues from existing attrs; coerce to expected types.
1688
+ const effect = getEffect(state.editing.name);
1689
+ for (const attr of effect.attrs) {
1690
+ if (attr.name in state.editing.attrs) {
1691
+ const raw = state.editing.attrs[attr.name];
1692
+ if (attr.type === 'number') state.fieldValues[attr.name] = Number(raw);
1693
+ else if (attr.type === 'checkbox') state.fieldValues[attr.name] = raw === 'true';
1694
+ else state.fieldValues[attr.name] = raw;
1695
+ }
1696
+ }
1697
+ }
1698
+ ```
1699
+
1700
+ (Edit-mode detection wins over host-injection: if the cursor is inside both `[card]` and a nested `[reveal]`, edit the inner reveal, not inject onto the card.)
1701
+
1702
+ - [ ] **Step 3: Smoke verify.**
1703
+
1704
+ Reload, edit a page containing:
1705
+
1706
+ ```markdown
1707
+ [reveal animation="zoom" duration="800"]
1708
+ Existing content.
1709
+ [/reveal]
1710
+ ```
1711
+
1712
+ Position cursor inside the reveal body. Open the slideover via the dev console snippet. Expected:
1713
+ - The active effect in the rail is Reveal.
1714
+ - Animation field is "zoom"; Duration is 800.
1715
+ - The Insert button label shows "Update" (we set this in D3 via `state.editing ? 'Update' : 'Insert effect'`).
1716
+
1717
+ Note: actually performing the update is implemented in F3.
1718
+
1719
+ ---
1720
+
1721
+ ### Phase E - User Checkpoint
1722
+
1723
+ Cursor awareness works internally but isn't yet visible in the UI (no scope toggle, no functional inject/update). User commits if the smoke checks pass.
1724
+
1725
+ ---
1726
+
1727
+ ## Phase F - Inject and edit modes
1728
+
1729
+ ### Task F1: Scope toggle UI
1730
+
1731
+ **Files:**
1732
+ - Modify: `admin/js/lib/effects-builder.js`
1733
+
1734
+ - [ ] **Step 1: Add `_renderScopeToggle`.**
1735
+
1736
+ Above `_renderPane`, add:
1737
+
1738
+ ```js
1739
+ function _renderScopeToggle(container, state, onChange) {
1740
+ container.replaceChildren();
1741
+ if (state.editing) return; // No scope toggle in edit mode.
1742
+
1743
+ const effect = getEffect(state.activeEffect);
1744
+ const compatible = state.host && effect.hosts.includes(state.host.name);
1745
+
1746
+ const wrap = document.createElement('div');
1747
+ wrap.style.cssText = 'background:var(--dm-surface-subtle,#15152a);border:1px solid var(--dm-border,#2a2a40);border-radius:4px;padding:.5rem .6rem;';
1748
+
1749
+ const lbl = document.createElement('div');
1750
+ lbl.style.cssText = 'color:var(--dm-text-muted,#888);text-transform:uppercase;font-size:.65rem;letter-spacing:.05em;margin-bottom:.3rem;';
1751
+ lbl.textContent = 'Apply as';
1752
+ wrap.appendChild(lbl);
1753
+
1754
+ if (!compatible) {
1755
+ const note = document.createElement('div');
1756
+ note.style.cssText = 'color:var(--dm-text-muted,#888);font-size:.75rem;font-style:italic;';
1757
+ if (state.host) {
1758
+ note.textContent = `Inserted as a standalone block (${effect.label} cannot be injected into [${state.host.name}]).`;
1759
+ } else {
1760
+ note.textContent = 'Inserted as a standalone block.';
1761
+ }
1762
+ wrap.appendChild(note);
1763
+ container.appendChild(wrap);
1764
+ state.scope = 'standalone';
1765
+ return;
1766
+ }
1767
+
1768
+ const row = document.createElement('div');
1769
+ row.style.cssText = 'display:flex;gap:.3rem;';
1770
+ function makeBtn(scope, label) {
1771
+ const b = document.createElement('button');
1772
+ b.type = 'button';
1773
+ b.style.cssText = `flex:1;padding:.35rem;border:1px solid var(--dm-border,#333);border-radius:3px;font-size:.75rem;font-weight:600;cursor:pointer;`;
1774
+ if (state.scope === scope) {
1775
+ b.style.background = 'var(--dm-primary,#4338ca)';
1776
+ b.style.color = '#fff';
1777
+ b.style.borderColor = 'var(--dm-primary,#4338ca)';
1778
+ } else {
1779
+ b.style.background = 'transparent';
1780
+ b.style.color = 'var(--dm-text-muted,#bbb)';
1781
+ }
1782
+ b.textContent = label;
1783
+ b.addEventListener('click', () => {
1784
+ state.scope = scope;
1785
+ onChange();
1786
+ });
1787
+ return b;
1788
+ }
1789
+ row.appendChild(makeBtn('standalone', 'Standalone block'));
1790
+ row.appendChild(makeBtn('inject', `Inject into [${state.host.name}]`));
1791
+ wrap.appendChild(row);
1792
+ container.appendChild(wrap);
1793
+ }
1794
+ ```
1795
+
1796
+ - [ ] **Step 2: Update `_renderPane` to include the scope toggle and a single rerender helper.**
1797
+
1798
+ Replace the body of `_renderPane` with:
1799
+
1800
+ ```js
1801
+ function _renderPane(pane, state, slideover) {
1802
+ const effect = getEffect(state.activeEffect);
1803
+ pane.replaceChildren();
1804
+
1805
+ const heading = document.createElement('h3');
1806
+ heading.style.cssText = 'margin:0;font-size:1rem;color:var(--dm-text,#eee);';
1807
+ heading.textContent = effect.label;
1808
+ pane.appendChild(heading);
1809
+
1810
+ const desc = document.createElement('p');
1811
+ desc.style.cssText = 'margin:0;color:var(--dm-text-muted,#999);font-size:.8rem;line-height:1.4;';
1812
+ desc.textContent = effect.description;
1813
+ pane.appendChild(desc);
1814
+
1815
+ const scopeBox = document.createElement('div');
1816
+ pane.appendChild(scopeBox);
1817
+
1818
+ const formContainer = document.createElement('div');
1819
+ pane.appendChild(formContainer);
1820
+
1821
+ const shortcodeBox = document.createElement('div');
1822
+ pane.appendChild(shortcodeBox);
1823
+
1824
+ function rerenderAll() {
1825
+ _renderScopeToggle(scopeBox, state, rerenderAll);
1826
+ _renderForm(formContainer, state, () => {
1827
+ _renderShortcodePreview(shortcodeBox, state);
1828
+ });
1829
+ _renderShortcodePreview(shortcodeBox, state);
1830
+ }
1831
+ rerenderAll();
1832
+
1833
+ const footer = document.createElement('div');
1834
+ footer.style.cssText = 'display:flex;gap:.5rem;margin-top:auto;padding-top:.75rem;border-top:1px solid var(--dm-border,#2a2a40);';
1835
+ const cancel = document.createElement('button');
1836
+ cancel.type = 'button';
1837
+ cancel.className = 'btn btn-ghost';
1838
+ cancel.textContent = 'Cancel';
1839
+ cancel.addEventListener('click', () => slideover.close());
1840
+ footer.appendChild(cancel);
1841
+ const insert = document.createElement('button');
1842
+ insert.type = 'button';
1843
+ insert.className = 'btn btn-primary';
1844
+ insert.style.flex = '2';
1845
+ insert.textContent = state.editing ? 'Update' : 'Insert effect';
1846
+ insert.addEventListener('click', () => _doInsert(state, slideover));
1847
+ footer.appendChild(insert);
1848
+ pane.appendChild(footer);
1849
+ }
1850
+ ```
1851
+
1852
+ - [ ] **Step 3: Update `_buildShortcode` to render an inject preview.**
1853
+
1854
+ Replace `_buildShortcode` with:
1855
+
1856
+ ```js
1857
+ function _buildShortcode(state) {
1858
+ const effect = getEffect(state.activeEffect);
1859
+ const attrStr = _serialiseAttrs(state, effect);
1860
+ const space = attrStr ? ' ' : '';
1861
+
1862
+ if (state.scope === 'inject' && state.host) {
1863
+ // Inject form: rewrite host opening tag.
1864
+ const hostAttrs = state.host.attrs;
1865
+ const flag = effect.name;
1866
+ const hostParts = [];
1867
+ for (const k of Object.keys(hostAttrs)) {
1868
+ // Skip any pre-existing flags/prefixed attrs for this effect (we replace).
1869
+ if (k === flag) continue;
1870
+ if (k.startsWith(flag + '-')) continue;
1871
+ const v = hostAttrs[k];
1872
+ hostParts.push(v === '' ? k : `${k}="${String(v).replace(/"/g, '&quot;')}"`);
1873
+ }
1874
+ hostParts.push(flag);
1875
+ for (const attr of effect.attrs) {
1876
+ const v = state.fieldValues[attr.name];
1877
+ if (v === undefined || v === '' || v === null || v === attr.default) continue;
1878
+ const stringified = attr.type === 'checkbox' ? (v ? 'true' : 'false') : String(v).replace(/"/g, '&quot;');
1879
+ hostParts.push(`${flag}-${attr.name}="${stringified}"`);
1880
+ }
1881
+ return `[${state.host.name} ${hostParts.join(' ')}]…[/${state.host.name}]`;
1882
+ }
1883
+
1884
+ if (effect.selfClosing) {
1885
+ return `[${effect.name}${space}${attrStr} /]`;
1886
+ }
1887
+ return `[${effect.name}${space}${attrStr}]\nContent\n[/${effect.name}]`;
1888
+ }
1889
+ ```
1890
+
1891
+ (The `…` is intentional placeholder text in the inject preview - the user is shown that the host's body is preserved verbatim. The actual insertion in F2 splices the new opening tag in without touching the body.)
1892
+
1893
+ - [ ] **Step 4: Update the rail click handler to trigger the new rerender.**
1894
+
1895
+ In `_buildLayout`, change:
1896
+
1897
+ ```js
1898
+ _renderRail(rail, state, () => _renderPane(pane, state, slideover));
1899
+ _renderPane(pane, state, slideover);
1900
+ ```
1901
+
1902
+ to:
1903
+
1904
+ ```js
1905
+ function rerenderPane() { _renderPane(pane, state, slideover); }
1906
+ _renderRail(rail, state, rerenderPane);
1907
+ rerenderPane();
1908
+ ```
1909
+
1910
+ - [ ] **Step 5: Browser verification.**
1911
+
1912
+ Reload, edit a page with `[card title="Hi"]Body[/card]`, position cursor inside the body, open the slideover. Expected:
1913
+
1914
+ - An "Apply as" box appears between the description and the form.
1915
+ - Two buttons: *Standalone block* and *Inject into [card]*. *Inject* highlighted (default, since reveal is compatible).
1916
+ - Click *Standalone* → preview switches to `[reveal …]…[/reveal]`.
1917
+ - Click *Inject into [card]* → preview becomes `[card title="Hi" reveal reveal-animation="..."]…[/card]`.
1918
+
1919
+ For an effect that doesn't accept the current host (e.g. switch to Twinkle while inside `[banner]`): the toggle disappears and an italic note explains.
1920
+
1921
+ ---
1922
+
1923
+ ### Task F2: Inject mode insert
1924
+
1925
+ **Files:**
1926
+ - Modify: `admin/js/lib/effects-builder.js`
1927
+
1928
+ - [ ] **Step 1: Implement the inject branch in `_doInsert`.**
1929
+
1930
+ Replace the `if (state.scope === 'inject' && state.host)` placeholder block with:
1931
+
1932
+ ```js
1933
+ if (state.scope === 'inject' && state.host) {
1934
+ const effect = getEffect(state.activeEffect);
1935
+ const value = ta.value;
1936
+ const before = value.substring(0, state.host.openTagStart);
1937
+ const after = value.substring(state.host.openTagEnd);
1938
+
1939
+ // Reuse the inject preview's host-tag construction. Build the new host
1940
+ // open tag (no body placeholder).
1941
+ const hostAttrs = state.host.attrs;
1942
+ const flag = effect.name;
1943
+ const hostParts = [];
1944
+ for (const k of Object.keys(hostAttrs)) {
1945
+ if (k === flag) continue;
1946
+ if (k.startsWith(flag + '-')) continue;
1947
+ const v = hostAttrs[k];
1948
+ hostParts.push(v === '' ? k : `${k}="${String(v).replace(/"/g, '&quot;')}"`);
1949
+ }
1950
+ hostParts.push(flag);
1951
+ for (const attr of effect.attrs) {
1952
+ const v = state.fieldValues[attr.name];
1953
+ if (v === undefined || v === '' || v === null || v === attr.default) continue;
1954
+ const stringified = attr.type === 'checkbox' ? (v ? 'true' : 'false') : String(v).replace(/"/g, '&quot;');
1955
+ hostParts.push(`${flag}-${attr.name}="${stringified}"`);
1956
+ }
1957
+ const newOpenTag = `[${state.host.name} ${hostParts.join(' ')}]`;
1958
+
1959
+ ta.value = before + newOpenTag + after;
1960
+ // Keep the cursor inside the host body after rewrite.
1961
+ const newCursor = before.length + newOpenTag.length;
1962
+ ta.selectionStart = ta.selectionEnd = newCursor;
1963
+ ta.dispatchEvent(new Event('input', {bubbles: true}));
1964
+ slideover.close();
1965
+ return;
1966
+ }
1967
+ ```
1968
+
1969
+ - [ ] **Step 2: Browser verification.**
1970
+
1971
+ Reload. Page contents:
1972
+
1973
+ ```markdown
1974
+ [card title="Hi"]
1975
+ Body
1976
+ [/card]
1977
+ ```
1978
+
1979
+ Cursor inside Body. Open slideover. Pick Reveal, scope = Inject, animation = slide-up. Click Insert.
1980
+
1981
+ Expected: editor now reads:
1982
+
1983
+ ```markdown
1984
+ [card title="Hi" reveal reveal-animation="slide-up"]
1985
+ Body
1986
+ [/card]
1987
+ ```
1988
+
1989
+ Save the page. Visit it on the public site. The card should slide up on scroll.
1990
+
1991
+ ---
1992
+
1993
+ ### Task F3: Edit-existing replace
1994
+
1995
+ **Files:**
1996
+ - Modify: `admin/js/lib/effects-builder.js`
1997
+
1998
+ - [ ] **Step 1: Implement the editing branch in `_doInsert`.**
1999
+
2000
+ Replace the editing placeholder with:
2001
+
2002
+ ```js
2003
+ if (state.editing) {
2004
+ const effect = getEffect(state.editing.name);
2005
+ const value = ta.value;
2006
+ const before = value.substring(0, state.editing.blockStart);
2007
+ const after = value.substring(state.editing.blockEnd);
2008
+
2009
+ // Preserve the existing body verbatim (everything between openTagEnd
2010
+ // and the close tag start = blockEnd - close tag length).
2011
+ const closeTagLen = `[/${effect.name}]`.length;
2012
+ const bodyStart = state.editing.openTagEnd;
2013
+ const bodyEnd = state.editing.blockEnd - closeTagLen;
2014
+ const body = value.substring(bodyStart, bodyEnd);
2015
+
2016
+ const attrStr = _serialiseAttrs(state, effect);
2017
+ const space = attrStr ? ' ' : '';
2018
+ const newBlock = effect.selfClosing
2019
+ ? `[${effect.name}${space}${attrStr} /]`
2020
+ : `[${effect.name}${space}${attrStr}]${body}[/${effect.name}]`;
2021
+
2022
+ ta.value = before + newBlock + after;
2023
+ // Place cursor just after the new opening tag.
2024
+ const openTagEnd = effect.selfClosing
2025
+ ? before.length + newBlock.length
2026
+ : before.length + newBlock.indexOf(']') + 1;
2027
+ ta.selectionStart = ta.selectionEnd = openTagEnd;
2028
+ ta.dispatchEvent(new Event('input', {bubbles: true}));
2029
+ slideover.close();
2030
+ return;
2031
+ }
2032
+ ```
2033
+
2034
+ - [ ] **Step 2: Browser verification.**
2035
+
2036
+ Reload. Page contents:
2037
+
2038
+ ```markdown
2039
+ [reveal animation="fade" duration="600"]
2040
+ This will change.
2041
+ [/reveal]
2042
+ ```
2043
+
2044
+ Cursor inside the body. Open slideover. Verify:
2045
+ - Active effect is Reveal.
2046
+ - Animation = "fade" (populated).
2047
+ - Duration = 600 (populated).
2048
+ - Insert button reads "Update".
2049
+
2050
+ Change Animation to "zoom", Duration to 1200. Click Update.
2051
+
2052
+ Expected:
2053
+
2054
+ ```markdown
2055
+ [reveal animation="zoom" duration="1200"]
2056
+ This will change.
2057
+ [/reveal]
2058
+ ```
2059
+
2060
+ The body is preserved verbatim. Save and verify the published page now zooms instead of fading.
2061
+
2062
+ ---
2063
+
2064
+ ### Phase F - User Checkpoint
2065
+
2066
+ All three modes (insert / inject / update) work. User commits. The remaining work is the live render preview and final wiring.
2067
+
2068
+ ---
2069
+
2070
+ ## Phase G - Live render preview
2071
+
2072
+ ### Task G1: Live preview pane (effects flagged previewable)
2073
+
2074
+ **Files:**
2075
+ - Modify: `admin/js/lib/effects-builder.js`
2076
+
2077
+ - [ ] **Step 1: Add `_renderLivePreview` and a camelCase helper.**
2078
+
2079
+ Above `_renderPane`, add:
2080
+
2081
+ ```js
2082
+ let _previewSeq = 0;
2083
+
2084
+ function _camelKey(s) {
2085
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2086
+ }
2087
+
2088
+ function _renderLivePreview(container, state) {
2089
+ container.replaceChildren();
2090
+ const effect = getEffect(state.activeEffect);
2091
+
2092
+ const head = document.createElement('div');
2093
+ head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;';
2094
+ const lbl = document.createElement('div');
2095
+ lbl.style.cssText = 'color:var(--dm-text-muted,#888);text-transform:uppercase;font-size:.65rem;letter-spacing:.05em;';
2096
+ lbl.textContent = 'Live preview';
2097
+ head.appendChild(lbl);
2098
+
2099
+ if (!effect.previewable) {
2100
+ const note = document.createElement('div');
2101
+ note.style.cssText = 'background:var(--dm-surface-subtle,#15152a);border:1px solid var(--dm-border,#2a2a40);border-radius:4px;padding:.6rem;color:var(--dm-text-muted,#888);font-size:.8rem;font-style:italic;text-align:center;';
2102
+ note.textContent = 'Runs full-page on the published site - no inline preview.';
2103
+ container.appendChild(note);
2104
+ return;
2105
+ }
2106
+
2107
+ const replay = document.createElement('button');
2108
+ replay.type = 'button';
2109
+ replay.style.cssText = 'background:transparent;border:1px solid var(--dm-border,#333);color:var(--dm-text-muted,#888);font-size:.65rem;padding:.15rem .4rem;border-radius:2px;cursor:pointer;';
2110
+ replay.textContent = '↻ Replay';
2111
+ head.appendChild(replay);
2112
+ container.appendChild(head);
2113
+
2114
+ const stage = document.createElement('div');
2115
+ stage.style.cssText = 'background:var(--dm-surface-subtle,#1a1a2e);border:1px solid var(--dm-border,#2a2a40);border-radius:4px;padding:1rem;text-align:center;min-height:80px;position:relative;overflow:hidden;';
2116
+ container.appendChild(stage);
2117
+
2118
+ function mount() {
2119
+ _previewSeq++;
2120
+ const seq = _previewSeq;
2121
+ stage.replaceChildren();
2122
+ const sample = document.createElement('div');
2123
+ sample.style.cssText = 'background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;padding:.6rem;border-radius:4px;font-size:.8rem;font-weight:600;';
2124
+ sample.textContent = 'Sample content';
2125
+ stage.appendChild(sample);
2126
+ const FX = window.Domma && window.Domma.effects;
2127
+ if (!FX) return;
2128
+ const opts = {};
2129
+ for (const attr of effect.attrs) {
2130
+ const v = state.fieldValues[attr.name];
2131
+ if (v === undefined || v === '' || v === null) continue;
2132
+ opts[_camelKey(attr.name)] = v;
2133
+ }
2134
+ try {
2135
+ const fn = FX[_camelKey(effect.name)];
2136
+ if (typeof fn === 'function') {
2137
+ if (seq !== _previewSeq) return; // stale
2138
+ fn(sample, opts);
2139
+ }
2140
+ } catch (err) {
2141
+ console.warn('Effects preview failed:', err);
2142
+ }
2143
+ }
2144
+
2145
+ replay.addEventListener('click', mount);
2146
+ mount();
2147
+ }
2148
+ ```
2149
+
2150
+ - [ ] **Step 2: Wire preview into the rerender helper.**
2151
+
2152
+ Update `_renderPane`'s `rerenderAll` so it also re-renders the live preview. Add `previewBox` after `shortcodeBox`:
2153
+
2154
+ ```js
2155
+ const shortcodeBox = document.createElement('div');
2156
+ pane.appendChild(shortcodeBox);
2157
+
2158
+ const previewBox = document.createElement('div');
2159
+ pane.appendChild(previewBox);
2160
+
2161
+ function rerenderAll() {
2162
+ _renderScopeToggle(scopeBox, state, rerenderAll);
2163
+ _renderForm(formContainer, state, () => {
2164
+ _renderShortcodePreview(shortcodeBox, state);
2165
+ _renderLivePreview(previewBox, state);
2166
+ });
2167
+ _renderShortcodePreview(shortcodeBox, state);
2168
+ _renderLivePreview(previewBox, state);
2169
+ }
2170
+ rerenderAll();
2171
+ ```
2172
+
2173
+ - [ ] **Step 3: Browser verification.**
2174
+
2175
+ Reload. Open the slideover, pick Reveal. The Sample content card should fade/slide as configured. Change Animation; click Replay. The new animation runs.
2176
+
2177
+ Try Twinkle: particles animate over the sample. Try Counter: a number animates from 0 to the configured target. Try Celebrate: the static placeholder shows ("Runs full-page on the published site").
2178
+
2179
+ Verify there are no console errors when switching between previewable and non-previewable effects.
2180
+
2181
+ ---
2182
+
2183
+ ### Phase G - User Checkpoint
2184
+
2185
+ The slideover is feature-complete. User commits. Final wiring in Phase H replaces the dropdown handler with the new builder.
2186
+
2187
+ ---
2188
+
2189
+ ## Phase H - Final wiring
2190
+
2191
+ ### Task H1: Remove the old dropdown
2192
+
2193
+ **Files:**
2194
+ - Modify: `admin/js/lib/markdown-toolbar.js`
2195
+
2196
+ - [ ] **Step 1: Delete `effectsMenuItems`.**
2197
+
2198
+ Locate the `effectsMenuItems` array (around line 338). Delete it entirely.
2199
+
2200
+ - [ ] **Step 2: Delete `openEffectsDropdown`.**
2201
+
2202
+ The function starting `function openEffectsDropdown(ta, triggerEl) { …` (around line 364). Delete it including its event-listener attachment for outside-click.
2203
+
2204
+ - [ ] **Step 3: Confirm the toolbar click branch only calls the registered handler.**
2205
+
2206
+ Find:
2207
+
2208
+ ```js
2209
+ } else if (action === 'effects') {
2210
+ if (handlers.effects) {
2211
+ handlers.effects(ta);
2212
+ }
2213
+ }
2214
+ ```
2215
+
2216
+ Confirm it does **not** call `openEffectsDropdown` as a fallback. If it does, remove that fallback. The branch should ONLY call `handlers.effects(ta)`.
2217
+
2218
+ - [ ] **Step 4: Syntax check.**
2219
+
2220
+ Run: `node --check admin/js/lib/markdown-toolbar.js`
2221
+ Expected: clean.
2222
+
2223
+ ---
2224
+
2225
+ ### Task H2: Wire `EffectsBuilder.open` into page-editor
2226
+
2227
+ **Files:**
2228
+ - Modify: `admin/js/views/page-editor.js`
2229
+
2230
+ - [ ] **Step 1: Add the import.**
2231
+
2232
+ After the existing `import { …createToolbar… } from "../lib/markdown-toolbar.js?v=12";` line (line 8), add:
2233
+
2234
+ ```js
2235
+ import {EffectsBuilder} from "../lib/effects-builder.js?v=1";
2236
+ ```
2237
+
2238
+ - [ ] **Step 2: Bump the markdown-toolbar import.**
2239
+
2240
+ Change:
2241
+
2242
+ ```js
2243
+ } from "../lib/markdown-toolbar.js?v=12";
2244
+ ```
2245
+
2246
+ to:
2247
+
2248
+ ```js
2249
+ } from "../lib/markdown-toolbar.js?v=13";
2250
+ ```
2251
+
2252
+ - [ ] **Step 3: Register the handler.**
2253
+
2254
+ Locate the existing `V.onHelp(()=>{Ke()})` line. Immediately before it, add:
2255
+
2256
+ ```js
2257
+ V.onEffects(ta => EffectsBuilder.open(ta));
2258
+ ```
2259
+
2260
+ - [ ] **Step 4: Remove any temporary test wiring from C1-G1.**
2261
+
2262
+ If you added an inline `import('...').then(m => V.onEffects(...))` block during C1, remove it now. Search the file for `effects-builder` and delete any temporary registration.
2263
+
2264
+ - [ ] **Step 5: Syntax check.**
2265
+
2266
+ Run: `node --check admin/js/views/page-editor.js`
2267
+ Expected: clean.
2268
+
2269
+ ---
2270
+
2271
+ ### Task H3: Bump the effects-builder cache-buster on edits
2272
+
2273
+ **Files:**
2274
+ - (No new edits - this is a discipline note for future revisions to `effects-builder.js`.)
2275
+
2276
+ - [ ] **Step 1: Confirm current state.**
2277
+
2278
+ Run: `grep -n "effects-builder" admin/js/views/page-editor.js`
2279
+ Expected: one line - `import {EffectsBuilder} from "../lib/effects-builder.js?v=1";`
2280
+
2281
+ When making future changes to `effects-builder.js`, bump this `?v=1` to `?v=2`, etc., in the same commit. Same convention applies to `effect-defs.js?v=1` if its API or contents change in a way that affects consumers.
2282
+
2283
+ ---
2284
+
2285
+ ### Phase H - User Checkpoint
2286
+
2287
+ The Effects toolbar button now opens the new slideover. The old dropdown is gone. User commits.
2288
+
2289
+ ---
2290
+
2291
+ ## Phase I - Final browser verification
2292
+
2293
+ ### Task I1: End-to-end smoke checklist
2294
+
2295
+ This phase is purely user-driven. Each item is to be performed in a real browser at `http://192.168.1.4:4096/admin`. **Do not mark this plan complete based on server tests alone.**
2296
+
2297
+ - [ ] **Standalone insertion** - Edit a page. Click the Effects toolbar button (sparkles icon). Slideover opens. Pick *Reveal*. Animation = "slide-up". Click Insert. Editor now contains a `[reveal animation="slide-up"]…[/reveal]` block. Save. Visit the public page. Reveal animation fires on scroll.
2298
+
2299
+ - [ ] **Injection** - Edit a page with an existing `[card title="Hello"]Body[/card]`. Cursor inside the body. Click Effects. Scope toggle appears with *Inject into [card]* highlighted. Pick *Pulse*, scale 1.05. Click Insert. Editor now reads `[card title="Hello" pulse pulse-scale="1.05"]Body[/card]`. Save. Visit the page. The card pulses.
2300
+
2301
+ - [ ] **Edit existing** - Edit a page with `[twinkle count="80" colour="#fff"]Body[/twinkle]`. Cursor inside body. Click Effects. Slideover opens on Twinkle, count=80, colour=#fff, button reads "Update". Change count to 150. Click Update. Editor reads `[twinkle count="150" colour="#fff"]Body[/twinkle]`. Save. Visit. Heavier twinkle density.
2302
+
2303
+ - [ ] **Reduced-motion** - In Chrome DevTools → More tools → Rendering → Emulate CSS media feature → `prefers-reduced-motion: reduce`. Reload the public page from the previous step. Twinkle should be skipped (effects.js gates it). The slideover preview also skips animation when reduced-motion is on (Domma's effect functions self-gate).
2304
+
2305
+ - [ ] **Old dropdown is gone** - Confirm clicking the Effects button never opens a flat dropdown. Confirm the file `admin/js/lib/markdown-toolbar.js` no longer contains the string `effectsMenuItems`.
2306
+
2307
+ - [ ] **Cheatsheet preserved** - Visit `/admin/#/effects`. The reference cheatsheet still loads with the Ticker tape section from Phase 1 intact.
2308
+
2309
+ - [ ] **No console errors** - Throughout all of the above, the dev console should remain clean. Domma `[effects.tickerTape] Initialised …` info logs are fine.
2310
+
2311
+ ---
2312
+
2313
+ ### Phase I - User Checkpoint
2314
+
2315
+ Plan complete. User commits the final state if all checks pass.
2316
+
2317
+ ---
2318
+
2319
+ ## Self-review notes
2320
+
2321
+ (For the implementing engineer - flagged here so the next session has the same context.)
2322
+
2323
+ 1. **Strict check for cache-buster bumps.** Any time you modify `markdown-toolbar.js`, bump its `?v=N` in `page-editor.js`. Same for `effects-builder.js` and `effect-defs.js`.
2324
+
2325
+ 2. **Don't restart the server.** The CMS is always running on port 4096. To verify endpoints, just `curl` or visit a URL. Killing the server interrupts the user's other work.
2326
+
2327
+ 3. **Browser verification is mandatory.** Server-side `node --test` passing does not prove the slideover works. Each browser-verification step in this plan must actually be performed.
2328
+
2329
+ 4. **The user commits.** This plan deliberately omits `git commit` steps. After each phase, the user reviews the diff and commits when ready.
2330
+
2331
+ 5. **The `[row]` shortcode is intentionally left untouched.** It already supports its own multi-child reveal logic with `data-reveal-*` attributes (different keyspace from `data-fx-*`). Don't migrate it.
2332
+
2333
+ 6. **`scribe`, `scramble`, `counter`, `firework`, `fireworks`, `celebrate`** are standalone-only by design. Their `hosts: []` in `effect-defs.js` is correct, not a TODO.
2334
+
2335
+ 7. **`[hero]`'s `blobs` flag** is preserved in its existing form (it pushes a `bg-ambient-*` CSS class, distinct from the `dm-fx-*` injection mechanism). Do not fold it into the generic helper.