domma-cms 1.6.60 → 1.7.1

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 (168) hide show
  1. package/CHANGELOG.md +4349 -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/mail-compose.js +1 -0
  8. package/admin/js/lib/page-editor-reference.js +5 -5
  9. package/admin/js/lib/shortcode-tags.js +1 -1
  10. package/admin/js/templates/call-new.html +1 -1
  11. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  12. package/admin/js/views/call-new.js +1 -1
  13. package/admin/js/views/contacts.js +15 -15
  14. package/admin/js/views/form-submissions.js +2 -2
  15. package/docs/DommaFeatures.md +3005 -0
  16. package/docs/MultiTenant.md +176 -0
  17. package/docs/api-reference.md +1667 -0
  18. package/docs/cache.md +96 -0
  19. package/docs/configuration.md +1811 -0
  20. package/docs/editions-and-licences.md +190 -0
  21. package/docs/getting-started.md +201 -0
  22. package/docs/guides/form-triggers.md +260 -0
  23. package/docs/guides/mongodb-storage-adapter.md +282 -0
  24. package/docs/guides/multi-page-forms.md +121 -0
  25. package/docs/images/scribe-composer.png +0 -0
  26. package/docs/markdown-shortcodes.md +1738 -0
  27. package/docs/pentest-report-2026-04-03.md +181 -0
  28. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  29. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  30. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  31. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  32. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  34. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  35. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  37. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  38. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  39. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  40. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  42. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  43. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  44. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  45. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  46. package/docs/plugin-development.md +827 -0
  47. package/docs/recruitment-recipe.md +501 -0
  48. package/docs/scaffolding.md +312 -0
  49. package/docs/screenshots/calculator-floating.png +0 -0
  50. package/docs/screenshots/calculator-standard.png +0 -0
  51. package/docs/screenshots/email-settings.png +0 -0
  52. package/docs/screenshots/messages-count.png +0 -0
  53. package/docs/structure/user-types.md +107 -0
  54. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  55. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  56. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  58. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  59. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  60. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  61. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  62. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  63. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  64. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  65. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  66. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  67. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  68. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  69. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  70. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  71. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  72. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  73. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  74. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  75. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  76. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  77. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  78. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  79. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  80. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  81. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  82. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  83. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  84. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  85. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  86. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  87. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  89. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  90. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  91. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  92. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  93. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  95. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  96. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  97. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  98. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  99. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  100. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  101. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  102. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  103. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  106. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  107. package/docs/theming.md +320 -0
  108. package/package.json +2 -2
  109. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  110. package/plugins/_lib/admin/mail/identity.js +18 -3
  111. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  112. package/plugins/blog/CLAUDE.md +4 -0
  113. package/plugins/blog/admin/lib/email.js +191 -0
  114. package/plugins/blog/admin/views/comments.js +3 -1
  115. package/plugins/blog/plugin.json +2 -2
  116. package/plugins/blog/plugin.public.js +1 -1
  117. package/plugins/blog/tests/email.test.js +69 -0
  118. package/plugins/booking/CLAUDE.md +4 -0
  119. package/plugins/booking/admin/lib/email.js +191 -0
  120. package/plugins/booking/admin/views/panels.js +7 -3
  121. package/plugins/booking/plugin.json +1 -1
  122. package/plugins/booking/tests/email.test.js +69 -0
  123. package/plugins/free-tier.lock.json +27 -19
  124. package/plugins/shopping-cart/CLAUDE.md +4 -0
  125. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  126. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  127. package/plugins/shopping-cart/plugin.json +2 -2
  128. package/plugins/shopping-cart/tests/email.test.js +69 -0
  129. package/plugins/support/CLAUDE.md +4 -0
  130. package/plugins/support/admin/lib/email.js +191 -0
  131. package/plugins/support/admin/views/desk.js +4 -1
  132. package/plugins/support/admin/views/inbox.js +6 -2
  133. package/plugins/support/plugin.json +2 -2
  134. package/plugins/support/tests/email.test.js +69 -0
  135. package/public/js/celebrations/core/canvas.js +1 -1
  136. package/public/js/celebrations/index.js +1 -1
  137. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  138. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  139. package/public/js/celebrations/message-attrs.mjs +1 -0
  140. package/public/js/celebrations/themes/anniversary.js +1 -0
  141. package/public/js/celebrations/themes/autumn.js +1 -0
  142. package/public/js/celebrations/themes/birthday.js +1 -0
  143. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  144. package/public/js/celebrations/themes/calendar.js +1 -0
  145. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  146. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  147. package/public/js/celebrations/themes/christmas.js +1 -1
  148. package/public/js/celebrations/themes/easter.js +1 -0
  149. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  150. package/public/js/celebrations/themes/garden.js +1 -0
  151. package/public/js/celebrations/themes/graduation.js +1 -0
  152. package/public/js/celebrations/themes/greeting.js +1 -0
  153. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  154. package/public/js/celebrations/themes/money-rain.js +1 -0
  155. package/public/js/celebrations/themes/new-year.js +1 -0
  156. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  157. package/public/js/celebrations/themes/occasions.js +1 -0
  158. package/public/js/celebrations/themes/rain-control.js +1 -0
  159. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  160. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  161. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  162. package/public/js/celebrations/themes/summer-garden.js +1 -0
  163. package/public/js/celebrations/themes/winter.js +1 -0
  164. package/public/js/celebrations/trait-data.mjs +1 -1
  165. package/public/js/effects.js +1 -1
  166. package/scripts/build.js +4 -0
  167. package/server/services/markdown.js +5 -2
  168. package/server/templates/page.html +1 -1
