domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Blog - the browser half.
3
+ *
4
+ * The server renders every listing and post in full, so all of this is an
5
+ * enhancement: without it the blog still reads, pages and links.
6
+ *
7
+ * On a listing, the cards become a model (each carries `data-post`: plain
8
+ * values, never markup) and the filters become domma-reactive observables -
9
+ * search text, category, tag, author, sort, display, how many are shown. What
10
+ * is visible is a computed of those; one effect applies it to the cards. The
11
+ * toolbar, the category pills and the active-filter chips are bindings over the
12
+ * same observables, and so is the right-click menu: "More in Design" just sets
13
+ * the category.
14
+ *
15
+ * On a post: a reading-progress bar, a contents list that follows the reader,
16
+ * copy / native share, and a comment form that posts in place.
17
+ *
18
+ * domma-reactive (`M`) ships in domma.min.js on every public page.
19
+ */
20
+
21
+ const M = window.M;
22
+ const PREF_KEY = 'blog.display';
23
+
24
+ const store = {
25
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
26
+ set(k, v) { try { localStorage.setItem(k, v); } catch { /* storage blocked */ } }
27
+ };
28
+
29
+ function decodeMeta(el) {
30
+ try {
31
+ const bytes = Uint8Array.from(atob(el.dataset.post || ''), c => c.charCodeAt(0));
32
+ return JSON.parse(new TextDecoder().decode(bytes));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function toast(text) {
39
+ const t = document.createElement('div');
40
+ t.className = 'blog-toast';
41
+ t.setAttribute('role', 'status');
42
+ t.textContent = text;
43
+ document.body.appendChild(t);
44
+ setTimeout(() => t.remove(), 1800);
45
+ }
46
+
47
+ async function copyText(text) {
48
+ try {
49
+ await navigator.clipboard.writeText(text);
50
+ toast('Link copied');
51
+ } catch {
52
+ window.prompt('Copy this link:', text);
53
+ }
54
+ }
55
+
56
+ function el(html) {
57
+ const t = document.createElement('template');
58
+ t.innerHTML = html.trim();
59
+ return t.content.firstElementChild;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // The right-click menu - one for the whole page, built per click
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const menu = (() => {
67
+ let node = null;
68
+ let lastFocus = null;
69
+
70
+ function close() {
71
+ if (!node) return;
72
+ node.remove();
73
+ node = null;
74
+ document.removeEventListener('pointerdown', onOutside, true);
75
+ document.removeEventListener('keydown', onKey, true);
76
+ window.removeEventListener('scroll', close, true);
77
+ window.removeEventListener('resize', close);
78
+ lastFocus?.focus?.({preventScroll: true});
79
+ }
80
+
81
+ function onOutside(e) { if (node && !node.contains(e.target)) close(); }
82
+
83
+ function onKey(e) {
84
+ if (!node) return;
85
+ const items = [...node.querySelectorAll('button')];
86
+ const at = items.indexOf(document.activeElement);
87
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
88
+ else if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus(); }
89
+ else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }
90
+ else if (e.key === 'Tab') close();
91
+ }
92
+
93
+ /**
94
+ * @param {number} x
95
+ * @param {number} y
96
+ * @param {string} heading
97
+ * @param {({label: string, hint?: string, run: Function}|'-')[]} entries
98
+ */
99
+ function open(x, y, heading, entries) {
100
+ close();
101
+ lastFocus = document.activeElement;
102
+ node = document.createElement('div');
103
+ node.className = 'blog-menu';
104
+ node.setAttribute('role', 'menu');
105
+ const head = document.createElement('div');
106
+ head.className = 'blog-menu-head';
107
+ head.textContent = heading;
108
+ node.appendChild(head);
109
+ const list = entries.filter((e, i, all) => e !== '-' || (i > 0 && i < all.length - 1 && all[i - 1] !== '-'));
110
+ for (const entry of list) {
111
+ if (entry === '-') { node.appendChild(document.createElement('hr')); continue; }
112
+ const b = document.createElement('button');
113
+ b.type = 'button';
114
+ b.setAttribute('role', 'menuitem');
115
+ b.textContent = entry.label;
116
+ if (entry.hint) {
117
+ const k = document.createElement('span');
118
+ k.className = 'k';
119
+ k.textContent = entry.hint;
120
+ b.appendChild(k);
121
+ }
122
+ b.addEventListener('click', () => { close(); entry.run(); });
123
+ node.appendChild(b);
124
+ }
125
+ document.body.appendChild(node);
126
+ const r = node.getBoundingClientRect();
127
+ node.style.left = `${Math.max(8, Math.min(x, innerWidth - r.width - 8))}px`;
128
+ node.style.top = `${Math.max(8, Math.min(y, innerHeight - r.height - 8))}px`;
129
+ node.querySelector('button')?.focus({preventScroll: true});
130
+ setTimeout(() => {
131
+ document.addEventListener('pointerdown', onOutside, true);
132
+ document.addEventListener('keydown', onKey, true);
133
+ window.addEventListener('scroll', close, true);
134
+ window.addEventListener('resize', close);
135
+ });
136
+ }
137
+
138
+ return {open, close};
139
+ })();
140
+
141
+ /**
142
+ * Menu entries for one card. `filters` is the live listing when there is one
143
+ * on the page; without it, each "More…" goes to that archive page instead.
144
+ */
145
+ function cardEntries(meta, link, base, filters) {
146
+ const go = (href) => { location.href = href; };
147
+ const narrow = (key, value, href) => filters ? () => filters.set(key, value) : () => go(href);
148
+ const entries = [
149
+ {label: 'Open post', run: () => go(link)},
150
+ {label: 'Open in new tab', run: () => window.open(link, '_blank', 'noopener')},
151
+ {label: 'Copy link', run: () => copyText(new URL(link, location.href).href)},
152
+ '-',
153
+ ...meta.cats.map(c => ({label: `More in ${c.name}`, run: narrow('cat', c.slug, `${base}/category/${encodeURIComponent(c.slug)}`)})),
154
+ ...meta.tags.slice(0, 4).map(t => ({label: `More tagged #${t}`, run: narrow('tag', t, `${base}/tag/${encodeURIComponent(t)}`)})),
155
+ ...(meta.author?.id ? [{label: `More by ${meta.author.name || 'this author'}`, run: narrow('author', meta.author.id, `${base}/author/${encodeURIComponent(meta.author.id)}`)}] : [])
156
+ ];
157
+ if (filters?.any()) entries.push('-', {label: 'Clear filters', run: () => filters.clear()});
158
+ return entries;
159
+ }
160
+
161
+ function wireCardMenu(scope, base, filters) {
162
+ scope.addEventListener('contextmenu', (e) => {
163
+ const card = e.target.closest('.blog-item[data-post]');
164
+ if (!card || !scope.contains(card) || e.shiftKey) return; // Shift+right-click: the browser's own menu
165
+ const meta = decodeMeta(card);
166
+ const link = card.querySelector('a[href]')?.getAttribute('href');
167
+ if (!meta || !link) return;
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ let {clientX: x, clientY: y} = e;
171
+ if (!x && !y) { // opened from the keyboard
172
+ const r = (document.activeElement?.closest('.blog-item') === card ? document.activeElement : card).getBoundingClientRect();
173
+ x = r.left + 12;
174
+ y = r.top + 12;
175
+ }
176
+ menu.open(x, y, meta.title, cardEntries(meta, link, base, filters));
177
+ });
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // A listing
182
+ // ---------------------------------------------------------------------------
183
+
184
+ const TOOLBAR = `
185
+ <label class="blog-search">
186
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
187
+ <input type="search" class="blog-search-input" placeholder="Search posts…" aria-label="Search posts" data-model="q.value">
188
+ </label>
189
+ <select class="blog-sort" aria-label="Sort" data-model="sort.value">
190
+ <option value="newest">Newest first</option>
191
+ <option value="oldest">Oldest first</option>
192
+ <option value="title">Title A–Z</option>
193
+ <option value="quick">Quickest read</option>
194
+ </select>`;
195
+
196
+ const PILL = `<button type="button" class="blog-pill {{cls}}" data-pill="{{slug}}" title="{{title}}">{{name}} <span class="n">{{count}}</span></button>`;
197
+ const CHIP = `<span class="blog-filter-chip">{{label}} <button type="button" data-unset="{{key}}" aria-label="Remove {{label}}">×</button></span>`;
198
+
199
+ function initArchive() {
200
+ const root = document.querySelector('[data-blog-archive]');
201
+ if (!root) return;
202
+ let cfg;
203
+ try { cfg = JSON.parse(root.querySelector('[data-blog-config]')?.textContent || '{}'); } catch { cfg = {}; }
204
+ const base = cfg.base || '/blog';
205
+
206
+ if (!M || !cfg.interactive) {
207
+ // Paged on the server: the menu still works, and "More…" opens the archive.
208
+ wireCardMenu(root, base, null);
209
+ return;
210
+ }
211
+ root.classList.add('is-live');
212
+
213
+ const bar = root.querySelector('.blog-archive-bar');
214
+ const readCards = (container) => [...container.querySelectorAll(':scope > .blog-item[data-post]')]
215
+ .map(node => ({node, meta: decodeMeta(node)}))
216
+ .filter(c => c.meta);
217
+
218
+ const params = new URLSearchParams(location.search);
219
+ const perPage = Math.max(1, Number(cfg.perPage) || 10);
220
+ const state = {
221
+ q: M.observable(params.get('q') ?? ''),
222
+ cat: M.observable(params.get('category') ?? ''),
223
+ tag: M.observable(params.get('tag') ?? ''),
224
+ author: M.observable(params.get('author') ?? ''),
225
+ sort: M.observable(['newest', 'oldest', 'title', 'quick'].includes(params.get('sort')) ? params.get('sort') : 'newest'),
226
+ limit: M.observable(perPage),
227
+ display: M.observable(cfg.display),
228
+ items: M.observable(root.querySelector('.blog-items')),
229
+ cards: M.observable(readCards(root.querySelector('.blog-items')))
230
+ };
231
+
232
+ // The same filters, as an object the right-click menu can drive.
233
+ const filters = {
234
+ set(key, value) {
235
+ state[key].value = value;
236
+ root.scrollIntoView({behavior: 'smooth', block: 'start'});
237
+ },
238
+ any: () => Boolean(state.q.value || state.cat.value || state.tag.value || state.author.value),
239
+ clear() { state.q.value = ''; state.cat.value = ''; state.tag.value = ''; state.author.value = ''; }
240
+ };
241
+
242
+ const words = (s) => String(s).toLowerCase();
243
+ const matches = (m, {skipCat = false} = {}) => {
244
+ const q = words(state.q.value).trim();
245
+ if (q && !words([m.title, m.excerpt, m.author?.name, ...m.tags, ...m.cats.map(c => c.name)].join(' ')).includes(q)) return false;
246
+ if (!skipCat && state.cat.value && !m.cats.some(c => c.slug === state.cat.value)) return false;
247
+ if (state.tag.value && !m.tags.includes(state.tag.value)) return false;
248
+ if (state.author.value && m.author?.id !== state.author.value) return false;
249
+ return true;
250
+ };
251
+ const SORTS = {
252
+ newest: (a, b) => (b.meta.date || '').localeCompare(a.meta.date || ''),
253
+ oldest: (a, b) => (a.meta.date || '').localeCompare(b.meta.date || ''),
254
+ title: (a, b) => a.meta.title.localeCompare(b.meta.title),
255
+ quick: (a, b) => (a.meta.minutes - b.meta.minutes) || (b.meta.date || '').localeCompare(a.meta.date || '')
256
+ };
257
+
258
+ const filtered = M.computed(() => state.cards.value.filter(c => matches(c.meta)).sort(SORTS[state.sort.value] ?? SORTS.newest));
259
+ const displayInfo = M.computed(() => (cfg.displays || []).find(d => d.id === state.display.value) ?? {lead: false});
260
+
261
+ // Lists for data-each: observables fed by effects (a data-each over a
262
+ // computed renders once and never again).
263
+ const pills = M.observable([]);
264
+ const chips = M.observable([]);
265
+
266
+ const catName = (slug) => (cfg.categories || []).find(c => c.slug === slug)?.name ?? slug;
267
+ const authorName = (id) => state.cards.value.find(c => c.meta.author?.id === id)?.meta.author.name || 'this author';
268
+
269
+ const data = {
270
+ ...state, pills, chips,
271
+ countText: M.computed(() => {
272
+ const all = state.cards.value.length, n = filtered.value.length;
273
+ return n === all ? `${all} post${all === 1 ? '' : 's'}` : `${n} of ${all} posts`;
274
+ }),
275
+ hasMore: M.computed(() => filtered.value.length > state.limit.value),
276
+ moreText: M.computed(() => `Showing ${Math.min(state.limit.value, filtered.value.length)} of ${filtered.value.length}`),
277
+ hasChips: M.computed(() => chips.value.length > 0)
278
+ };
279
+
280
+ // ── Build the toolbar, pills, chips and "Show more"
281
+ const toolbar = document.createElement('div');
282
+ toolbar.className = 'blog-toolbar';
283
+ toolbar.style.display = 'contents';
284
+ toolbar.innerHTML = TOOLBAR;
285
+ bar.prepend(toolbar);
286
+
287
+ const count = el('<span class="blog-more-count" data-bind-text="countText.value" aria-live="polite"></span>');
288
+ bar.insertBefore(count, bar.querySelector('.blog-displays'));
289
+
290
+ const pillRow = el('<div class="blog-pills" role="group" aria-label="Categories" data-each="pills.value key=k"></div>');
291
+ pillRow.innerHTML = PILL;
292
+ bar.after(pillRow);
293
+
294
+ const active = el('<div class="blog-active" data-if="hasChips.value"><span>Showing</span><span class="blog-active-list" data-each="chips.value key=key"></span><button type="button" class="blog-clear" data-on-click="clearAll">Clear all</button></div>');
295
+ active.querySelector('.blog-active-list').innerHTML = CHIP;
296
+ pillRow.after(active);
297
+
298
+ const more = el('<div class="blog-more" data-if="hasMore.value"><span class="blog-more-count" data-bind-text="moreText.value"></span><button type="button" class="btn btn-secondary" data-on-click="showMore">Show more</button></div>');
299
+ root.querySelector('.blog-empty').after(more);
300
+
301
+ const handle = M.applyBindings(data, root, {
302
+ methods: {
303
+ showMore: () => { state.limit.value += perPage; },
304
+ clearAll: () => filters.clear()
305
+ }
306
+ });
307
+
308
+ // Pills: "All" plus every category with a post that the OTHER filters let through.
309
+ M.effect(() => {
310
+ const counts = new Map();
311
+ let total = 0;
312
+ for (const c of state.cards.value) {
313
+ if (!matches(c.meta, {skipCat: true})) continue;
314
+ total++;
315
+ for (const cat of c.meta.cats) counts.set(cat.slug, (counts.get(cat.slug) ?? 0) + 1);
316
+ }
317
+ const current = state.cat.value;
318
+ const list = [{k: '*', slug: '', name: 'All', count: total, cls: current ? '' : 'is-active', title: 'Every category'}];
319
+ for (const cat of cfg.categories || []) {
320
+ const n = counts.get(cat.slug) ?? 0;
321
+ if (!n && cat.slug !== current) continue;
322
+ list.push({k: cat.slug, slug: cat.slug, name: cat.name, count: n, cls: cat.slug === current ? 'is-active' : '', title: `Only ${cat.name}`});
323
+ }
324
+ pills.value = list.length > 2 || current ? list : [];
325
+ });
326
+
327
+ M.effect(() => {
328
+ const list = [];
329
+ if (state.q.value.trim()) list.push({key: 'q', label: `“${state.q.value.trim()}”`});
330
+ if (state.cat.value) list.push({key: 'cat', label: catName(state.cat.value)});
331
+ if (state.tag.value) list.push({key: 'tag', label: `#${state.tag.value}`});
332
+ if (state.author.value) list.push({key: 'author', label: `by ${authorName(state.author.value)}`});
333
+ chips.value = list;
334
+ });
335
+
336
+ // A new filter starts from the first page again. Writes `limit` without
337
+ // reading it, so "Show more" is not undone the moment it is pressed.
338
+ let lastFilter = null;
339
+ M.effect(() => {
340
+ const key = JSON.stringify([state.q.value, state.cat.value, state.tag.value, state.author.value, state.sort.value]);
341
+ if (lastFilter !== null && key !== lastFilter) state.limit.value = perPage;
342
+ lastFilter = key;
343
+ });
344
+
345
+ // Apply: order, visibility, the Magazine lead, the empty note.
346
+ M.effect(() => {
347
+ const container = state.items.value;
348
+ const shown = filtered.value.slice(0, state.limit.value);
349
+ const visible = new Set(shown.map(c => c.node));
350
+ for (const c of filtered.value) container.appendChild(c.node); // sorted order
351
+ for (const c of state.cards.value) {
352
+ c.node.hidden = !visible.has(c.node);
353
+ c.node.classList.remove('is-lead');
354
+ }
355
+ if (displayInfo.value.lead && shown[0] && state.sort.value === 'newest' && !filters.any()) shown[0].node.classList.add('is-lead');
356
+ root.querySelector('.blog-empty').hidden = shown.length > 0;
357
+ root.querySelector('.blog-empty').textContent = state.cards.value.length ? 'No posts match - try clearing a filter.' : 'No posts here yet.';
358
+ });
359
+
360
+ // The address follows the filters, so a filtered view is a link.
361
+ M.effect(() => {
362
+ const p = new URLSearchParams(location.search);
363
+ const put = (k, v, dflt = '') => (v && v !== dflt ? p.set(k, v) : p.delete(k));
364
+ put('q', state.q.value.trim());
365
+ put('category', state.cat.value);
366
+ put('tag', state.tag.value);
367
+ put('author', state.author.value);
368
+ put('sort', state.sort.value, 'newest');
369
+ put('display', state.display.value, cfg.display);
370
+ const qs = p.toString();
371
+ history.replaceState(history.state, '', `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`);
372
+ });
373
+
374
+ // ── Clicks: pills, chip ×, category / tag chips inside cards, display switcher
375
+ root.addEventListener('click', (e) => {
376
+ const pill = e.target.closest('[data-pill]');
377
+ if (pill) { state.cat.value = pill.dataset.pill; return; }
378
+ const unset = e.target.closest('[data-unset]');
379
+ if (unset) { state[unset.dataset.unset].value = ''; return; }
380
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
381
+ const cat = e.target.closest('.blog-item [data-blog-cat]');
382
+ if (cat) { e.preventDefault(); filters.set('cat', cat.dataset.blogCat); return; }
383
+ const tag = e.target.closest('.blog-item [data-blog-tag]');
384
+ if (tag) { e.preventDefault(); filters.set('tag', tag.dataset.blogTag); return; }
385
+ const disp = e.target.closest('[data-blog-display]');
386
+ if (disp) { e.preventDefault(); swapDisplay(disp.dataset.blogDisplay, true); }
387
+ });
388
+
389
+ /**
390
+ * Switch display without leaving the page: fetch the same listing in the
391
+ * other display and take its cards (each display is a different BLOCK, so
392
+ * the markup genuinely differs), keeping every filter as it is.
393
+ */
394
+ async function swapDisplay(id, remember) {
395
+ const info = (cfg.displays || []).find(d => d.id === id);
396
+ if (!info || id === state.display.value) return;
397
+ const current = state.items.value;
398
+ current.classList.add('is-swapping');
399
+ try {
400
+ const url = new URL(location.href);
401
+ url.search = `?display=${encodeURIComponent(id)}`;
402
+ const res = await fetch(url, {headers: {Accept: 'text/html'}});
403
+ if (!res.ok) throw new Error(String(res.status));
404
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
405
+ const fresh = doc.querySelector('[data-blog-archive] .blog-items');
406
+ if (!fresh) throw new Error('no listing');
407
+ // The block's CSS rides along as a <style> just before the listing.
408
+ for (const style of doc.querySelectorAll('style')) {
409
+ const m = /\[data-block="([^"]+)"\]/.exec(style.textContent);
410
+ if (m && !document.querySelector(`style[data-blog-style="${m[1]}"]`) && !document.querySelector(`[data-block="${m[1]}"]`)) {
411
+ style.dataset.blogStyle = m[1];
412
+ document.head.appendChild(document.importNode(style, true));
413
+ }
414
+ }
415
+ const node = document.importNode(fresh, true);
416
+ current.replaceWith(node);
417
+ state.items.value = node;
418
+ state.cards.value = readCards(node);
419
+ state.display.value = id;
420
+ root.querySelectorAll('[data-blog-display]').forEach(a => {
421
+ if (a.dataset.blogDisplay === id) a.setAttribute('aria-current', 'true');
422
+ else a.removeAttribute('aria-current');
423
+ });
424
+ if (remember) store.set(PREF_KEY, id);
425
+ } catch {
426
+ location.href = `?display=${encodeURIComponent(id)}`;
427
+ } finally {
428
+ current.classList.remove('is-swapping');
429
+ }
430
+ }
431
+
432
+ wireCardMenu(root, base, filters);
433
+
434
+ // A reader who chose a display before gets it again - unless this address names one.
435
+ const pref = store.get(PREF_KEY);
436
+ if (pref && !params.get('display') && pref !== cfg.display && root.querySelector(`[data-blog-display="${CSS.escape(pref)}"]`)) {
437
+ swapDisplay(pref, false);
438
+ }
439
+
440
+ window.addEventListener('pagehide', () => handle?.dispose?.(), {once: true});
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // A post
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * CSS `position: sticky`, done by hand. Site themes put `overflow: hidden` on
449
+ * the page wrapper, which makes that wrapper the sticky element's scroll
450
+ * container - one that never scrolls - so a sticky contents list just scrolls
451
+ * away. Only applied where a layout asked for sticky; the element is nudged
452
+ * down within its parent and never past the parent's end.
453
+ */
454
+ function keepInView(node) {
455
+ const cs = getComputedStyle(node);
456
+ if (cs.position !== 'sticky') return;
457
+ const offset = parseFloat(cs.top) || 80;
458
+ node.style.position = 'relative';
459
+ node.style.top = '0';
460
+ const box = node.parentElement;
461
+ let queued = false;
462
+ const place = () => {
463
+ queued = false;
464
+ node.style.transform = '';
465
+ if (matchMedia('(max-width: 960px)').matches) return;
466
+ const r = node.getBoundingClientRect();
467
+ const b = box.getBoundingClientRect();
468
+ const shift = Math.max(0, Math.min(offset - r.top, b.bottom - r.bottom));
469
+ if (shift) node.style.transform = `translateY(${shift}px)`;
470
+ };
471
+ const queue = () => { if (!queued) { queued = true; requestAnimationFrame(place); } };
472
+ addEventListener('scroll', queue, {passive: true, capture: true});
473
+ addEventListener('resize', queue);
474
+ place();
475
+ }
476
+
477
+ function initPost() {
478
+ const page = document.querySelector('[data-blog-post]');
479
+ if (!page) return;
480
+ const base = (location.pathname.replace(/\/[^/]*$/, '') || '/blog');
481
+
482
+ wireCardMenu(page, base, null); // the related cards
483
+
484
+ // Share: copy needs the clipboard, Share… needs the platform's sheet.
485
+ page.querySelectorAll('[data-blog-share]').forEach(box => {
486
+ const copy = box.querySelector('[data-blog-copy]');
487
+ const native = box.querySelector('[data-blog-native]');
488
+ if (copy && navigator.clipboard) {
489
+ copy.hidden = false;
490
+ copy.addEventListener('click', () => copyText(location.href.split('#')[0]));
491
+ }
492
+ if (native && navigator.share) {
493
+ native.hidden = false;
494
+ native.addEventListener('click', () => navigator.share({title: document.title, url: location.href.split('#')[0]}).catch(() => {}));
495
+ }
496
+ });
497
+
498
+ if (!M) return;
499
+
500
+ // Reading progress, over the article rather than the whole page.
501
+ const article = page.querySelector('.blog-content') ?? page;
502
+ const bar = el('<div class="blog-progress" aria-hidden="true"><span></span></div>');
503
+ document.body.appendChild(bar);
504
+ const progress = M.observable(0);
505
+ M.effect(() => { bar.firstElementChild.style.transform = `scaleX(${progress.value})`; });
506
+ let ticking = false;
507
+ const measure = () => {
508
+ ticking = false;
509
+ const r = article.getBoundingClientRect();
510
+ const span = r.height - innerHeight * 0.6;
511
+ progress.value = Math.max(0, Math.min(1, span > 0 ? -r.top / span : (r.top < 0 ? 1 : 0)));
512
+ };
513
+ addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(measure); } }, {passive: true});
514
+ measure();
515
+
516
+ // The contents list follows the heading being read.
517
+ const toc = page.querySelector('[data-blog-toc]');
518
+ if (toc) keepInView(toc);
519
+ if (toc) {
520
+ const links = new Map([...toc.querySelectorAll('a[href^="#"]')].map(a => [decodeURIComponent(a.hash.slice(1)), a]));
521
+ const heads = [...links.keys()].map(id => document.getElementById(id)).filter(Boolean);
522
+ const current = M.observable(heads[0]?.id ?? '');
523
+ M.effect(() => links.forEach((a, id) => a.classList.toggle('is-active', id === current.value)));
524
+ const onScroll = () => {
525
+ const line = 120;
526
+ let at = heads[0]?.id ?? '';
527
+ for (const h of heads) { if (h.getBoundingClientRect().top - line <= 0) at = h.id; else break; }
528
+ current.value = at;
529
+ };
530
+ addEventListener('scroll', onScroll, {passive: true});
531
+ onScroll();
532
+ toc.addEventListener('click', (e) => {
533
+ const a = e.target.closest('a[href^="#"]');
534
+ if (!a) return;
535
+ e.preventDefault();
536
+ document.getElementById(decodeURIComponent(a.hash.slice(1)))?.scrollIntoView({behavior: 'smooth', block: 'start'});
537
+ history.replaceState(history.state, '', a.hash);
538
+ });
539
+ }
540
+
541
+ // Comments, posted in place.
542
+ const form = page.querySelector('[data-blog-comment-form]');
543
+ if (form) {
544
+ const status = M.observable({kind: '', text: ''});
545
+ const sending = M.observable(false);
546
+ const out = form.querySelector('[data-blog-comment-status]');
547
+ const button = form.querySelector('button[type="submit"]');
548
+ M.effect(() => {
549
+ out.textContent = status.value.text;
550
+ out.className = `blog-comment-status${status.value.kind ? ` is-${status.value.kind}` : ''}`;
551
+ button.disabled = sending.value;
552
+ button.textContent = sending.value ? 'Posting…' : 'Post comment';
553
+ });
554
+ form.addEventListener('submit', async (e) => {
555
+ e.preventDefault();
556
+ if (sending.value) return;
557
+ const body = Object.fromEntries(new FormData(form).entries());
558
+ sending.value = true;
559
+ status.value = {kind: '', text: ''};
560
+ try {
561
+ const res = await fetch(form.action, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
562
+ const json = await res.json().catch(() => ({}));
563
+ if (!res.ok) throw new Error(json.error || 'Your comment could not be posted.');
564
+ if (json.status === 'approved') {
565
+ const li = el('<li class="blog-comment is-new"><p class="blog-comment-head"><strong></strong> <time>just now</time></p><p class="blog-comment-body"></p></li>');
566
+ li.querySelector('strong').textContent = json.authorName ?? body.authorName;
567
+ li.querySelector('.blog-comment-body').textContent = json.body ?? body.body;
568
+ page.querySelector('[data-blog-comment-list]')?.appendChild(li);
569
+ const n = page.querySelector('[data-blog-comment-count]');
570
+ if (n) n.textContent = String((Number(n.textContent) || 0) + 1);
571
+ status.value = {kind: 'ok', text: 'Thanks - your comment is live.'};
572
+ } else {
573
+ status.value = {kind: 'ok', text: 'Thanks - your comment will appear once it has been approved.'};
574
+ }
575
+ form.querySelector('textarea[name="body"]').value = '';
576
+ } catch (err) {
577
+ status.value = {kind: 'error', text: err.message};
578
+ } finally {
579
+ sending.value = false;
580
+ }
581
+ });
582
+ }
583
+ }
584
+
585
+ initArchive();
586
+ initPost();
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="487" cy="606" r="398" fill="#ffffff" opacity="0.14"/><circle cx="1236" cy="485" r="417" fill="#22d3ee" opacity="0.1"/><circle cx="26" cy="857" r="360" fill="#22d3ee" opacity="0.18"/><circle cx="479" cy="196" r="360" fill="#ffffff" opacity="0.22"/><circle cx="1308" cy="881" r="197" fill="#22d3ee" opacity="0.14"/><circle cx="310" cy="888" r="387" fill="#22d3ee" opacity="0.22"/><circle cx="31" cy="687" r="152" fill="#22d3ee" opacity="0.14"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#312e81"/><stop offset="1" stop-color="#8b5cf6"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="1069" cy="424" r="275" fill="#ffffff" opacity="0.18"/><circle cx="357" cy="784" r="396" fill="#f0abfc" opacity="0.18"/><circle cx="55" cy="254" r="316" fill="#ffffff" opacity="0.22"/><circle cx="1027" cy="821" r="282" fill="#f0abfc" opacity="0.22"/><circle cx="1128" cy="63" r="191" fill="#f0abfc" opacity="0.14"/><circle cx="1445" cy="545" r="406" fill="#ffffff" opacity="0.14"/><circle cx="1105" cy="126" r="155" fill="#ffffff" opacity="0.18"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#9a3412"/><stop offset="1" stop-color="#f97316"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="1170" cy="33" r="339" fill="#f97316" opacity="0.22"/><circle cx="30" cy="211" r="356" fill="#ffffff" opacity="0.22"/><circle cx="1338" cy="830" r="202" fill="#f97316" opacity="0.1"/><circle cx="1003" cy="335" r="158" fill="#f97316" opacity="0.14"/><circle cx="739" cy="45" r="335" fill="#f97316" opacity="0.14"/><circle cx="727" cy="390" r="335" fill="#f97316" opacity="0.18"/><circle cx="537" cy="467" r="209" fill="#f97316" opacity="0.18"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0c4a6e"/><stop offset="1" stop-color="#3b82f6"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="25" cy="480" r="177" fill="#bae6fd" opacity="0.22"/><circle cx="1400" cy="44" r="191" fill="#3b82f6" opacity="0.1"/><circle cx="474" cy="728" r="191" fill="#3b82f6" opacity="0.14"/><circle cx="67" cy="678" r="151" fill="#bae6fd" opacity="0.14"/><circle cx="1097" cy="749" r="349" fill="#bae6fd" opacity="0.22"/><circle cx="1214" cy="95" r="180" fill="#ffffff" opacity="0.1"/><circle cx="699" cy="203" r="225" fill="#ffffff" opacity="0.18"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#365314"/><stop offset="1" stop-color="#84cc16"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="1458" cy="392" r="418" fill="#fef08a" opacity="0.14"/><circle cx="342" cy="198" r="206" fill="#84cc16" opacity="0.1"/><circle cx="1551" cy="154" r="265" fill="#ffffff" opacity="0.1"/><circle cx="957" cy="886" r="179" fill="#84cc16" opacity="0.1"/><circle cx="345" cy="503" r="347" fill="#ffffff" opacity="0.18"/><circle cx="174" cy="703" r="250" fill="#84cc16" opacity="0.14"/><circle cx="669" cy="312" r="158" fill="#84cc16" opacity="0.18"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>