@@ -0,0 +1,1970 @@
1
+ # Card Builder Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:
4
+ > executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
5
+
6
+ **Goal:** Add an interactive card builder dialog to the page editor and expand the `[card]` shortcode to support 33
7
+ layouts, 6 style variants, and 14 gradient presets.
8
+
9
+ **Architecture:** A self-contained `card-builder.js` admin lib builds a custom DOM overlay (appended to `document.body`,
10
+ no E.modal), wired into the existing `toolbar.onCard()` hook in `page-editor.js`. Server-side, `processCardBlocks` in
11
+ `markdown.js` is refactored to route via a `LAYOUT_RENDERERS` map - existing shortcodes fall through to the unchanged
12
+ legacy renderer. CSS variant and layout classes are added to `site.css`; collapsible click wiring is added to `site.js`.
13
+
14
+ **Tech Stack:** Domma SPA (ESM, no bundler), Fastify 5 SSR, vanilla DOM APIs, CSS custom properties. No test framework -
15
+ curl integration tests against the running server on port 4096.
16
+
17
+ **Spec:** `docs/superpowers/specs/2026-04-05-card-builder-design.md`
18
+
19
+ ---
20
+
21
+ ## File Map
22
+
23
+ | File | Action | Responsibility |
24
+ |---------------------------------|------------|---------------------------------------------------------------------------|
25
+ | `public/css/site.css` | Modify | 6 variant classes, 14 gradient classes, layout structural CSS |
26
+ | `public/js/site.js` | Modify | Collapsible card click delegation |
27
+ | `server/services/markdown.js` | Modify | `processCardBlocks` routing + 33 layout renderers |
28
+ | `admin/js/lib/card-builder.js` | **Create** | Builder dialog - state, fields, preview, icon picker, shortcode generator |
29
+ | `admin/js/views/page-editor.js` | Modify | Wire `toolbar.onCard()` + `onMediaBrowse` callback |
30
+
31
+ No new routes. No schema changes. No new npm packages.
32
+
33
+ ---
34
+
35
+ ## Task 1: CSS - 6 variant classes + utilities
36
+
37
+ **Files:**
38
+
39
+ - Modify: `public/css/site.css`
40
+
41
+ - [ ] **Step 1: Add the 6 variant base + hover rules after the existing `.page-body .card` block**
42
+
43
+ ```css
44
+ /* ── Card style variants ── */
45
+ .dm-card-clean {
46
+ background: #fff;
47
+ border: 1px solid #e2e8f0;
48
+ box-shadow: 0 1px 4px rgba(0,0,0,.06), 0 2px 8px rgba(0,0,0,.04);
49
+ transition: transform .2s ease, box-shadow .2s ease;
50
+ }
51
+ .dm-card-clean:hover {
52
+ transform: translateY(-4px);
53
+ box-shadow: 0 8px 24px rgba(0,0,0,.10), 0 2px 8px rgba(0,0,0,.06);
54
+ }
55
+
56
+ .dm-card-gradient {
57
+ background: #fff;
58
+ border: none;
59
+ box-shadow: 0 4px 20px rgba(99,102,241,.14);
60
+ transition: transform .2s ease, box-shadow .2s ease;
61
+ }
62
+ .dm-card-gradient:hover {
63
+ transform: translateY(-5px);
64
+ box-shadow: 0 16px 40px rgba(99,102,241,.25);
65
+ }
66
+ .dm-card-gradient .card-header {
67
+ background: linear-gradient(135deg, var(--dm-card-g-start, #6366f1), var(--dm-card-g-end, #8b5cf6));
68
+ color: #fff;
69
+ border-bottom: none;
70
+ }
71
+
72
+ .dm-card-glass {
73
+ background: rgba(255,255,255,.18);
74
+ backdrop-filter: blur(14px);
75
+ -webkit-backdrop-filter: blur(14px);
76
+ border: 1px solid rgba(255,255,255,.38);
77
+ box-shadow: 0 4px 24px rgba(0,0,0,.08);
78
+ transition: transform .2s ease, box-shadow .2s ease, background .2s ease;
79
+ }
80
+ .dm-card-glass:hover {
81
+ transform: translateY(-4px);
82
+ box-shadow: 0 12px 36px rgba(0,0,0,.14);
83
+ background: rgba(255,255,255,.26);
84
+ }
85
+ .dm-card-glass .card-header { border-bottom: 1px solid rgba(255,255,255,.25); }
86
+
87
+ .dm-card-accent {
88
+ background: #fff;
89
+ border: 1px solid #e2e8f0;
90
+ border-left: 4px solid #6366f1;
91
+ box-shadow: 0 1px 4px rgba(0,0,0,.05);
92
+ transition: transform .2s ease, box-shadow .2s ease, border-left-color .2s ease;
93
+ }
94
+ .dm-card-accent:hover {
95
+ transform: translateY(-3px);
96
+ border-left-color: #4f46e5;
97
+ box-shadow: 0 8px 24px rgba(99,102,241,.12);
98
+ }
99
+ .dm-card-accent .card-header { color: #4338ca; border-bottom: none; }
100
+
101
+ .dm-card-dark {
102
+ background: linear-gradient(160deg, #1e293b 0%, #0f172a 100%);
103
+ border: 1px solid rgba(255,255,255,.06);
104
+ box-shadow: 0 4px 20px rgba(0,0,0,.30);
105
+ transition: transform .2s ease, box-shadow .2s ease;
106
+ }
107
+ .dm-card-dark:hover {
108
+ transform: translateY(-4px) scale(1.012);
109
+ box-shadow: 0 16px 40px rgba(0,0,0,.45);
110
+ }
111
+ .dm-card-dark .card-header { color: #f1f5f9; border-bottom: 1px solid rgba(255,255,255,.08); }
112
+ .dm-card-dark .card-body { color: #94a3b8; }
113
+ .dm-card-dark .card-footer { color: #475569; border-top: 1px solid rgba(255,255,255,.06); }
114
+
115
+ .dm-card-glow {
116
+ background: #fff;
117
+ border: 1px solid #a5b4fc;
118
+ box-shadow: 0 0 0 0 rgba(99,102,241,0);
119
+ transition: transform .2s ease, box-shadow .3s ease, border-color .2s ease;
120
+ }
121
+ .dm-card-glow:hover {
122
+ transform: translateY(-3px);
123
+ border-color: #818cf8;
124
+ box-shadow: 0 0 0 4px rgba(99,102,241,.14), 0 0 28px rgba(99,102,241,.22);
125
+ }
126
+ .dm-card-glow .card-header { color: #4338ca; border-bottom: 1px solid #e0e7ff; }
127
+ .dm-card-glow .card-footer { border-top: 1px solid #e0e7ff; color: #818cf8; }
128
+
129
+ /* opt-in lift for layouts without a variant */
130
+ .card-hover { transition: transform .2s ease, box-shadow .2s ease; }
131
+ .card-hover:hover { transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0,0,0,.10); }
132
+ ```
133
+
134
+ - [ ] **Step 2: Create test page `content/pages/card-variants-test.md`**
135
+
136
+ ```markdown
137
+ ---
138
+ title: Card Variant Test
139
+ layout: default
140
+ ---
141
+ [card variant="clean" title="Clean" hover]Body.[/card]
142
+ [card variant="gradient" title="Gradient"]Body.[/card]
143
+ [card variant="accent" title="Accent"]Body.[/card]
144
+ [card variant="dark" title="Dark"]Body.[/card]
145
+ [card variant="glow" title="Glow"]Body.[/card]
146
+ ```
147
+
148
+ - [ ] **Step 3: Verify**
149
+
150
+ ```bash
151
+ curl -s http://localhost:4096/card-variants-test | grep -c 'dm-card-'
152
+ ```
153
+
154
+ Expected: `5`
155
+
156
+ - [ ] **Step 4: Delete test page, commit**
157
+
158
+ ```bash
159
+ rm content/pages/card-variants-test.md
160
+ git add public/css/site.css
161
+ git commit -m "feat(css): add 6 card variant classes with hover effects"
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Task 2: CSS - 14 gradient preset classes
167
+
168
+ **Files:**
169
+
170
+ - Modify: `public/css/site.css`
171
+
172
+ - [ ] **Step 1: Add gradient classes after the variant block**
173
+
174
+ ```css
175
+ /* ── Gradient presets ── */
176
+ /* Apply as class on .card-header, .card-badge-band, .card-step-bg, .card-split-left */
177
+ .card-gradient-indigo { background: linear-gradient(135deg,#6366f1,#8b5cf6); }
178
+ .card-gradient-ocean { background: linear-gradient(135deg,#0891b2,#2563eb); }
179
+ .card-gradient-sunset { background: linear-gradient(135deg,#f59e0b,#ef4444,#ec4899); }
180
+ .card-gradient-forest { background: linear-gradient(135deg,#10b981,#0d9488); }
181
+ .card-gradient-rose { background: linear-gradient(135deg,#fb7185,#e11d48); }
182
+ .card-gradient-midnight { background: linear-gradient(135deg,#1e1b4b,#4338ca); }
183
+ .card-gradient-aurora { background: linear-gradient(135deg,#06b6d4,#6366f1,#a855f7); }
184
+ .card-gradient-fire { background: linear-gradient(135deg,#ef4444,#f97316,#fbbf24); }
185
+ .card-gradient-lagoon { background: linear-gradient(135deg,#06b6d4,#0e7490); }
186
+ .card-gradient-dusk { background: linear-gradient(135deg,#7c3aed,#db2777); }
187
+ .card-gradient-lime { background: linear-gradient(135deg,#84cc16,#10b981); }
188
+ .card-gradient-gold { background: linear-gradient(135deg,#f59e0b,#b45309); }
189
+ .card-gradient-arctic { background: linear-gradient(135deg,#bae6fd,#e0f2fe); color: #1e293b; }
190
+ .card-gradient-slate { background: linear-gradient(135deg,#475569,#1e293b); }
191
+ /* All gradient elements get white text except arctic */
192
+ [class*="card-gradient-"]:not(.card-gradient-arctic) { color: #fff; }
193
+ ```
194
+
195
+ - [ ] **Step 2: Commit**
196
+
197
+ ```bash
198
+ git add public/css/site.css
199
+ git commit -m "feat(css): add 14 gradient preset classes"
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Task 3: CSS - Layout structural CSS
205
+
206
+ **Files:**
207
+
208
+ - Modify: `public/css/site.css`
209
+
210
+ - [ ] **Step 1: Add all layout structural rules**
211
+
212
+ ```css
213
+ /* ── Card layout structural CSS ── */
214
+
215
+ /* Image containers */
216
+ .card-img-top {
217
+ width:100%; height:180px; background-size:cover; background-position:center;
218
+ background-color:#e2e8f0; display:flex; align-items:center;
219
+ justify-content:center; font-size:2.5rem; color:rgba(0,0,0,.15);
220
+ }
221
+ .card-img-left, .card-img-right {
222
+ width:90px; flex-shrink:0; background-size:cover; background-position:center;
223
+ background-color:#e2e8f0; display:flex; align-items:center;
224
+ justify-content:center; font-size:1.75rem; color:rgba(0,0,0,.2);
225
+ }
226
+ .card-img-wide {
227
+ width:130px; flex-shrink:0; background-size:cover; background-position:center;
228
+ background-color:#e2e8f0; display:flex; align-items:center;
229
+ justify-content:center; font-size:2rem; color:rgba(0,0,0,.2);
230
+ }
231
+
232
+ /* Horizontal shells */
233
+ .card-layout-horizontal,
234
+ .card-layout-thumb-left,
235
+ .card-layout-thumb-right { display:flex; }
236
+ .card-layout-thumb-left .card-body,
237
+ .card-layout-thumb-right .card-body,
238
+ .card-layout-horizontal .card-body { flex:1; min-width:0; }
239
+
240
+ /* Split */
241
+ .card-layout-split { display:flex; min-height:140px; }
242
+ .card-split-left { width:50%; display:flex; align-items:center; justify-content:center; font-size:2.25rem; flex-shrink:0; background:linear-gradient(160deg,#6366f1,#8b5cf6); }
243
+ .card-split-right { flex:1; background:#1e293b; display:flex; flex-direction:column; justify-content:center; }
244
+ .card-split-right .card-body { color:#94a3b8; }
245
+ .card-split-right .card-title { color:#f1f5f9; }
246
+
247
+ /* Image overlay */
248
+ .card-img-overlay { position:relative; height:180px; overflow:hidden; background-size:cover; background-position:center; background-color:#1e293b; display:flex; align-items:flex-end; }
249
+ .card-overlay-text { width:100%; padding:40px 16px 14px; background:linear-gradient(to top,rgba(0,0,0,.75),transparent); }
250
+ .card-overlay-text .card-title { color:#fff; }
251
+
252
+ /* Dark variant on image containers */
253
+ .dm-card-dark .card-img-top,
254
+ .dm-card-dark .card-img-left,
255
+ .dm-card-dark .card-img-right,
256
+ .dm-card-dark .card-img-wide { filter:brightness(.7); }
257
+
258
+ /* Stat */
259
+ .card-stat-value { font-size:2.4rem; font-weight:800; color:#111827; line-height:1.1; }
260
+ .card-stat-delta { font-size:.8rem; font-weight:600; margin-top:2px; }
261
+ .card-stat-delta.positive { color:#10b981; }
262
+ .card-stat-delta.negative { color:#ef4444; }
263
+ .card-stat-bar { height:5px; background:#f1f5f9; border-radius:3px; margin-top:14px; overflow:hidden; }
264
+ .card-stat-fill { height:100%; border-radius:3px; background:linear-gradient(90deg,#6366f1,#8b5cf6); }
265
+
266
+ /* Progress */
267
+ .card-progress-bar { height:8px; background:#f1f5f9; border-radius:4px; overflow:hidden; margin:8px 0; }
268
+ .card-progress-fill { height:100%; border-radius:4px; background:linear-gradient(90deg,#6366f1,#8b5cf6); }
269
+ .card-milestone { display:flex; align-items:center; gap:8px; font-size:.8rem; color:#4b5563; padding:3px 0; }
270
+ .card-milestone-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
271
+ .card-milestone-dot.done { background:#10b981; }
272
+ .card-milestone-dot.pending { background:#e5e7eb; }
273
+
274
+ /* Icon stacked header */
275
+ .card-header-icon-stacked { text-align:center; padding:20px 16px 12px; }
276
+ .card-header-icon-stacked [data-icon],
277
+ .card-header-icon-stacked .card-icon { font-size:2rem; display:block; margin-bottom:8px; }
278
+
279
+ /* Step numbered */
280
+ .card-step-bg { position:relative; overflow:hidden; min-height:60px; display:flex; align-items:center; padding:14px 16px; }
281
+ .card-step-ghost { position:absolute; right:8px; top:-10px; font-size:5rem; font-weight:900; color:rgba(99,102,241,.12); line-height:1; pointer-events:none; }
282
+ .card-step-badge { width:32px; height:32px; border-radius:50%; background:#6366f1; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:.85rem; z-index:1; flex-shrink:0; }
283
+
284
+ /* Corner badge */
285
+ .card-corner-badge-wrap { position:relative; }
286
+ .card-corner-badge { position:absolute; top:10px; right:10px; background:#ef4444; color:#fff; font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; padding:2px 8px; border-radius:20px; }
287
+
288
+ /* Callout */
289
+ .card-callout { border-left:4px solid #6366f1; }
290
+ .card-callout.warn { border-left-color:#f59e0b; }
291
+ .card-callout.success { border-left-color:#10b981; }
292
+ .card-callout.error { border-left-color:#ef4444; }
293
+ .card-callout-inner { display:flex; gap:12px; align-items:flex-start; }
294
+ .card-callout-icon { font-size:1.2rem; flex-shrink:0; margin-top:1px; }
295
+
296
+ /* Quote / testimonial */
297
+ .card-quote-mark { font-size:3.5rem; color:#ede9fe; line-height:.8; padding:14px 16px 0; font-family:Georgia,serif; display:block; }
298
+ .card-quote-text { padding:4px 16px 14px; font-style:italic; color:#374151; line-height:1.7; }
299
+ .card-quote-attr { display:flex; align-items:center; gap:10px; }
300
+ .card-quote-avatar { width:32px; height:32px; border-radius:50%; background:linear-gradient(135deg,#6366f1,#8b5cf6); display:flex; align-items:center; justify-content:center; font-size:.85rem; color:#fff; flex-shrink:0; }
301
+
302
+ /* Video */
303
+ .card-video-thumb { position:relative; height:160px; background:#1e293b; display:flex; align-items:center; justify-content:center; background-size:cover; background-position:center; }
304
+ .card-play-btn { width:52px; height:52px; border-radius:50%; background:rgba(255,255,255,.9); display:flex; align-items:center; justify-content:center; font-size:1.2rem; box-shadow:0 4px 16px rgba(0,0,0,.3); }
305
+ .card-video-duration { position:absolute; bottom:8px; right:10px; background:rgba(0,0,0,.7); color:#fff; font-size:.65rem; font-weight:600; padding:2px 7px; border-radius:4px; }
306
+
307
+ /* Map */
308
+ .card-map-placeholder { height:130px; background:linear-gradient(160deg,#bfdbfe,#dbeafe); position:relative; display:flex; align-items:center; justify-content:center; overflow:hidden; }
309
+ .card-map-grid { position:absolute; inset:0; background-image:repeating-linear-gradient(0deg,rgba(59,130,246,.08) 0,rgba(59,130,246,.08) 1px,transparent 1px,transparent 32px),repeating-linear-gradient(90deg,rgba(59,130,246,.08) 0,rgba(59,130,246,.08) 1px,transparent 1px,transparent 32px); }
310
+ .card-map-pin { font-size:2rem; z-index:1; }
311
+
312
+ /* Avatar */
313
+ .card-avatar { width:64px; height:64px; border-radius:50%; background:linear-gradient(135deg,#6366f1,#8b5cf6); display:flex; align-items:center; justify-content:center; font-size:1.8rem; color:#fff; margin:0 auto 12px; }
314
+ .card-avatar-wrap { text-align:center; padding:24px 16px 16px; }
315
+ .card-tag-pills { display:flex; flex-wrap:wrap; gap:6px; justify-content:center; margin-top:10px; }
316
+ .card-pill { background:#ede9fe; color:#7c3aed; font-size:.7rem; font-weight:700; padding:3px 9px; border-radius:20px; }
317
+
318
+ /* Tag cloud */
319
+ .card-tag-cloud { padding:14px 16px; display:flex; flex-wrap:wrap; gap:7px; }
320
+ .card-tag { padding:4px 11px; border-radius:20px; font-size:.78rem; font-weight:600; }
321
+
322
+ /* File document */
323
+ .card-file-row { display:flex; gap:14px; align-items:center; padding:16px; }
324
+ .card-file-icon { width:48px; height:58px; border-radius:7px; background:linear-gradient(160deg,#6366f1,#8b5cf6); display:flex; align-items:center; justify-content:center; font-size:1.4rem; color:#fff; flex-shrink:0; position:relative; }
325
+ .card-file-ext { position:absolute; bottom:4px; left:0; right:0; text-align:center; font-size:.55rem; font-weight:800; color:rgba(255,255,255,.9); letter-spacing:.05em; }
326
+ .card-file-dl { font-size:.78rem; font-weight:700; color:#6366f1; display:inline-block; margin-top:4px; }
327
+
328
+ /* Rating */
329
+ .card-stars { color:#f59e0b; letter-spacing:2px; padding:14px 16px 0; display:block; }
330
+ .card-verified { display:inline-block; background:#d1fae5; color:#065f46; font-size:.6rem; font-weight:700; padding:1px 5px; border-radius:3px; margin-left:6px; }
331
+
332
+ /* Activity feed */
333
+ .card-activity-item { display:flex; gap:10px; align-items:flex-start; padding:8px 14px; border-bottom:1px solid #f8fafc; }
334
+ .card-activity-item:last-child { border-bottom:none; }
335
+ .card-activity-avatar { width:30px; height:30px; border-radius:50%; flex-shrink:0; background:linear-gradient(135deg,#6366f1,#8b5cf6); display:flex; align-items:center; justify-content:center; font-size:.8rem; color:#fff; }
336
+
337
+ /* Before/After */
338
+ .card-compare-grid { display:grid; grid-template-columns:1fr 1fr; }
339
+ .card-compare-col { padding:12px 14px; }
340
+ .card-compare-col + .card-compare-col { border-left:1px solid #f1f5f9; }
341
+ .card-compare-label { font-size:.68rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; display:block; }
342
+ .card-compare-label.before { color:#ef4444; }
343
+ .card-compare-label.after { color:#10b981; }
344
+ .card-compare-item { display:flex; gap:7px; align-items:flex-start; font-size:.78rem; color:#4b5563; padding:3px 0; line-height:1.4; }
345
+ .card-compare-x { color:#ef4444; flex-shrink:0; }
346
+ .card-compare-check { color:#10b981; flex-shrink:0; }
347
+
348
+ /* Pricing */
349
+ .card-pricing-features { padding:14px 16px; }
350
+ .card-pricing-feature { display:flex; align-items:center; gap:8px; font-size:.82rem; color:#374151; padding:4px 0; }
351
+ .card-pricing-check { color:#10b981; }
352
+ .card-pricing-cta { display:block; text-align:center; background:#6366f1; color:#fff; font-size:.82rem; font-weight:700; padding:9px; border-radius:8px; text-decoration:none; margin:0 16px 14px; }
353
+
354
+ /* Timeline */
355
+ .card-timeline-row { display:flex; }
356
+ .card-timeline-side { width:44px; flex-shrink:0; display:flex; flex-direction:column; align-items:center; padding-top:14px; }
357
+ .card-timeline-dot { width:12px; height:12px; border-radius:50%; background:#6366f1; flex-shrink:0; }
358
+ .card-timeline-line { width:2px; background:#e5e7eb; flex:1; margin-top:4px; }
359
+ .card-timeline-body { flex:1; padding:12px 14px 12px 0; }
360
+ .card-timeline-date { font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#9ca3af; }
361
+ .card-timeline-tag { display:inline-block; background:#d1fae5; color:#065f46; font-size:.65rem; font-weight:700; padding:2px 6px; border-radius:4px; margin-top:6px; }
362
+
363
+ /* Code snippet */
364
+ .card-code-header { background:#1e293b; display:flex; justify-content:space-between; align-items:center; padding:8px 14px; }
365
+ .card-code-lang { font-size:.68rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#6366f1; }
366
+ .card-code-body { background:#0f172a; padding:14px; font-family:'Courier New',monospace; font-size:.78rem; line-height:1.8; color:#94a3b8; overflow-x:auto; }
367
+
368
+ /* Glass gradient border */
369
+ .card-glass-outer { background:linear-gradient(135deg,#6366f1,#8b5cf6,#ec4899); border-radius:12px; padding:2px; }
370
+ .card-glass-inner { background:rgba(255,255,255,.92); backdrop-filter:blur(10px); border-radius:10px; padding:16px; }
371
+
372
+ /* Badge band */
373
+ .card-badge-band { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; }
374
+ .card-badge-band-label { background:rgba(255,255,255,.2); color:#fff; font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; padding:3px 8px; border-radius:4px; }
375
+ .card-badge-band-icon { font-size:1.4rem; }
376
+
377
+ /* Full bg */
378
+ .dm-card-full-bg { background:linear-gradient(160deg,#1e293b,#0f172a); }
379
+ .dm-card-full-bg .card-body { color:#94a3b8; }
380
+ .dm-card-full-bg .card-title { color:#f1f5f9; font-size:1rem; }
381
+ .dm-card-full-bg .card-footer { border-top-color:rgba(255,255,255,.08); color:#475569; }
382
+
383
+ /* Feature comparison */
384
+ .card-fc-row { display:flex; justify-content:space-between; align-items:center; padding:8px 16px; border-bottom:1px solid #f8fafc; font-size:.82rem; color:#374151; }
385
+ .card-fc-row:last-child { border-bottom:none; }
386
+ .card-fc-yes { color:#10b981; }
387
+ .card-fc-no { color:#d1d5db; }
388
+
389
+ /* Responsive: side-image layouts stack at 480px */
390
+ @media (max-width: 480px) {
391
+ .card-layout-thumb-left,
392
+ .card-layout-thumb-right,
393
+ .card-layout-horizontal { flex-direction:column; }
394
+ .card-img-left, .card-img-right, .card-img-wide { width:100%; height:120px; }
395
+ .card-layout-split { flex-direction:column; }
396
+ .card-split-left { width:100%; height:100px; }
397
+ }
398
+ ```
399
+
400
+ - [ ] **Step 2: Commit**
401
+
402
+ ```bash
403
+ git add public/css/site.css
404
+ git commit -m "feat(css): add card layout structural CSS for all 33 layouts"
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Task 4: site.js - Collapsible card click handler
410
+
411
+ **Files:**
412
+
413
+ - Modify: `public/js/site.js`
414
+
415
+ - [ ] **Step 1: Check if handler already exists**
416
+
417
+ ```bash
418
+ grep -n 'collapsible\|is-collapsed' public/js/site.js
419
+ ```
420
+
421
+ Skip this task if a working handler is already present.
422
+
423
+ - [ ] **Step 2: Add click delegation near end of site init**
424
+
425
+ ```js
426
+ // Collapsible card toggle
427
+ document.addEventListener('click', function (e) {
428
+ const header = e.target.closest('.card-collapsible .card-header');
429
+ if (!header) return;
430
+ header.closest('.card-collapsible').classList.toggle('is-collapsed');
431
+ });
432
+ ```
433
+
434
+ - [ ] **Step 3: Verify** - Create a test page with `[card title="Click me" collapsible="true"]Body.[/card]`, open in
435
+ browser, click header, body toggles. Delete test page.
436
+
437
+ - [ ] **Step 4: Commit**
438
+
439
+ ```bash
440
+ git add public/js/site.js
441
+ git commit -m "feat(site): add collapsible card click delegation"
442
+ ```
443
+
444
+ ---
445
+
446
+ ## Task 5: markdown.js - Refactor processCardBlocks (routing infrastructure)
447
+
448
+ **Files:**
449
+
450
+ - Modify: `server/services/markdown.js`
451
+
452
+ - [ ] **Step 1: Locate `processCardBlocks` (around line 837)**
453
+
454
+ - [ ] **Step 2: Rename the existing callback body to `renderLegacyCard`**
455
+
456
+ Extract the existing `(_, attrStr, body) => { ... }` callback body into:
457
+
458
+ ```js
459
+ function renderLegacyCard(attrs, body, markedInstance, escAttr) {
460
+ // paste existing match callback body here verbatim
461
+ // replace `marked` with `markedInstance`, `escapeAttr` with `escAttr`
462
+ }
463
+ ```
464
+
465
+ - [ ] **Step 3: Add routing infrastructure**
466
+
467
+ ```js
468
+ const LAYOUT_RENDERERS = {}; // populated by Tasks 6-9
469
+
470
+ function processCardBlocks(markdown) {
471
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
472
+ return restore(scrubbed.replace(
473
+ /\[card([^\]]*)\]([\s\S]*?)\[\/card\]/gi,
474
+ (_, attrStr, body) => {
475
+ const attrs = parseShortcodeAttrs(attrStr);
476
+ const layout = typeof attrs.layout === 'string' ? attrs.layout.trim() : '';
477
+ const renderer = LAYOUT_RENDERERS[layout];
478
+ return renderer
479
+ ? renderer(attrs, body, marked, escapeAttr)
480
+ : renderLegacyCard(attrs, body, marked, escapeAttr);
481
+ }
482
+ ));
483
+ }
484
+ ```
485
+
486
+ - [ ] **Step 4: Add shared helpers**
487
+
488
+ ```js
489
+ function cardVariantClasses(attrs) {
490
+ const variant = typeof attrs.variant === 'string' ? attrs.variant.trim() : '';
491
+ const gradient = typeof attrs.gradient === 'string' ? attrs.gradient.trim() : 'indigo';
492
+ const classes = ['card', 'mb-4'];
493
+ const valid = ['clean','gradient','glass','accent','dark','glow'];
494
+ if (valid.includes(variant)) {
495
+ classes.push(`dm-card-${variant}`);
496
+ if (variant === 'gradient') classes.push(`card-gradient-${gradient}`);
497
+ }
498
+ if ('hover' in attrs) classes.push('card-hover');
499
+ if (attrs.collapsible === 'true') classes.push('card-collapsible');
500
+ if (typeof attrs.class === 'string') classes.push(attrs.class.trim());
501
+ return classes;
502
+ }
503
+
504
+ function cardRoot(attrs, extraClasses = []) {
505
+ const classes = [...cardVariantClasses(attrs), ...extraClasses];
506
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
507
+ const coll = attrs.collapsible === 'true' ? ' data-collapsible="true"' : '';
508
+ return `<div class="${classes.join(' ')}"${id}${coll}>`;
509
+ }
510
+
511
+ function cardFooterAttr(attrs) {
512
+ return attrs.footer ? `<div class="card-footer">${escapeAttr(attrs.footer)}</div>` : '';
513
+ }
514
+
515
+ function imgStyle(attrs) {
516
+ const img = typeof attrs.image === 'string' ? attrs.image.trim() : '';
517
+ return img ? ` style="background-image:url('${escapeAttr(img)}')"` : '';
518
+ }
519
+
520
+ function parseSubTagList(body, tagName) {
521
+ const re = new RegExp(`\\[${tagName}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${tagName}\\]`, 'gi');
522
+ const items = [];
523
+ body.replace(re, (_, attrStr, inner) => {
524
+ items.push({ attrs: parseShortcodeAttrs(attrStr), content: inner.trim() });
525
+ });
526
+ return items;
527
+ }
528
+
529
+ function parseSubTag(body, tagName) {
530
+ const re = new RegExp(`\\[${tagName}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${tagName}\\]`, 'i');
531
+ const m = body.match(re);
532
+ return m ? m[2].trim() : null;
533
+ }
534
+
535
+ function tagColorStyle(i) {
536
+ const p = [
537
+ 'background:#ede9fe;color:#7c3aed',
538
+ 'background:#dbeafe;color:#1d4ed8',
539
+ 'background:#d1fae5;color:#065f46',
540
+ 'background:#fef3c7;color:#92400e',
541
+ 'background:#fce7f3;color:#9d174d',
542
+ ];
543
+ return p[i % p.length];
544
+ }
545
+ ```
546
+
547
+ - [ ] **Step 5: Verify server starts and existing cards still render**
548
+
549
+ ```bash
550
+ curl -s http://localhost:4096/ | grep -c 'card' || echo "0 - no cards on homepage, that is fine"
551
+ ```
552
+
553
+ - [ ] **Step 6: Commit**
554
+
555
+ ```bash
556
+ git add server/services/markdown.js
557
+ git commit -m "refactor(markdown): extract card renderer routing infrastructure"
558
+ ```
559
+
560
+ ---
561
+
562
+ ## Task 6: markdown.js - Tier 1 basic + icon layouts
563
+
564
+ **Files:**
565
+
566
+ - Modify: `server/services/markdown.js`
567
+
568
+ - [ ] **Step 1: Add basic, header-body, header-body-footer, no-header-footer**
569
+
570
+ ```js
571
+ LAYOUT_RENDERERS['basic'] = (attrs, body) => {
572
+ const bodyHtml = marked.parse(body.trim());
573
+ return `${cardRoot(attrs)}<div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
574
+ };
575
+
576
+ LAYOUT_RENDERERS['header-body'] = (attrs, body) => {
577
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
578
+ const hdr = title ? `<div class="card-header"><div class="card-title">${escapeAttr(title)}</div></div>` : '';
579
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
580
+ return `${cardRoot(attrs)}${hdr}<div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
581
+ };
582
+
583
+ LAYOUT_RENDERERS['header-body-footer'] = (attrs, body) => {
584
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
585
+ const footer = typeof attrs.footer === 'string' ? attrs.footer.trim() : '';
586
+ const hdr = title ? `<div class="card-header"><div class="card-title">${escapeAttr(title)}</div></div>` : '';
587
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
588
+ const ftr = footer ? `<div class="card-footer">${escapeAttr(footer)}</div>` : '';
589
+ return `${cardRoot(attrs)}${hdr}<div class="card-body">${bodyHtml}</div>${ftr}</div>\n`;
590
+ };
591
+
592
+ LAYOUT_RENDERERS['no-header-footer'] = (attrs, body) => {
593
+ const footer = typeof attrs.footer === 'string' ? attrs.footer.trim() : '';
594
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
595
+ const ftr = footer ? `<div class="card-footer">${escapeAttr(footer)}</div>` : '';
596
+ return `${cardRoot(attrs)}<div class="card-body">${bodyHtml}</div>${ftr}</div>\n`;
597
+ };
598
+ ```
599
+
600
+ - [ ] **Step 2: Add icon-top and icon-inline**
601
+
602
+ ```js
603
+ LAYOUT_RENDERERS['icon-top'] = (attrs, body) => {
604
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
605
+ const subtitle = typeof attrs.subtitle === 'string' ? attrs.subtitle.trim() : '';
606
+ const icon = typeof attrs.icon === 'string' ? attrs.icon.trim() : '';
607
+ const iconHtml = icon ? `<span data-icon="${escapeAttr(icon)}" class="card-icon"></span>` : '';
608
+ const subHtml = subtitle ? `<div class="card-subtitle">${escapeAttr(subtitle)}</div>` : '';
609
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
610
+ return `${cardRoot(attrs)}
611
+ <div class="card-header card-header-icon-stacked">
612
+ <div class="card-avatar-wrap">${iconHtml}<div class="card-title">${escapeAttr(title)}</div>${subHtml}</div>
613
+ </div>
614
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
615
+ };
616
+
617
+ LAYOUT_RENDERERS['icon-inline'] = (attrs, body) => {
618
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
619
+ const subtitle = typeof attrs.subtitle === 'string' ? attrs.subtitle.trim() : '';
620
+ const icon = typeof attrs.icon === 'string' ? attrs.icon.trim() : '';
621
+ const iconHtml = icon ? `<span data-icon="${escapeAttr(icon)}"></span>` : '';
622
+ const subHtml = subtitle ? `<div class="card-subtitle">${escapeAttr(subtitle)}</div>` : '';
623
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
624
+ return `${cardRoot(attrs)}
625
+ <div class="card-header card-header-icon-inline">
626
+ ${iconHtml}<div class="card-header-content"><div class="card-title">${escapeAttr(title)}</div>${subHtml}</div>
627
+ </div>
628
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
629
+ };
630
+ ```
631
+
632
+ - [ ] **Step 3: Test**
633
+
634
+ ```bash
635
+ # Create content/pages/card-icon-test.md:
636
+ # [card layout="icon-top" title="Feature" icon="star" subtitle="Sub"]Body.[/card]
637
+ # [card layout="icon-inline" title="Feature" icon="check"]Body.[/card]
638
+ curl -s http://localhost:4096/card-icon-test | grep -c 'card-header-icon'
639
+ # Expected: 2
640
+ # Delete test page
641
+ ```
642
+
643
+ - [ ] **Step 4: Commit**
644
+
645
+ ```bash
646
+ git add server/services/markdown.js
647
+ git commit -m "feat(markdown): add Tier 1 basic + icon card layout renderers"
648
+ ```
649
+
650
+ ---
651
+
652
+ ## Task 7: markdown.js - Tier 1 image layouts
653
+
654
+ **Files:**
655
+
656
+ - Modify: `server/services/markdown.js`
657
+
658
+ - [ ] **Step 1: Add all 7 image layouts**
659
+
660
+ ```js
661
+ LAYOUT_RENDERERS['image-top'] = (attrs, body) => {
662
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
663
+ const hdr = title ? `<div class="card-header"><div class="card-title">${escapeAttr(title)}</div></div>` : '';
664
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
665
+ return `${cardRoot(attrs)}<div class="card-img-top"${imgStyle(attrs)}></div>${hdr}<div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
666
+ };
667
+
668
+ LAYOUT_RENDERERS['image-overlay'] = (attrs, body) => {
669
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
670
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
671
+ return `${cardRoot(attrs)}
672
+ <div class="card-img-overlay"${imgStyle(attrs)}>
673
+ <div class="card-overlay-text"><div class="card-title">${escapeAttr(title)}</div></div>
674
+ </div>
675
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
676
+ };
677
+
678
+ LAYOUT_RENDERERS['thumb-left'] = (attrs, body) => {
679
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
680
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
681
+ return `${cardRoot(attrs, ['card-layout-thumb-left'])}<div class="card-img-left"${imgStyle(attrs)}></div>
682
+ <div class="card-body">${title ? `<div class="card-title">${escapeAttr(title)}</div>` : ''}${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
683
+ };
684
+
685
+ LAYOUT_RENDERERS['thumb-right'] = (attrs, body) => {
686
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
687
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
688
+ return `${cardRoot(attrs, ['card-layout-thumb-right'])}
689
+ <div class="card-body">${title ? `<div class="card-title">${escapeAttr(title)}</div>` : ''}${bodyHtml}</div>
690
+ <div class="card-img-right"${imgStyle(attrs)}></div></div>\n`;
691
+ };
692
+
693
+ LAYOUT_RENDERERS['wide-left-image'] = (attrs, body) => {
694
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
695
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
696
+ return `${cardRoot(attrs, ['card-layout-horizontal'])}<div class="card-img-wide"${imgStyle(attrs)}></div>
697
+ <div class="card-body">${title ? `<div class="card-title">${escapeAttr(title)}</div>` : ''}${bodyHtml}${cardFooterAttr(attrs)}</div></div>\n`;
698
+ };
699
+
700
+ LAYOUT_RENDERERS['full-bg'] = (attrs, body) => {
701
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
702
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
703
+ return `${cardRoot(attrs, ['dm-card-full-bg'])}<div class="card-img-top"${imgStyle(attrs)}></div>
704
+ <div class="card-body">${title ? `<div class="card-title">${escapeAttr(title)}</div>` : ''}${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
705
+ };
706
+
707
+ LAYOUT_RENDERERS['split-half'] = (attrs, body) => {
708
+ const title = typeof attrs.title === 'string' ? attrs.title.trim() : '';
709
+ const gradient = typeof attrs.gradient === 'string' ? attrs.gradient.trim() : 'indigo';
710
+ const bodyHtml = marked.parse(processGridBlocks(body.trim()));
711
+ return `${cardRoot(attrs, ['card-layout-split'])}<div class="card-split-left card-gradient-${escapeAttr(gradient)}"${imgStyle(attrs)}></div>
712
+ <div class="card-split-right"><div class="card-body">${title ? `<div class="card-title">${escapeAttr(title)}</div>` : ''}${bodyHtml}</div></div></div>\n`;
713
+ };
714
+ ```
715
+
716
+ - [ ] **Step 2: Test**
717
+
718
+ ```bash
719
+ # Create content/pages/card-img-test.md with image-top, thumb-left, split-half shortcodes
720
+ curl -s http://localhost:4096/card-img-test | grep -c 'card-img'
721
+ # Expected: 3+
722
+ # Delete test page
723
+ ```
724
+
725
+ - [ ] **Step 3: Commit**
726
+
727
+ ```bash
728
+ git add server/services/markdown.js
729
+ git commit -m "feat(markdown): add Tier 1 image card layout renderers"
730
+ ```
731
+
732
+ ---
733
+
734
+ ## Task 8: markdown.js - Tier 1 people, data, and special layouts
735
+
736
+ **Files:**
737
+
738
+ - Modify: `server/services/markdown.js`
739
+
740
+ - [ ] **Step 1: Add avatar-profile, stat-metric, quote-testimonial, callout**
741
+
742
+ ```js
743
+ LAYOUT_RENDERERS['avatar-profile'] = (attrs, body) => {
744
+ const title = escapeAttr(attrs.title || '');
745
+ const subtitle = escapeAttr(attrs.subtitle || '');
746
+ const icon = escapeAttr(attrs.icon || '');
747
+ const tags = typeof attrs.tags === 'string' ? attrs.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
748
+ const tagHtml = tags.length ? `<div class="card-tag-pills">${tags.map(t => `<span class="card-pill">${escapeAttr(t)}</span>`).join('')}</div>` : '';
749
+ const iconHtml = icon ? `<span data-icon="${icon}"></span>` : '';
750
+ const bodyHtml = body.trim() ? `<div class="card-body">${marked.parse(body.trim())}</div>` : '';
751
+ return `${cardRoot(attrs)}<div class="card-avatar-wrap">
752
+ <div class="card-avatar">${iconHtml}</div>
753
+ ${title ? `<div class="card-title">${title}</div>` : ''}
754
+ ${subtitle ? `<div class="card-subtitle">${subtitle}</div>` : ''}${tagHtml}
755
+ </div>${bodyHtml}${cardFooterAttr(attrs)}</div>\n`;
756
+ };
757
+
758
+ LAYOUT_RENDERERS['stat-metric'] = (attrs, _body) => {
759
+ const title = escapeAttr(attrs.title || '');
760
+ const value = escapeAttr(attrs.value || '-');
761
+ const delta = escapeAttr(attrs.delta || '');
762
+ const progress = Math.min(100, Math.max(0, parseInt(attrs.progress || '0', 10)));
763
+ const isPos = delta && !delta.startsWith('-') && !delta.startsWith('↓');
764
+ const deltaHtml = delta ? `<div class="card-stat-delta ${isPos ? 'positive' : 'negative'}">${delta}</div>` : '';
765
+ const barHtml = progress > 0 ? `<div class="card-stat-bar"><div class="card-stat-fill" style="width:${progress}%"></div></div>` : '';
766
+ return `${cardRoot(attrs)}<div class="card-body">
767
+ ${title ? `<div class="card-subtitle">${title}</div>` : ''}
768
+ <div class="card-stat-value">${value}</div>${deltaHtml}${barHtml}
769
+ </div>${cardFooterAttr(attrs)}</div>\n`;
770
+ };
771
+
772
+ LAYOUT_RENDERERS['quote-testimonial'] = (attrs, body) => {
773
+ const author = escapeAttr(attrs.title || '');
774
+ const role = escapeAttr(attrs.subtitle || '');
775
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
776
+ return `${cardRoot(attrs)}<span class="card-quote-mark">\u201c</span>
777
+ <div class="card-quote-text">${bodyHtml}</div>
778
+ <div class="card-footer card-quote-attr">
779
+ <div class="card-quote-avatar"></div>
780
+ <div>${author ? `<div style="font-weight:700;font-size:.85rem">${author}</div>` : ''}${role ? `<div class="card-subtitle">${role}</div>` : ''}</div>
781
+ </div></div>\n`;
782
+ };
783
+
784
+ LAYOUT_RENDERERS['callout'] = (attrs, body) => {
785
+ const type = typeof attrs['callout-type'] === 'string' ? attrs['callout-type'].trim() : 'info';
786
+ const defaults = { info:'💡', warn:'⚠️', success:'✅', error:'❌' };
787
+ const icon = escapeAttr(attrs.icon || defaults[type] || '💡');
788
+ const title = escapeAttr(attrs.title || '');
789
+ const bodyHtml = marked.parse(body.trim());
790
+ return `${cardRoot(attrs, ['card-callout', type])}<div class="card-callout-inner card-body">
791
+ <div class="card-callout-icon"><span data-icon="${icon}"></span></div>
792
+ <div>${title ? `<div class="card-title">${title}</div>` : ''}${bodyHtml}</div>
793
+ </div></div>\n`;
794
+ };
795
+ ```
796
+
797
+ - [ ] **Step 2: Add video-media, location-map, step-numbered, corner-badge, badge-band**
798
+
799
+ ```js
800
+ LAYOUT_RENDERERS['video-media'] = (attrs, body) => {
801
+ const title = escapeAttr(attrs.title || '');
802
+ const duration = escapeAttr(attrs.duration || '');
803
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
804
+ const dur = duration ? `<div class="card-video-duration">${duration}</div>` : '';
805
+ return `${cardRoot(attrs)}<div class="card-video-thumb"${imgStyle(attrs)}>
806
+ <div class="card-play-btn">\u25B6</div>${dur}
807
+ </div>${title ? `<div class="card-header"><div class="card-title">${title}</div></div>` : ''}
808
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
809
+ };
810
+
811
+ LAYOUT_RENDERERS['location-map'] = (attrs, body) => {
812
+ const address = escapeAttr(attrs.address || '');
813
+ const title = escapeAttr(attrs.title || '');
814
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
815
+ return `${cardRoot(attrs)}<div class="card-map-placeholder">
816
+ <div class="card-map-grid"></div><div class="card-map-pin">📍</div>
817
+ </div>${title || address ? `<div class="card-body">${title ? `<div class="card-title">${title}</div>` : ''}${address ? `<div class="card-subtitle">${address}</div>` : ''}${bodyHtml}</div>` : ''}</div>\n`;
818
+ };
819
+
820
+ LAYOUT_RENDERERS['step-numbered'] = (attrs, body) => {
821
+ const step = escapeAttr(String(attrs.step || '1'));
822
+ const title = escapeAttr(attrs.title || '');
823
+ const gradient = escapeAttr(attrs.gradient || 'indigo');
824
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
825
+ return `${cardRoot(attrs)}<div class="card-step-bg card-gradient-${gradient}">
826
+ <div class="card-step-badge">${step}</div>
827
+ <div class="card-step-ghost">${step}</div>
828
+ </div>${title ? `<div class="card-header"><div class="card-title">${title}</div></div>` : ''}
829
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
830
+ };
831
+
832
+ LAYOUT_RENDERERS['corner-badge'] = (attrs, body) => {
833
+ const badge = escapeAttr(attrs.badge || 'New');
834
+ const title = escapeAttr(attrs.title || '');
835
+ const icon = escapeAttr(attrs.icon || '');
836
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
837
+ return `${cardRoot(attrs, ['card-corner-badge-wrap'])}<span class="card-corner-badge">${badge}</span>
838
+ ${icon ? `<div style="display:flex;justify-content:center;padding:18px 16px 4px"><span data-icon="${icon}" style="font-size:2rem"></span></div>` : ''}
839
+ <div class="card-body" style="text-align:center">${title ? `<div class="card-title">${title}</div>` : ''}${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
840
+ };
841
+
842
+ LAYOUT_RENDERERS['badge-band'] = (attrs, body) => {
843
+ const badge = escapeAttr(attrs.badge || 'New');
844
+ const icon = escapeAttr(attrs.icon || '');
845
+ const gradient = escapeAttr(attrs.gradient || 'indigo');
846
+ const title = escapeAttr(attrs.title || '');
847
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
848
+ return `${cardRoot(attrs)}<div class="card-badge-band card-gradient-${gradient}">
849
+ <span class="card-badge-band-label">${badge}</span>
850
+ ${icon ? `<span class="card-badge-band-icon" data-icon="${icon}"></span>` : ''}
851
+ </div>${title ? `<div class="card-header"><div class="card-title">${title}</div></div>` : ''}
852
+ <div class="card-body">${bodyHtml}</div>${cardFooterAttr(attrs)}</div>\n`;
853
+ };
854
+ ```
855
+
856
+ - [ ] **Step 3: Add glass-gradient-border, rating-review, tag-cloud, timeline-entry, code-snippet**
857
+
858
+ ```js
859
+ LAYOUT_RENDERERS['glass-gradient-border'] = (attrs, body) => {
860
+ const title = escapeAttr(attrs.title || '');
861
+ const bodyHtml = marked.parse(body.trim());
862
+ return `<div class="card-glass-outer mb-4">
863
+ <div class="card-glass-inner">
864
+ ${title ? `<div class="card-title">${title}</div>` : ''}<div>${bodyHtml}</div>
865
+ </div></div>\n`;
866
+ };
867
+
868
+ LAYOUT_RENDERERS['rating-review'] = (attrs, body) => {
869
+ const rating = Math.min(5, Math.max(0, parseInt(attrs.rating || '5', 10)));
870
+ const stars = '\u2605'.repeat(rating) + '\u2606'.repeat(5 - rating);
871
+ const author = escapeAttr(attrs.title || '');
872
+ const role = escapeAttr(attrs.subtitle || '');
873
+ const verified = 'verified' in attrs;
874
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
875
+ return `${cardRoot(attrs)}<span class="card-stars">${stars}</span>
876
+ <div class="card-quote-text">${bodyHtml}</div>
877
+ <div class="card-footer card-quote-attr">
878
+ <div class="card-quote-avatar"></div>
879
+ <div>${author ? `<div style="font-weight:700;font-size:.82rem">${author}${verified ? '<span class="card-verified">Verified</span>' : ''}</div>` : ''}
880
+ ${role ? `<div class="card-subtitle">${role}</div>` : ''}</div>
881
+ </div></div>\n`;
882
+ };
883
+
884
+ LAYOUT_RENDERERS['tag-cloud'] = (attrs, body) => {
885
+ const title = escapeAttr(attrs.title || '');
886
+ const tagAttr = typeof attrs.tags === 'string' ? attrs.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
887
+ const tagHtml = tagAttr.map((t, i) => `<span class="card-tag" style="${tagColorStyle(i)}">${escapeAttr(t)}</span>`).join('');
888
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
889
+ return `${cardRoot(attrs)}${title ? `<div class="card-header"><div class="card-title">${title}</div></div>` : ''}
890
+ <div class="card-tag-cloud">${tagHtml}${bodyHtml}</div></div>\n`;
891
+ };
892
+
893
+ LAYOUT_RENDERERS['timeline-entry'] = (attrs, body) => {
894
+ const date = escapeAttr(attrs.subtitle || '');
895
+ const title = escapeAttr(attrs.title || '');
896
+ const tag = escapeAttr(attrs.badge || '');
897
+ const bodyHtml = body.trim() ? marked.parse(body.trim()) : '';
898
+ return `${cardRoot(attrs)}<div class="card-timeline-row">
899
+ <div class="card-timeline-side"><div class="card-timeline-dot"></div><div class="card-timeline-line"></div></div>
900
+ <div class="card-timeline-body">
901
+ ${date ? `<div class="card-timeline-date">${date}</div>` : ''}
902
+ ${title ? `<div class="card-title">${title}</div>` : ''}
903
+ ${bodyHtml}
904
+ ${tag ? `<span class="card-timeline-tag">${tag}</span>` : ''}
905
+ </div>
906
+ </div></div>\n`;
907
+ };
908
+
909
+ LAYOUT_RENDERERS['code-snippet'] = (attrs, body) => {
910
+ const lang = escapeAttr(attrs.lang || 'Code');
911
+ const code = body.trim()
912
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
913
+ return `${cardRoot(attrs)}<div class="card-code-header">
914
+ <span class="card-code-lang">${lang}</span>
915
+ </div><div class="card-code-body"><pre><code>${code}</code></pre></div></div>\n`;
916
+ };
917
+ ```
918
+
919
+ - [ ] **Step 4: Test**
920
+
921
+ ```bash
922
+ # Create content/pages/card-special-test.md with callout, stat-metric, timeline-entry, code-snippet
923
+ curl -s http://localhost:4096/card-special-test | grep -c 'card-callout\|card-stat\|card-timeline\|card-code'
924
+ # Expected: 4
925
+ # Delete test page
926
+ ```
927
+
928
+ - [ ] **Step 5: Commit**
929
+
930
+ ```bash
931
+ git add server/services/markdown.js
932
+ git commit -m "feat(markdown): add Tier 1 people/data/special card layout renderers"
933
+ ```
934
+
935
+ ---
936
+
937
+ ## Task 9: markdown.js - Tier 2 sub-tag layouts
938
+
939
+ **Files:**
940
+
941
+ - Modify: `server/services/markdown.js`
942
+
943
+ - [ ] **Step 1: Add before-after, pricing, feature-comparison**
944
+
945
+ ```js
946
+ LAYOUT_RENDERERS['before-after'] = (attrs, body) => {
947
+ const title = escapeAttr(attrs.title || '');
948
+ const beforeRaw = parseSubTag(body, 'before') || '';
949
+ const afterRaw = parseSubTag(body, 'after') || '';
950
+ const beforeItems = beforeRaw.split(/[·\n]/).map(s => s.trim()).filter(Boolean);
951
+ const afterItems = afterRaw.split(/[·\n]/).map(s => s.trim()).filter(Boolean);
952
+ const colBefore = beforeItems.map(i => `<div class="card-compare-item"><span class="card-compare-x">\u2717</span>${escapeAttr(i)}</div>`).join('');
953
+ const colAfter = afterItems.map(i => `<div class="card-compare-item"><span class="card-compare-check">\u2713</span>${escapeAttr(i)}</div>`).join('');
954
+ return `${cardRoot(attrs)}${title ? `<div class="card-header"><div class="card-title">${title}</div></div>` : ''}
955
+ <div class="card-compare-grid">
956
+ <div class="card-compare-col"><span class="card-compare-label before">Before</span>${colBefore}</div>
957
+ <div class="card-compare-col"><span class="card-compare-label after">After</span>${colAfter}</div>
958
+ </div></div>\n`;
959
+ };
960
+
961
+ LAYOUT_RENDERERS['pricing'] = (attrs, body) => {
962
+ const title = escapeAttr(attrs.title || '');
963
+ const price = escapeAttr(attrs.price || '');
964
+ const period = escapeAttr(attrs.period || '');
965
+ const gradient = escapeAttr(attrs.gradient || 'indigo');
966
+ const cta = escapeAttr(attrs.footer || 'Get started');
967
+ const features = parseSubTagList(body, 'feature');
968
+ const featHtml = features.map(f => `<div class="card-pricing-feature"><span class="card-pricing-check">\u2713</span>${escapeAttr(f.content)}</div>`).join('');
969
+ return `${cardRoot(attrs)}<div class="card-header card-gradient-${gradient}">
970
+ <div class="card-subtitle">${title}</div>
971
+ <div class="card-stat-value" style="color:#fff">${price}</div>
972
+ ${period ? `<div style="opacity:.75;font-size:.8rem">${period}</div>` : ''}
973
+ </div><div class="card-pricing-features">${featHtml}</div>
974
+ <a class="card-pricing-cta" href="#">${cta}</a></div>\n`;
975
+ };
976
+
977
+ LAYOUT_RENDERERS['feature-comparison'] = (attrs, body) => {
978
+ const title = escapeAttr(attrs.title || '');
979
+ const plan = escapeAttr(attrs.subtitle || '');
980
+ const gradient = escapeAttr(attrs.gradient || 'indigo');
981
+ const features = parseSubTagList(body, 'feature');
982
+ const rows = features.map(f => {
983
+ const included = !('excluded' in f.attrs);
984
+ return `<div class="card-fc-row"><span>${escapeAttr(f.content)}</span><span class="${included ? 'card-fc-yes' : 'card-fc-no'}">${included ? '\u2713' : '\u2717'}</span></div>`;
985
+ }).join('');
986
+ return `${cardRoot(attrs)}<div class="card-header card-gradient-${gradient}">
987
+ ${plan ? `<div class="card-subtitle" style="opacity:.8;font-size:.75rem">${plan}</div>` : ''}
988
+ <div class="card-title">${title}</div>
989
+ </div>${rows}</div>\n`;
990
+ };
991
+ ```
992
+
993
+ - [ ] **Step 2: Add activity-feed, progress-goal, file-document**
994
+
995
+ ```js
996
+ LAYOUT_RENDERERS['activity-feed'] = (attrs, body) => {
997
+ const title = escapeAttr(attrs.title || 'Activity');
998
+ const activities = parseSubTagList(body, 'activity');
999
+ const items = activities.map(a => {
1000
+ const user = escapeAttr(a.attrs.user || '');
1001
+ const action = escapeAttr(a.attrs.action || '');
1002
+ const time = escapeAttr(a.attrs.time || '');
1003
+ return `<div class="card-activity-item">
1004
+ <div class="card-activity-avatar">${user.charAt(0).toUpperCase()}</div>
1005
+ <div><div style="font-size:.82rem"><strong>${user}</strong> ${action}</div>${time ? `<div style="font-size:.7rem;color:#9ca3af;margin-top:2px">${time}</div>` : ''}</div>
1006
+ </div>`;
1007
+ }).join('');
1008
+ return `${cardRoot(attrs)}<div class="card-header"><div class="card-title">${title}</div></div>
1009
+ <div>${items}</div>${cardFooterAttr(attrs)}</div>\n`;
1010
+ };
1011
+
1012
+ LAYOUT_RENDERERS['progress-goal'] = (attrs, body) => {
1013
+ const title = escapeAttr(attrs.title || '');
1014
+ const subtitle = escapeAttr(attrs.subtitle || '');
1015
+ const progress = Math.min(100, Math.max(0, parseInt(attrs.progress || '0', 10)));
1016
+ const milestones = parseSubTagList(body, 'milestone');
1017
+ const msHtml = milestones.map(m => {
1018
+ const done = 'done' in m.attrs;
1019
+ return `<div class="card-milestone"><div class="card-milestone-dot ${done ? 'done' : 'pending'}"></div>${escapeAttr(m.content)}</div>`;
1020
+ }).join('');
1021
+ return `${cardRoot(attrs)}<div class="card-body">
1022
+ ${title ? `<div class="card-title">${title}</div>` : ''}
1023
+ <div style="display:flex;justify-content:space-between;font-size:.82rem;margin-top:8px">
1024
+ <span>${subtitle}</span><strong style="color:#6366f1">${progress}%</strong>
1025
+ </div>
1026
+ <div class="card-progress-bar"><div class="card-progress-fill" style="width:${progress}%"></div></div>
1027
+ ${msHtml}
1028
+ </div>${cardFooterAttr(attrs)}</div>\n`;
1029
+ };
1030
+
1031
+ LAYOUT_RENDERERS['file-document'] = (attrs, _body) => {
1032
+ const filename = escapeAttr(attrs.filename || 'document');
1033
+ const filesize = escapeAttr(attrs.filesize || '');
1034
+ const filetype = escapeAttr((attrs.filetype || '').toUpperCase());
1035
+ const title = escapeAttr(attrs.title || filename);
1036
+ return `${cardRoot(attrs)}<div class="card-file-row">
1037
+ <div class="card-file-icon">\uD83D\uDCC4<div class="card-file-ext">${filetype}</div></div>
1038
+ <div><div class="card-title" style="font-size:.9rem">${title}</div>
1039
+ ${filesize ? `<div class="card-subtitle">${filesize}</div>` : ''}
1040
+ <a class="card-file-dl" href="#">\u2193 Download</a></div>
1041
+ </div></div>\n`;
1042
+ };
1043
+ ```
1044
+
1045
+ - [ ] **Step 3: Test**
1046
+
1047
+ ```bash
1048
+ # Create content/pages/card-tier2-test.md with pricing, before-after, activity-feed shortcodes
1049
+ curl -s http://localhost:4096/card-tier2-test | grep -c 'card-pricing\|card-compare\|card-activity'
1050
+ # Expected: 3+
1051
+ # Delete test page
1052
+ ```
1053
+
1054
+ - [ ] **Step 4: Commit**
1055
+
1056
+ ```bash
1057
+ git add server/services/markdown.js
1058
+ git commit -m "feat(markdown): add Tier 2 sub-tag card layout renderers"
1059
+ ```
1060
+
1061
+ ---
1062
+
1063
+ ## Task 10: card-builder.js - Module scaffold, state, shortcode generator
1064
+
1065
+ **Files:**
1066
+
1067
+ - Create: `admin/js/lib/card-builder.js`
1068
+
1069
+ - [ ] **Step 1: Create the file with layout registry, state object, and `CardBuilder.open()`**
1070
+
1071
+ ```js
1072
+ /**
1073
+ * card-builder.js
1074
+ * Self-contained card builder dialog.
1075
+ * API: CardBuilder.open(textarea, { onMediaBrowse })
1076
+ */
1077
+
1078
+ const LAYOUTS = [
1079
+ { key:'header-body', group:'Basic', label:'Header + Body', thumb:'▤' },
1080
+ { key:'basic', group:'Basic', label:'Body only', thumb:'▭' },
1081
+ { key:'header-body-footer', group:'Basic', label:'Header + Body + Footer', thumb:'☰' },
1082
+ { key:'no-header-footer', group:'Basic', label:'Body + Footer (no header)', thumb:'▥' },
1083
+ { key:'icon-top', group:'Icon', label:'Icon top (stacked)', thumb:'⬆★' },
1084
+ { key:'icon-inline', group:'Icon', label:'Icon inline', thumb:'★▶' },
1085
+ { key:'image-top', group:'Image', label:'Image top', thumb:'🖼' },
1086
+ { key:'image-overlay', group:'Image', label:'Image + overlay title', thumb:'🖼✦' },
1087
+ { key:'thumb-left', group:'Image', label:'Thumbnail left', thumb:'🖼▐' },
1088
+ { key:'thumb-right', group:'Image', label:'Thumbnail right', thumb:'▌🖼' },
1089
+ { key:'wide-left-image', group:'Image', label:'Wide image left', thumb:'🖼▍' },
1090
+ { key:'full-bg', group:'Image', label:'Full background', thumb:'🖼' },
1091
+ { key:'split-half', group:'Image', label:'Split half', thumb:'▒▐' },
1092
+ { key:'avatar-profile', group:'People', label:'Avatar / Profile', thumb:'👤' },
1093
+ { key:'stat-metric', group:'People', label:'Stat / Metric', thumb:'📊' },
1094
+ { key:'quote-testimonial', group:'People', label:'Quote / Testimonial', thumb:'\u201c' },
1095
+ { key:'rating-review', group:'People', label:'Rating / Review', thumb:'★' },
1096
+ { key:'activity-feed', group:'People', label:'Activity feed', thumb:'🔔' },
1097
+ { key:'callout', group:'Content', label:'Callout', thumb:'💡' },
1098
+ { key:'code-snippet', group:'Content', label:'Code snippet', thumb:'</>' },
1099
+ { key:'file-document', group:'Content', label:'File / Document', thumb:'📄' },
1100
+ { key:'tag-cloud', group:'Content', label:'Tag cloud', thumb:'🏷' },
1101
+ { key:'video-media', group:'Media', label:'Video / Media', thumb:'▶' },
1102
+ { key:'location-map', group:'Special', label:'Location / Map', thumb:'📍' },
1103
+ { key:'step-numbered', group:'Special', label:'Step / Numbered', thumb:'①' },
1104
+ { key:'corner-badge', group:'Special', label:'Corner badge', thumb:'🏷' },
1105
+ { key:'badge-band', group:'Special', label:'Badge band', thumb:'⬛' },
1106
+ { key:'glass-gradient-border', group:'Special', label:'Glass gradient border', thumb:'◈' },
1107
+ { key:'timeline-entry', group:'Special', label:'Timeline entry', thumb:'•-' },
1108
+ { key:'before-after', group:'Compare', label:'Before / After', thumb:'↔' },
1109
+ { key:'pricing', group:'Compare', label:'Pricing card', thumb:'£' },
1110
+ { key:'feature-comparison', group:'Compare', label:'Feature comparison', thumb:'☑' },
1111
+ { key:'progress-goal', group:'Compare', label:'Progress / Goal', thumb:'▰' },
1112
+ ];
1113
+
1114
+ const VARIANTS = ['clean','gradient','glass','accent','dark','glow'];
1115
+ const GRADIENTS = ['indigo','ocean','sunset','forest','rose','midnight','aurora','fire','lagoon','dusk','lime','gold','arctic','slate'];
1116
+
1117
+ const GRAD_COLORS = {
1118
+ indigo:'#6366f1,#8b5cf6', ocean:'#0891b2,#2563eb', sunset:'#f59e0b,#ef4444',
1119
+ forest:'#10b981,#0d9488', rose:'#fb7185,#e11d48', midnight:'#1e1b4b,#4338ca',
1120
+ aurora:'#06b6d4,#6366f1', fire:'#ef4444,#f97316', lagoon:'#06b6d4,#0e7490',
1121
+ dusk:'#7c3aed,#db2777', lime:'#84cc16,#10b981', gold:'#f59e0b,#b45309',
1122
+ arctic:'#bae6fd,#e0f2fe', slate:'#475569,#1e293b',
1123
+ };
1124
+
1125
+ // Singleton state
1126
+ let _overlay = null;
1127
+ let _state = null;
1128
+ let _ta = null;
1129
+ let _opts = null;
1130
+
1131
+ export const CardBuilder = {
1132
+ open(textarea, options = {}) {
1133
+ if (_overlay) return;
1134
+ _ta = textarea;
1135
+ _opts = options;
1136
+ _state = {
1137
+ layout:'header-body', variant:'clean', gradient:'indigo',
1138
+ title:'', subtitle:'', icon:'', iconLayout:'inline', image:'',
1139
+ body:'', footer:'', hover:false, collapsible:false,
1140
+ value:'', delta:'', progress:'50', price:'', period:'',
1141
+ lang:'JavaScript', filename:'', filesize:'', filetype:'PDF',
1142
+ rating:'5', duration:'', address:'', step:'1', badge:'New',
1143
+ tags:'', calloutType:'info',
1144
+ };
1145
+ _buildDialog();
1146
+ },
1147
+ close() { _closeDialog(); },
1148
+ };
1149
+ ```
1150
+
1151
+ - [ ] **Step 2: Add `_buildShortcode()`**
1152
+
1153
+ ```js
1154
+ function _buildShortcode() {
1155
+ const s = _state;
1156
+ const a = [];
1157
+
1158
+ if (s.layout !== 'header-body') a.push(`layout="${s.layout}"`);
1159
+ if (s.title) a.push(`title="${s.title}"`);
1160
+ if (s.subtitle) a.push(`subtitle="${s.subtitle}"`);
1161
+ if (s.icon) a.push(`icon="${s.icon}"`);
1162
+ if (s.icon && s.iconLayout === 'stacked') a.push('icon-layout="stacked"');
1163
+ if (s.image) a.push(`image="${s.image}"`);
1164
+ if (s.variant !== 'clean') a.push(`variant="${s.variant}"`);
1165
+ if (s.variant === 'gradient' && s.gradient !== 'indigo') a.push(`gradient="${s.gradient}"`);
1166
+ if (s.hover) a.push('hover');
1167
+ if (s.collapsible) a.push('collapsible="true"');
1168
+ if (s.footer && !['pricing','feature-comparison','activity-feed','before-after','progress-goal'].includes(s.layout)) {
1169
+ a.push(`footer="${s.footer}"`);
1170
+ }
1171
+
1172
+ const lsa = {
1173
+ 'stat-metric': () => { if (s.value) a.push(`value="${s.value}"`); if (s.delta) a.push(`delta="${s.delta}"`); if (s.progress) a.push(`progress="${s.progress}"`); },
1174
+ 'pricing': () => { if (s.price) a.push(`price="${s.price}"`); if (s.period) a.push(`period="${s.period}"`); },
1175
+ 'code-snippet': () => a.push(`lang="${s.lang}"`),
1176
+ 'file-document': () => { a.push(`filename="${s.filename}"`); if (s.filesize) a.push(`filesize="${s.filesize}"`); if (s.filetype) a.push(`filetype="${s.filetype}"`); },
1177
+ 'rating-review': () => a.push(`rating="${s.rating}"`),
1178
+ 'video-media': () => { if (s.duration) a.push(`duration="${s.duration}"`); },
1179
+ 'location-map': () => { if (s.address) a.push(`address="${s.address}"`); },
1180
+ 'step-numbered': () => a.push(`step="${s.step}"`),
1181
+ 'corner-badge': () => { if (s.badge) a.push(`badge="${s.badge}"`); },
1182
+ 'badge-band': () => { if (s.badge) a.push(`badge="${s.badge}"`); },
1183
+ 'tag-cloud': () => { if (s.tags) a.push(`tags="${s.tags}"`); },
1184
+ 'avatar-profile': () => { if (s.tags) a.push(`tags="${s.tags}"`); },
1185
+ 'callout': () => { if (s.calloutType) a.push(`callout-type="${s.calloutType}"`); },
1186
+ 'split-half': () => {},
1187
+ };
1188
+ if (lsa[s.layout]) lsa[s.layout]();
1189
+
1190
+ const attrStr = a.length ? ' ' + a.join(' ') : '';
1191
+ const body = s.body.trim() || 'Content here.';
1192
+
1193
+ const tier2defaults = {
1194
+ 'before-after': `[before]Before item 1 · Before item 2[/before]\n[after]After item 1 · After item 2[/after]`,
1195
+ 'pricing': `[feature]Feature one[/feature]\n[feature]Feature two[/feature]`,
1196
+ 'feature-comparison':`[feature]Included feature[/feature]\n[feature excluded]Excluded feature[/feature]`,
1197
+ 'activity-feed': `[activity user="Alice" action="published a page" time="2 min ago"]`,
1198
+ 'progress-goal': `[milestone done]Phase 1[/milestone]\n[milestone]Phase 2[/milestone]`,
1199
+ };
1200
+ const resolvedBody = tier2defaults[s.layout] || body;
1201
+
1202
+ return `[card${attrStr}]\n${resolvedBody}\n[/card]`;
1203
+ }
1204
+
1205
+ function _closeDialog() {
1206
+ if (_overlay) { _overlay.remove(); _overlay = null; _state = null; }
1207
+ }
1208
+
1209
+ function _insert() {
1210
+ const snippet = _buildShortcode();
1211
+ const start = _ta.selectionStart;
1212
+ const end = _ta.selectionEnd;
1213
+ _ta.value = _ta.value.slice(0, start) + snippet + _ta.value.slice(end);
1214
+ _ta.selectionStart = _ta.selectionEnd = start + snippet.length;
1215
+ _ta.dispatchEvent(new Event('input', { bubbles: true }));
1216
+ _ta.focus();
1217
+ _closeDialog();
1218
+ }
1219
+ ```
1220
+
1221
+ - [ ] **Step 3: Verify module syntax**
1222
+
1223
+ ```bash
1224
+ node --input-type=module << 'EOF'
1225
+ import { CardBuilder } from '/home/darryl/src/js/domma-cms/admin/js/lib/card-builder.js';
1226
+ console.log('OK:', Object.keys(CardBuilder).join(', '));
1227
+ EOF
1228
+ ```
1229
+
1230
+ Expected: `OK: open, close`
1231
+
1232
+ - [ ] **Step 4: Commit**
1233
+
1234
+ ```bash
1235
+ git add admin/js/lib/card-builder.js
1236
+ git commit -m "feat(admin): add card-builder module scaffold + shortcode generator"
1237
+ ```
1238
+
1239
+ ---
1240
+
1241
+ ## Task 11: card-builder.js - Dialog DOM + control panel
1242
+
1243
+ **Files:**
1244
+
1245
+ - Modify: `admin/js/lib/card-builder.js`
1246
+
1247
+ - [ ] **Step 1: Add `_buildDialog()` - overlay, two-panel layout, header, footer**
1248
+
1249
+ ```js
1250
+ function _buildDialog() {
1251
+ _overlay = document.createElement('div');
1252
+ Object.assign(_overlay.style, {
1253
+ position:'fixed', inset:'0', background:'rgba(0,0,0,.6)',
1254
+ display:'flex', alignItems:'center', justifyContent:'center',
1255
+ zIndex:'9999', padding:'24px',
1256
+ });
1257
+
1258
+ const dialog = document.createElement('div');
1259
+ Object.assign(dialog.style, {
1260
+ background:'#1e293b', border:'1px solid #334155', borderRadius:'14px',
1261
+ width:'100%', maxWidth:'860px', maxHeight:'90vh',
1262
+ display:'flex', flexDirection:'column',
1263
+ boxShadow:'0 24px 64px rgba(0,0,0,.6)', overflow:'hidden',
1264
+ });
1265
+
1266
+ const hdr = _el('div', 'display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid #334155;flex-shrink:0');
1267
+ const htitle = _el('span', 'font-weight:700;color:#f1f5f9;font-size:14px');
1268
+ htitle.textContent = 'Insert Card';
1269
+ const closeBtn = _el('button', 'background:#334155;border:none;color:#94a3b8;border-radius:6px;width:26px;height:26px;cursor:pointer;font-size:14px');
1270
+ closeBtn.textContent = '\u2715';
1271
+ closeBtn.addEventListener('click', _closeDialog);
1272
+ hdr.appendChild(htitle);
1273
+ hdr.appendChild(closeBtn);
1274
+
1275
+ const body = _el('div', 'display:grid;grid-template-columns:280px 1fr;flex:1;overflow:hidden');
1276
+ body.appendChild(_buildControlPanel());
1277
+ body.appendChild(_buildPreviewPanel());
1278
+
1279
+ const ftr = _el('div', 'display:flex;justify-content:flex-end;gap:10px;padding:12px 20px;border-top:1px solid #334155;flex-shrink:0');
1280
+ const cancelBtn = _el('button', 'padding:8px 18px;border-radius:8px;border:none;background:#334155;color:#94a3b8;font-size:12px;font-weight:700;cursor:pointer');
1281
+ cancelBtn.textContent = 'Cancel';
1282
+ cancelBtn.addEventListener('click', _closeDialog);
1283
+ const insertBtn = _el('button', 'padding:8px 18px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-size:12px;font-weight:700;cursor:pointer');
1284
+ insertBtn.textContent = 'Insert Card';
1285
+ insertBtn.addEventListener('click', _insert);
1286
+ ftr.appendChild(cancelBtn);
1287
+ ftr.appendChild(insertBtn);
1288
+
1289
+ dialog.appendChild(hdr);
1290
+ dialog.appendChild(body);
1291
+ dialog.appendChild(ftr);
1292
+ _overlay.appendChild(dialog);
1293
+ _overlay.addEventListener('click', e => { if (e.target === _overlay) _closeDialog(); });
1294
+ document.body.appendChild(_overlay);
1295
+ _refreshDynFields();
1296
+ _updatePreview();
1297
+ }
1298
+
1299
+ // DOM helper
1300
+ function _el(tag, css = '') {
1301
+ const el = document.createElement(tag);
1302
+ if (css) el.style.cssText = css;
1303
+ return el;
1304
+ }
1305
+ ```
1306
+
1307
+ - [ ] **Step 2: Add `_buildControlPanel()` - layout dropdown, style swatches, gradient picker, options**
1308
+
1309
+ ```js
1310
+ const IS = 'padding:7px 10px;border-radius:7px;border:1px solid #334155;background:#0f172a;color:#e2e8f0;font-size:12px;font-family:inherit;width:100%';
1311
+
1312
+ function _buildControlPanel() {
1313
+ const panel = _el('div', 'padding:18px;border-right:1px solid #334155;display:flex;flex-direction:column;gap:14px;overflow-y:auto');
1314
+ panel.appendChild(_field('Layout', _layoutSelect()));
1315
+
1316
+ const dynFields = _el('div', 'display:contents');
1317
+ dynFields.id = 'cb-dyn-fields';
1318
+ panel.appendChild(dynFields);
1319
+
1320
+ panel.appendChild(_divider());
1321
+ panel.appendChild(_field('Style', _styleSwatches()));
1322
+
1323
+ const gradWrap = _el('div');
1324
+ gradWrap.id = 'cb-grad-wrap';
1325
+ gradWrap.style.display = _state.variant === 'gradient' ? '' : 'none';
1326
+ gradWrap.appendChild(_field('Gradient', _gradientPicker()));
1327
+ panel.appendChild(gradWrap);
1328
+
1329
+ panel.appendChild(_field('Options', _optionToggles()));
1330
+ return panel;
1331
+ }
1332
+
1333
+ function _layoutSelect() {
1334
+ const sel = _el('select', IS + ';cursor:pointer');
1335
+ const groups = {};
1336
+ LAYOUTS.forEach(l => { (groups[l.group] = groups[l.group] || []).push(l); });
1337
+ Object.entries(groups).forEach(([grp, items]) => {
1338
+ const og = document.createElement('optgroup');
1339
+ og.label = grp;
1340
+ items.forEach(l => {
1341
+ const opt = document.createElement('option');
1342
+ opt.value = l.key;
1343
+ opt.textContent = `${l.thumb} ${l.label}`;
1344
+ if (l.key === _state.layout) opt.selected = true;
1345
+ og.appendChild(opt);
1346
+ });
1347
+ sel.appendChild(og);
1348
+ });
1349
+ sel.addEventListener('change', () => { _state.layout = sel.value; _refreshDynFields(); _updatePreview(); });
1350
+ return sel;
1351
+ }
1352
+
1353
+ function _styleSwatches() {
1354
+ const wrap = _el('div', 'display:grid;grid-template-columns:repeat(3,1fr);gap:5px');
1355
+ const swStyle = {
1356
+ clean: 'background:#f8fafc;color:#374151;border:2px solid #e2e8f0',
1357
+ gradient:'background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;border:2px solid transparent',
1358
+ glass: 'background:rgba(255,255,255,.06);color:#94a3b8;border:2px dashed #334155',
1359
+ accent: 'background:#fff;color:#4338ca;border-left:4px solid #6366f1;border-top:2px solid #e2e8f0;border-right:2px solid #e2e8f0;border-bottom:2px solid #e2e8f0',
1360
+ dark: 'background:#0f172a;color:#818cf8;border:2px solid #334155',
1361
+ glow: 'color:#818cf8;border:2px solid #6366f1;box-shadow:0 0 6px rgba(99,102,241,.4)',
1362
+ };
1363
+ VARIANTS.forEach(v => {
1364
+ const sw = _el('div', `padding:6px 4px;border-radius:7px;font-size:10px;font-weight:700;text-align:center;cursor:pointer;${swStyle[v]}`);
1365
+ sw.textContent = v.charAt(0).toUpperCase() + v.slice(1);
1366
+ sw.dataset.variant = v;
1367
+ if (v === _state.variant) sw.style.outline = '2px solid #6366f1';
1368
+ sw.addEventListener('click', () => {
1369
+ _state.variant = v;
1370
+ wrap.querySelectorAll('[data-variant]').forEach(s => { s.style.outline = ''; });
1371
+ sw.style.outline = '2px solid #6366f1';
1372
+ const gw = document.getElementById('cb-grad-wrap');
1373
+ if (gw) gw.style.display = v === 'gradient' ? '' : 'none';
1374
+ _updatePreview();
1375
+ });
1376
+ wrap.appendChild(sw);
1377
+ });
1378
+ return wrap;
1379
+ }
1380
+
1381
+ function _gradientPicker() {
1382
+ const wrap = _el('div', 'display:flex;flex-wrap:wrap;gap:7px');
1383
+ GRADIENTS.forEach(g => {
1384
+ const [c1, c2] = (GRAD_COLORS[g] || '#6366f1,#8b5cf6').split(',');
1385
+ const c = _el('div', `width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,${c1},${c2});cursor:pointer;border:2px solid transparent;transition:border-color .15s`);
1386
+ c.title = g;
1387
+ if (g === _state.gradient) c.style.borderColor = '#fff';
1388
+ c.addEventListener('click', () => {
1389
+ _state.gradient = g;
1390
+ wrap.querySelectorAll('div').forEach(d => { d.style.borderColor = 'transparent'; });
1391
+ c.style.borderColor = '#fff';
1392
+ _updatePreview();
1393
+ });
1394
+ wrap.appendChild(c);
1395
+ });
1396
+ return wrap;
1397
+ }
1398
+
1399
+ function _optionToggles() {
1400
+ const row = _el('div', 'display:flex;gap:6px');
1401
+ ['hover','collapsible'].forEach(key => {
1402
+ const btn = _el('div');
1403
+ const setStyle = (on) => {
1404
+ btn.style.cssText = `flex:1;padding:6px 8px;border-radius:7px;border:1px solid;font-size:11px;font-weight:700;text-align:center;cursor:pointer;${on ? 'border-color:#6366f1;background:#1e1b4b;color:#a5b4fc' : 'border-color:#334155;background:#0f172a;color:#64748b'}`;
1405
+ };
1406
+ setStyle(_state[key]);
1407
+ btn.textContent = key.charAt(0).toUpperCase() + key.slice(1);
1408
+ btn.addEventListener('click', () => { _state[key] = !_state[key]; setStyle(_state[key]); _updatePreview(); });
1409
+ row.appendChild(btn);
1410
+ });
1411
+ return row;
1412
+ }
1413
+
1414
+ // Field builder helpers
1415
+ function _field(label, el) {
1416
+ const wrap = _el('div', 'display:flex;flex-direction:column;gap:5px');
1417
+ const lbl = _el('div', 'font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#475569');
1418
+ lbl.textContent = label;
1419
+ wrap.appendChild(lbl);
1420
+ wrap.appendChild(el);
1421
+ return wrap;
1422
+ }
1423
+
1424
+ function _divider() {
1425
+ const d = _el('div', 'height:1px;background:#1e293b;margin:2px 0');
1426
+ return d;
1427
+ }
1428
+
1429
+ function _textInput(stateKey, placeholder) {
1430
+ const input = _el('input', IS);
1431
+ input.type = 'text';
1432
+ if (placeholder) input.placeholder = placeholder;
1433
+ input.value = _state[stateKey] || '';
1434
+ input.addEventListener('input', () => { _state[stateKey] = input.value; _updatePreview(); });
1435
+ return input;
1436
+ }
1437
+
1438
+ function _selectInput(stateKey, options) {
1439
+ const sel = _el('select', IS + ';cursor:pointer');
1440
+ options.forEach(o => {
1441
+ const opt = document.createElement('option');
1442
+ opt.value = o; opt.textContent = o;
1443
+ if (o === _state[stateKey]) opt.selected = true;
1444
+ sel.appendChild(opt);
1445
+ });
1446
+ sel.addEventListener('change', () => { _state[stateKey] = sel.value; _updatePreview(); });
1447
+ return sel;
1448
+ }
1449
+ ```
1450
+
1451
+ - [ ] **Step 3: Add `_refreshDynFields()` - replaces the dynamic fields on layout change**
1452
+
1453
+ ```js
1454
+ const LAYOUT_FIELDS = {
1455
+ 'icon-top': ['subtitle','icon','iconLayout'],
1456
+ 'icon-inline': ['subtitle','icon','iconLayout'],
1457
+ 'image-top': ['image'],
1458
+ 'image-overlay': ['image'],
1459
+ 'thumb-left': ['image'],
1460
+ 'thumb-right': ['image'],
1461
+ 'wide-left-image': ['image'],
1462
+ 'full-bg': ['image'],
1463
+ 'split-half': ['image'],
1464
+ 'avatar-profile': ['subtitle','icon','tags'],
1465
+ 'stat-metric': ['value','delta','progress'],
1466
+ 'quote-testimonial': ['subtitle','icon'],
1467
+ 'callout': ['calloutType','icon'],
1468
+ 'video-media': ['image','duration'],
1469
+ 'location-map': ['address'],
1470
+ 'step-numbered': ['step'],
1471
+ 'corner-badge': ['icon','badge'],
1472
+ 'badge-band': ['icon','badge'],
1473
+ 'rating-review': ['rating','subtitle','icon'],
1474
+ 'tag-cloud': ['tags'],
1475
+ 'activity-feed': [],
1476
+ 'code-snippet': ['lang'],
1477
+ 'file-document': ['filename','filesize','filetype'],
1478
+ 'before-after': [],
1479
+ 'pricing': ['price','period'],
1480
+ 'feature-comparison':['subtitle'],
1481
+ 'progress-goal': ['progress'],
1482
+ 'timeline-entry': ['subtitle','badge'],
1483
+ 'glass-gradient-border':[],
1484
+ };
1485
+
1486
+ const NO_TITLE = new Set(['basic','no-header-footer','code-snippet','file-document','stat-metric']);
1487
+ const NO_BODY = new Set(['stat-metric','file-document']);
1488
+ const NO_FOOTER = new Set(['pricing','feature-comparison','activity-feed','before-after','progress-goal']);
1489
+
1490
+ function _refreshDynFields() {
1491
+ const container = document.getElementById('cb-dyn-fields');
1492
+ if (!container) return;
1493
+ while (container.firstChild) container.removeChild(container.firstChild);
1494
+
1495
+ const layout = _state.layout;
1496
+ const fields = LAYOUT_FIELDS[layout] || [];
1497
+
1498
+ if (!NO_TITLE.has(layout)) container.appendChild(_field('Title', _textInput('title', 'Card heading')));
1499
+
1500
+ fields.forEach(f => {
1501
+ const fieldMap = {
1502
+ subtitle: () => _field('Subtitle', _textInput('subtitle', 'Secondary line')),
1503
+ icon: () => _field('Icon', _iconRow()),
1504
+ iconLayout: () => _field('Icon layout', _iconLayoutToggle()),
1505
+ image: () => _field('Image', _imageRow()),
1506
+ value: () => _field('Value', _textInput('value', '\u00a324,980')),
1507
+ delta: () => _field('Delta', _textInput('delta', '\u2191 12.4%')),
1508
+ progress: () => _field('Progress %', _textInput('progress', '50')),
1509
+ price: () => _field('Price', _textInput('price', '\u00a329')),
1510
+ period: () => _field('Period', _textInput('period', 'per month')),
1511
+ lang: () => _field('Language', _textInput('lang', 'JavaScript')),
1512
+ filename: () => _field('Filename', _textInput('filename', 'document.pdf')),
1513
+ filesize: () => _field('File size', _textInput('filesize', '2.4 MB')),
1514
+ filetype: () => _field('File type', _textInput('filetype', 'PDF')),
1515
+ rating: () => _field('Rating (1-5)', _textInput('rating', '5')),
1516
+ duration: () => _field('Duration', _textInput('duration', '4:32')),
1517
+ address: () => _field('Address', _textInput('address', '12 Main St')),
1518
+ step: () => _field('Step number', _textInput('step', '1')),
1519
+ badge: () => _field('Badge label', _textInput('badge', 'New')),
1520
+ tags: () => _field('Tags (comma-separated)', _textInput('tags', 'Design, CSS')),
1521
+ calloutType: () => _field('Type', _selectInput('calloutType', ['info','warn','success','error'])),
1522
+ };
1523
+ if (fieldMap[f]) container.appendChild(fieldMap[f]());
1524
+ });
1525
+
1526
+ if (!NO_BODY.has(layout)) container.appendChild(_field('Body content', _bodyTextarea()));
1527
+ if (!NO_FOOTER.has(layout)) container.appendChild(_field('Footer', _textInput('footer', '')));
1528
+ }
1529
+
1530
+ function _bodyTextarea() {
1531
+ const ta = _el('textarea', IS + ';resize:vertical;min-height:60px;line-height:1.5');
1532
+ ta.value = _state.body || '';
1533
+ ta.addEventListener('input', () => { _state.body = ta.value; _updatePreview(); });
1534
+ return ta;
1535
+ }
1536
+
1537
+ function _iconRow() {
1538
+ const row = _el('div', 'display:flex;gap:6px');
1539
+ const input = _el('input', IS + ';flex:1');
1540
+ input.type = 'text'; input.placeholder = 'icon name';
1541
+ input.value = _state.icon || '';
1542
+ input.addEventListener('input', () => { _state.icon = input.value; _updatePreview(); });
1543
+ const btn = _el('button', 'padding:0 10px;height:34px;background:#334155;border:none;color:#94a3b8;border-radius:7px;font-size:13px;cursor:pointer;flex-shrink:0');
1544
+ btn.title = 'Browse icons';
1545
+ btn.textContent = '\u2605';
1546
+ btn.addEventListener('click', () => _openIconPicker(name => { _state.icon = name; input.value = name; _updatePreview(); }));
1547
+ row.appendChild(input);
1548
+ row.appendChild(btn);
1549
+ return row;
1550
+ }
1551
+
1552
+ function _iconLayoutToggle() {
1553
+ const row = _el('div', 'display:flex;gap:6px');
1554
+ ['inline','stacked'].forEach(v => {
1555
+ const btn = _el('div');
1556
+ const setS = (on) => {
1557
+ btn.style.cssText = `flex:1;padding:6px;border-radius:7px;border:1px solid;font-size:11px;font-weight:700;text-align:center;cursor:pointer;${on ? 'border-color:#6366f1;background:#1e1b4b;color:#a5b4fc' : 'border-color:#334155;background:#0f172a;color:#64748b'}`;
1558
+ };
1559
+ setS(_state.iconLayout === v);
1560
+ btn.textContent = v.charAt(0).toUpperCase() + v.slice(1);
1561
+ btn.addEventListener('click', () => {
1562
+ _state.iconLayout = v;
1563
+ row.querySelectorAll('div').forEach((b, i) => setS(['inline','stacked'][i] === v));
1564
+ _updatePreview();
1565
+ });
1566
+ row.appendChild(btn);
1567
+ });
1568
+ return row;
1569
+ }
1570
+
1571
+ function _imageRow() {
1572
+ const row = _el('div', 'display:flex;gap:6px');
1573
+ const input = _el('input', IS + ';flex:1');
1574
+ input.type = 'text'; input.placeholder = '/media/image.jpg';
1575
+ input.value = _state.image || '';
1576
+ input.addEventListener('input', () => { _state.image = input.value; _updatePreview(); });
1577
+ const btn = _el('button', 'padding:0 10px;height:34px;background:#334155;border:none;color:#94a3b8;border-radius:7px;font-size:13px;cursor:pointer;flex-shrink:0');
1578
+ btn.title = 'Browse media';
1579
+ btn.textContent = '\uD83D\uDCC1';
1580
+ btn.addEventListener('click', async () => {
1581
+ if (_opts && typeof _opts.onMediaBrowse === 'function') {
1582
+ const url = await _opts.onMediaBrowse();
1583
+ if (url) { _state.image = url; input.value = url; _updatePreview(); }
1584
+ }
1585
+ });
1586
+ row.appendChild(input);
1587
+ row.appendChild(btn);
1588
+ return row;
1589
+ }
1590
+ ```
1591
+
1592
+ - [ ] **Step 4: Commit**
1593
+
1594
+ ```bash
1595
+ git add admin/js/lib/card-builder.js
1596
+ git commit -m "feat(admin): add card builder dialog DOM + dynamic control panel"
1597
+ ```
1598
+
1599
+ ---
1600
+
1601
+ ## Task 12: card-builder.js - Preview panel + live preview renderer
1602
+
1603
+ **Files:**
1604
+
1605
+ - Modify: `admin/js/lib/card-builder.js`
1606
+
1607
+ - [ ] **Step 1: Add `_buildPreviewPanel()` - right panel structure**
1608
+
1609
+ ```js
1610
+ function _buildPreviewPanel() {
1611
+ const panel = _el('div', 'padding:20px;background:#f8fafc;display:flex;flex-direction:column;gap:14px;overflow-y:auto');
1612
+
1613
+ const lbl = _el('div', 'font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8');
1614
+ lbl.textContent = 'Live Preview';
1615
+
1616
+ const area = _el('div', 'flex:1;display:flex;align-items:flex-start;justify-content:center');
1617
+ area.id = 'cb-preview-area';
1618
+
1619
+ const sLbl = _el('div', 'font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:6px');
1620
+ sLbl.textContent = 'Shortcode';
1621
+
1622
+ const sBox = _el('div', 'background:#1e293b;border:1px solid #334155;border-radius:8px;padding:10px 14px;font-family:monospace;font-size:11px;color:#a5b4fc;line-height:1.8;white-space:pre-wrap;word-break:break-all');
1623
+ sBox.id = 'cb-snippet';
1624
+
1625
+ panel.appendChild(lbl);
1626
+ panel.appendChild(area);
1627
+ panel.appendChild(sLbl);
1628
+ panel.appendChild(sBox);
1629
+ return panel;
1630
+ }
1631
+ ```
1632
+
1633
+ - [ ] **Step 2: Add `_updatePreview()` and `_buildPreviewCard()`**
1634
+
1635
+ ```js
1636
+ function _updatePreview() {
1637
+ const area = document.getElementById('cb-preview-area');
1638
+ if (!area) return;
1639
+ while (area.firstChild) area.removeChild(area.firstChild);
1640
+ area.appendChild(_buildPreviewCard());
1641
+ const box = document.getElementById('cb-snippet');
1642
+ if (box) box.textContent = _buildShortcode();
1643
+ }
1644
+
1645
+ function _buildPreviewCard() {
1646
+ const s = _state;
1647
+ const card = _el('div');
1648
+ card.style.cssText = 'width:100%;max-width:300px;border-radius:12px;overflow:hidden;font-size:13px;font-family:system-ui,sans-serif';
1649
+ card.className = ['card','mb-4', s.variant !== 'clean' ? `dm-card-${s.variant}` : '', s.variant === 'gradient' ? `card-gradient-${s.gradient}` : ''].filter(Boolean).join(' ');
1650
+
1651
+ // Image area (for image-based layouts)
1652
+ const imgLayouts = new Set(['image-top','image-overlay','thumb-left','thumb-right','wide-left-image','full-bg','split-half','video-media']);
1653
+ if (imgLayouts.has(s.layout)) {
1654
+ const img = _el('div');
1655
+ img.className = 'card-img-top';
1656
+ if (s.image) img.style.backgroundImage = `url('${s.image}')`;
1657
+ img.style.backgroundSize = 'cover';
1658
+ img.style.backgroundPosition = 'center';
1659
+ if (!s.image) img.textContent = '\uD83D\uDDBC';
1660
+ card.appendChild(img);
1661
+ }
1662
+
1663
+ // Header
1664
+ const noHdr = new Set(['basic','no-header-footer','image-overlay','stat-metric','quote-testimonial','activity-feed','before-after','code-snippet','file-document','glass-gradient-border']);
1665
+ if (!noHdr.has(s.layout) && s.title) {
1666
+ const hdr = _el('div', '');
1667
+ hdr.className = s.variant === 'gradient' ? `card-header card-gradient-${s.gradient}` : 'card-header';
1668
+ const t = _el('div', 'font-weight:700');
1669
+ t.textContent = s.title;
1670
+ hdr.appendChild(t);
1671
+ card.appendChild(hdr);
1672
+ }
1673
+
1674
+ // Body
1675
+ const body = _el('div', '');
1676
+ body.className = 'card-body';
1677
+ const tier2 = new Set(['before-after','pricing','feature-comparison','activity-feed','progress-goal']);
1678
+ if (tier2.has(s.layout)) {
1679
+ const note = _el('em', 'font-size:11px;color:#9ca3af');
1680
+ note.textContent = 'Simplified preview - full render on save.';
1681
+ body.appendChild(note);
1682
+ } else if (s.layout === 'stat-metric') {
1683
+ const val = _el('div', 'font-size:2rem;font-weight:800;color:#111827');
1684
+ val.textContent = s.value || '-';
1685
+ const lbl2 = _el('div', 'font-size:.8rem;color:#6b7280');
1686
+ lbl2.textContent = s.title || '';
1687
+ body.appendChild(lbl2);
1688
+ body.appendChild(val);
1689
+ if (s.delta) { const d = _el('div', 'font-size:.78rem;font-weight:600;color:#10b981'); d.textContent = s.delta; body.appendChild(d); }
1690
+ } else {
1691
+ body.textContent = s.body || 'Card body content.';
1692
+ }
1693
+ card.appendChild(body);
1694
+
1695
+ // Footer
1696
+ if (s.footer) {
1697
+ const ftr = _el('div', '');
1698
+ ftr.className = 'card-footer';
1699
+ ftr.textContent = s.footer;
1700
+ card.appendChild(ftr);
1701
+ }
1702
+
1703
+ return card;
1704
+ }
1705
+ ```
1706
+
1707
+ - [ ] **Step 3: Commit**
1708
+
1709
+ ```bash
1710
+ git add admin/js/lib/card-builder.js
1711
+ git commit -m "feat(admin): add card builder live preview renderer"
1712
+ ```
1713
+
1714
+ ---
1715
+
1716
+ ## Task 13: card-builder.js - Icon picker overlay
1717
+
1718
+ **Files:**
1719
+
1720
+ - Modify: `admin/js/lib/card-builder.js`
1721
+
1722
+ - [ ] **Step 1: Get the icon list from markdown-toolbar.js**
1723
+
1724
+ ```bash
1725
+ grep -A 5 "const allIcons\|ALL_ICONS\|iconNames" /home/darryl/src/js/domma-cms/admin/js/lib/markdown-toolbar.js | head -20
1726
+ ```
1727
+
1728
+ Find the array name and copy its contents. Store as `const ALL_ICONS = [...]` at the top of card-builder.js.
1729
+
1730
+ - [ ] **Step 2: Add `_openIconPicker(onSelect)` - overlay with search + grid**
1731
+
1732
+ ```js
1733
+ function _openIconPicker(onSelect) {
1734
+ const overlay = _el('div', 'position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:10000;padding:24px');
1735
+
1736
+ const panel = _el('div', 'background:#1e293b;border:1px solid #475569;border-radius:12px;padding:16px;width:100%;max-width:520px;max-height:70vh;display:flex;flex-direction:column;gap:12px');
1737
+
1738
+ const searchRow = _el('div', 'display:flex;gap:8px');
1739
+ const searchInput = _el('input', 'flex:1;padding:8px 12px;border-radius:8px;border:1px solid #334155;background:#0f172a;color:#e2e8f0;font-size:13px;font-family:inherit');
1740
+ searchInput.type = 'text'; searchInput.placeholder = 'Search icons\u2026';
1741
+ const closeBtn2 = _el('button', 'padding:8px 14px;background:#334155;border:none;border-radius:8px;color:#94a3b8;font-size:12px;cursor:pointer');
1742
+ closeBtn2.textContent = '\u2715 Close';
1743
+ closeBtn2.addEventListener('click', () => overlay.remove());
1744
+ searchRow.appendChild(searchInput);
1745
+ searchRow.appendChild(closeBtn2);
1746
+
1747
+ const grid = _el('div', 'display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:6px;overflow-y:auto;max-height:400px');
1748
+ let selectedItem = null;
1749
+
1750
+ const confirmBtn = _el('button', 'padding:8px 18px;background:#6366f1;border:none;border-radius:8px;color:#fff;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-end');
1751
+ confirmBtn.textContent = 'Select an icon';
1752
+
1753
+ function renderGrid(filter) {
1754
+ while (grid.firstChild) grid.removeChild(grid.firstChild);
1755
+ const icons = filter
1756
+ ? ALL_ICONS.filter(n => n.includes(filter.toLowerCase()))
1757
+ : ALL_ICONS;
1758
+ icons.slice(0, 120).forEach(name => {
1759
+ const item = _el('div', 'display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px;border-radius:8px;cursor:pointer;border:2px solid transparent;background:#0f172a');
1760
+ const glyph = document.createElement('span');
1761
+ glyph.setAttribute('data-icon', name);
1762
+ glyph.style.fontSize = '20px';
1763
+ const label = _el('div', 'font-size:8px;color:#475569;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%');
1764
+ label.textContent = name;
1765
+ item.appendChild(glyph);
1766
+ item.appendChild(label);
1767
+ item.addEventListener('click', () => {
1768
+ if (selectedItem) selectedItem.style.borderColor = 'transparent';
1769
+ item.style.borderColor = '#6366f1';
1770
+ selectedItem = item;
1771
+ confirmBtn.textContent = `Use: ${name}`;
1772
+ });
1773
+ item.addEventListener('dblclick', () => { onSelect(name); overlay.remove(); });
1774
+ grid.appendChild(item);
1775
+ });
1776
+ if (typeof I !== 'undefined' && I.scan) I.scan(grid);
1777
+ }
1778
+
1779
+ searchInput.addEventListener('input', () => renderGrid(searchInput.value));
1780
+ renderGrid('');
1781
+
1782
+ confirmBtn.addEventListener('click', () => {
1783
+ if (selectedItem) {
1784
+ const name = selectedItem.querySelector('[data-icon]').getAttribute('data-icon');
1785
+ onSelect(name);
1786
+ overlay.remove();
1787
+ }
1788
+ });
1789
+
1790
+ panel.appendChild(searchRow);
1791
+ panel.appendChild(grid);
1792
+ panel.appendChild(confirmBtn);
1793
+ overlay.appendChild(panel);
1794
+ overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
1795
+ document.body.appendChild(overlay);
1796
+ searchInput.focus();
1797
+ }
1798
+ ```
1799
+
1800
+ - [ ] **Step 3: Commit**
1801
+
1802
+ ```bash
1803
+ git add admin/js/lib/card-builder.js
1804
+ git commit -m "feat(admin): add icon picker overlay to card builder"
1805
+ ```
1806
+
1807
+ ---
1808
+
1809
+ ## Task 14: page-editor.js - Wire toolbar.onCard()
1810
+
1811
+ **Files:**
1812
+
1813
+ - Modify: `admin/js/views/page-editor.js`
1814
+
1815
+ - [ ] **Step 1: Find toolbar initialisation and existing onXxx handlers**
1816
+
1817
+ ```bash
1818
+ grep -n 'toolbar\.\|onCard\|onGrid\|onImage\|MarkdownToolbar\|markdown-toolbar' /home/darryl/src/js/domma-cms/admin/js/views/page-editor.js | head -30
1819
+ ```
1820
+
1821
+ Identify:
1822
+
1823
+ 1. Where the toolbar instance is created
1824
+ 2. Where other `toolbar.onXxx()` handlers are registered (e.g. `toolbar.onImage`, `toolbar.onGrid`)
1825
+ 3. How the existing media picker is called (pattern for `onMediaBrowse` callback)
1826
+
1827
+ - [ ] **Step 2: Add import at top of page-editor.js**
1828
+
1829
+ ```js
1830
+ import { CardBuilder } from '../lib/card-builder.js';
1831
+ ```
1832
+
1833
+ - [ ] **Step 3: Register onCard handler alongside other toolbar handlers**
1834
+
1835
+ ```js
1836
+ toolbar.onCard((ta) => {
1837
+ CardBuilder.open(ta, {
1838
+ onMediaBrowse: () => new Promise((resolve) => {
1839
+ // Mirror the existing media picker invocation pattern.
1840
+ // Check how toolbar.onImage or toolbar.onLink calls the media modal
1841
+ // and adapt the same call here, resolving with the selected URL.
1842
+ openMediaPicker(resolve); // replace with the actual function name used in page-editor
1843
+ }),
1844
+ });
1845
+ });
1846
+ ```
1847
+
1848
+ Look at the existing `toolbar.onImage` or `toolbar.onLink` handler - copy its media modal invocation pattern exactly.
1849
+
1850
+ - [ ] **Step 4: Verify in browser**
1851
+
1852
+ 1. Open `http://localhost:4096/admin/#/pages`
1853
+ 2. Open any page in the editor
1854
+ 3. Click **Card** in the toolbar
1855
+ 4. Confirm dialog opens with 33 layouts in the dropdown
1856
+ 5. Change layout - fields update, preview updates
1857
+ 6. Change variant - preview updates
1858
+ 7. Click Insert - shortcode inserted at cursor
1859
+ 8. Preview page - card renders correctly
1860
+
1861
+ - [ ] **Step 5: Commit**
1862
+
1863
+ ```bash
1864
+ git add admin/js/views/page-editor.js
1865
+ git commit -m "feat(admin): wire card builder into page editor toolbar"
1866
+ ```
1867
+
1868
+ ---
1869
+
1870
+ ## Task 15: End-to-end verification
1871
+
1872
+ **Files:**
1873
+
1874
+ - Temporary: `content/pages/card-e2e-test.md`
1875
+
1876
+ - [ ] **Step 1: Create comprehensive test page**
1877
+
1878
+ ```markdown
1879
+ ---
1880
+ title: Card E2E Test
1881
+ layout: default
1882
+ ---
1883
+
1884
+ [card variant="clean" title="Clean" hover]Plain body text.[/card]
1885
+
1886
+ [card layout="image-top" title="Image Top" variant="gradient" gradient="aurora" footer="5 min read" hover]Short description.[/card]
1887
+
1888
+ [card layout="callout" callout-type="info" icon="bulb" title="Tip"]Body here.[/card]
1889
+
1890
+ [card layout="stat-metric" title="Revenue" value="£24,980" delta="↑ 12.4%" progress="68"][/card]
1891
+
1892
+ [card layout="pricing" title="Pro" price="£29" period="per month" variant="gradient" gradient="indigo"]
1893
+ [feature]Unlimited pages[/feature]
1894
+ [feature]All plugins[/feature]
1895
+ [/card]
1896
+
1897
+ [card layout="before-after" title="Why switch?"]
1898
+ [before]Manual updates · Dev dependency[/before]
1899
+ [after]Auto deploy · Editor-owned[/after]
1900
+ [/card]
1901
+
1902
+ [card layout="glass-gradient-border" title="Glass card"]Frosted glass border.[/card]
1903
+
1904
+ [card layout="timeline-entry" title="Plugin system" subtitle="Apr 2026" badge="Feature"]Plugins can now inject routes.[/card]
1905
+
1906
+ [card layout="code-snippet" lang="JavaScript"]const x = 1 + 2;[/card]
1907
+ ```
1908
+
1909
+ - [ ] **Step 2: Verify all layouts render**
1910
+
1911
+ ```bash
1912
+ curl -s http://localhost:4096/card-e2e-test > /tmp/card-test.html
1913
+ grep -c 'dm-card\|card-gradient\|card-callout\|card-stat\|card-pricing\|card-compare\|card-glass\|card-timeline\|card-code' /tmp/card-test.html
1914
+ ```
1915
+
1916
+ Expected: `9` or more
1917
+
1918
+ - [ ] **Step 3: Visual verification in browser** - `http://localhost:4096/card-e2e-test`
1919
+
1920
+ Confirm:
1921
+
1922
+ - Clean card lifts on hover
1923
+ - Gradient card (aurora) shows coloured header
1924
+ - Callout has left accent stripe
1925
+ - Stat shows large value + progress bar
1926
+ - Pricing shows feature list
1927
+ - Before/After shows two columns
1928
+ - Glass has frosted appearance
1929
+ - Timeline shows dot + line + tag
1930
+ - Code shows dark block with language badge
1931
+
1932
+ - [ ] **Step 4: Card builder dialog verification** - `http://localhost:4096/admin/#/pages`
1933
+
1934
+ - Click Card in toolbar → dialog opens
1935
+ - Select each layout group → fields update correctly
1936
+ - Select Gradient variant → gradient circle picker appears
1937
+ - Type title → preview card updates
1938
+ - Click Browse icons → icon picker opens, search works, double-click selects
1939
+ - Click Insert → shortcode inserted, dialog closes
1940
+ - Preview the page - card renders correctly
1941
+
1942
+ - [ ] **Step 5: Clean up and final commit**
1943
+
1944
+ ```bash
1945
+ rm content/pages/card-e2e-test.md
1946
+ git status
1947
+ # commit any remaining uncommitted changes
1948
+ ```
1949
+
1950
+ ---
1951
+
1952
+ ## Summary
1953
+
1954
+ | Task | Produces | Verified by |
1955
+ |------|--------------------------------------|---------------------------------|
1956
+ | 1 | 6 variant CSS classes | `curl grep dm-card-` |
1957
+ | 2 | 14 gradient CSS classes | Visual |
1958
+ | 3 | All layout structural CSS | Visual with layouts |
1959
+ | 4 | Collapsible click handler | Browser click test |
1960
+ | 5 | `processCardBlocks` routing | Server restart + existing cards |
1961
+ | 6 | Basic + icon renderers | `curl grep card-header-icon` |
1962
+ | 7 | Image layout renderers | `curl grep card-img` |
1963
+ | 8 | People/data/special renderers | `curl grep card-callout` |
1964
+ | 9 | Tier 2 sub-tag renderers | `curl grep card-pricing` |
1965
+ | 10 | CardBuilder scaffold + shortcode gen | `node --input-type=module` |
1966
+ | 11 | Control panel + dynamic fields | Browser dialog |
1967
+ | 12 | Live preview renderer | Browser preview |
1968
+ | 13 | Icon picker | Browser icon browse |
1969
+ | 14 | page-editor wiring | Full browser flow |
1970
+ | 15 | End-to-end | All 33 layouts render + dialog |