@seliseblocks/mailcraft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/DOCS.md +557 -0
  2. package/LICENSE +21 -0
  3. package/README.md +126 -0
  4. package/dist/mailcraft-editor.bundle.js +519 -0
  5. package/dist/mailcraft-editor.bundle.js.map +7 -0
  6. package/examples/templates/activate-your-account.html +54 -0
  7. package/examples/templates/back-in-stock.html +63 -0
  8. package/examples/templates/cart-left-behind.html +74 -0
  9. package/examples/templates/community-giveaway.html +81 -0
  10. package/examples/templates/frontend-futures-invite.html +69 -0
  11. package/examples/templates/give-25-get-25.html +69 -0
  12. package/examples/templates/invoice-paid.html +77 -0
  13. package/examples/templates/meet-nova-launch.html +69 -0
  14. package/examples/templates/mega-weekend-sale.html +57 -0
  15. package/examples/templates/order-confirmed.html +80 -0
  16. package/examples/templates/rate-your-headphones.html +71 -0
  17. package/examples/templates/reset-your-password.html +54 -0
  18. package/examples/templates/thankyou-promo-code.html +68 -0
  19. package/examples/templates/the-sunday-brief.html +47 -0
  20. package/examples/templates/welcome-to-your-workspace.html +62 -0
  21. package/examples/templates/your-order-shipped.html +74 -0
  22. package/examples/templates/your-password-was-changed.html +58 -0
  23. package/examples/templates/your-signin-code.html +58 -0
  24. package/examples/vanilla.html +1574 -0
  25. package/package.json +66 -0
  26. package/src/core/accent.js +194 -0
  27. package/src/core/assets.js +15 -0
  28. package/src/core/binder.js +119 -0
  29. package/src/core/blocks.js +256 -0
  30. package/src/core/css-cascade.js +117 -0
  31. package/src/core/editor-core.js +1492 -0
  32. package/src/core/export.js +58 -0
  33. package/src/core/footer.js +73 -0
  34. package/src/core/i18n/ar.js +177 -0
  35. package/src/core/i18n/bg.js +152 -0
  36. package/src/core/i18n/bn.js +176 -0
  37. package/src/core/i18n/ca.js +152 -0
  38. package/src/core/i18n/cs.js +152 -0
  39. package/src/core/i18n/da.js +152 -0
  40. package/src/core/i18n/de-CH.js +152 -0
  41. package/src/core/i18n/de.js +152 -0
  42. package/src/core/i18n/dz.js +179 -0
  43. package/src/core/i18n/el.js +152 -0
  44. package/src/core/i18n/en.js +269 -0
  45. package/src/core/i18n/es.js +152 -0
  46. package/src/core/i18n/et.js +152 -0
  47. package/src/core/i18n/fi.js +152 -0
  48. package/src/core/i18n/fr.js +152 -0
  49. package/src/core/i18n/hr.js +152 -0
  50. package/src/core/i18n/hu.js +152 -0
  51. package/src/core/i18n/index.js +83 -0
  52. package/src/core/i18n/it.js +152 -0
  53. package/src/core/i18n/lt.js +152 -0
  54. package/src/core/i18n/lv.js +152 -0
  55. package/src/core/i18n/nb.js +152 -0
  56. package/src/core/i18n/nl.js +152 -0
  57. package/src/core/i18n/pl.js +152 -0
  58. package/src/core/i18n/pt.js +152 -0
  59. package/src/core/i18n/ro.js +152 -0
  60. package/src/core/i18n/ru.js +152 -0
  61. package/src/core/i18n/sk.js +152 -0
  62. package/src/core/i18n/sl.js +152 -0
  63. package/src/core/i18n/sv.js +152 -0
  64. package/src/core/i18n/tables.js +50 -0
  65. package/src/core/i18n/tr.js +152 -0
  66. package/src/core/i18n/uk.js +152 -0
  67. package/src/core/icons.js +235 -0
  68. package/src/core/ids.js +1 -0
  69. package/src/core/import-html.js +959 -0
  70. package/src/core/layout-style.js +115 -0
  71. package/src/core/parse.js +10 -0
  72. package/src/core/placeholder.js +15 -0
  73. package/src/core/sanitize.js +141 -0
  74. package/src/core/storage-limits.js +184 -0
  75. package/src/core/storage.js +85 -0
  76. package/src/core/theme.js +1 -0
  77. package/src/core/toolbar.js +67 -0
  78. package/src/core/variables.js +11 -0
  79. package/src/create-editor.js +109 -0
  80. package/src/index.js +9 -0
  81. package/src/mailcraft-editor.js +1862 -0
  82. package/src/render/block-body.js +295 -0
  83. package/src/render/canvas.js +284 -0
  84. package/src/render/fields.js +609 -0
  85. package/src/render/focus-preserve.js +158 -0
  86. package/src/render/rte.js +212 -0
  87. package/src/render/screenshot.js +132 -0
  88. package/src/render/story.js +415 -0
  89. package/src/render/style.js +452 -0
@@ -0,0 +1,609 @@
1
+ import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
+ import { parseItems } from '../core/parse.js';
3
+
4
+ function el(tag, style, attrs) {
5
+ const node = document.createElement(tag);
6
+ if (style) Object.assign(node.style, style);
7
+ if (attrs) for (const [k, v] of Object.entries(attrs)) {
8
+ if (k === 'text') node.textContent = v;
9
+ else if (v !== undefined) node.setAttribute(k, v);
10
+ }
11
+ return node;
12
+ }
13
+
14
+ /**
15
+ * Trailing debounce for free-typing inputs. Every commit rebuilds the whole
16
+ * canvas and panel (full re-render, no diffing) -- fine per click, but per
17
+ * keystroke it floors low-end hardware. Typing therefore commits 120ms after
18
+ * the last keystroke: one rebuild per pause instead of per key. `flush` runs
19
+ * on blur so leaving the field (or a blur-time clamp that reads committed
20
+ * state, e.g. B.num) never races a still-pending commit. Discrete controls
21
+ * (toggles, selects, steppers' -/+) stay immediate.
22
+ */
23
+ export function typeCommit(fn) {
24
+ let t = null; let last;
25
+ const fire = () => { t = null; fn(last); };
26
+ return {
27
+ call(v) { last = v; if (t) clearTimeout(t); t = setTimeout(fire, 120); },
28
+ flush() { if (t) { clearTimeout(t); fire(); } },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Label of the switch the user just clicked, consumed by the very next render.
34
+ *
35
+ * Toggling a prop re-renders the whole panel, which replaces the switch with a
36
+ * fresh node built already in its new state -- there is no before/after on one
37
+ * element for a CSS `transition` to interpolate, so the knob used to jump. The
38
+ * flag lets the rebuilt switch (and no other) start a one-shot keyframe from
39
+ * where the old one stood, so a click slides while merely opening a panel full
40
+ * of already-on switches still paints them in place.
41
+ */
42
+ let pendingSwitchAnim = null;
43
+
44
+ /**
45
+ * One label style for every field kind, so the inspector reads as one system:
46
+ * semibold editor UI font at 75% ink for field names (distinct from both the muted
47
+ * values and the uppercase group kickers), block or inline per context.
48
+ */
49
+ function fieldLabel(text, inline) {
50
+ // One label treatment across every right-panel field. Placement can differ
51
+ // (above wide controls, beside compact controls), but size, weight and color
52
+ // must not change with the control type.
53
+ return el('label', Object.assign(
54
+ { fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' },
55
+ inline
56
+ ? { flex: '1', minWidth: '0' }
57
+ : { display: 'block', marginBottom: '6px' },
58
+ ), { text, class: 'mc-field-label' });
59
+ }
60
+
61
+ /**
62
+ * Which accordion sections the user has collapsed, by section label.
63
+ * Editor-UI state, deliberately module-level: it must survive the full
64
+ * re-render on every commit (the panel is rebuilt each time) but is never
65
+ * part of the document, and collapsing/expanding itself is pure DOM --
66
+ * no core state, no re-render.
67
+ */
68
+ const sectionCollapsed = Object.create(null);
69
+
70
+ /**
71
+ * The flat accordion inspector layout: each `head` field becomes a
72
+ * full-width light section bar (uppercase label, chevron, click to
73
+ * collapse) and the fields under it render as roomy rows directly on the
74
+ * panel surface. Fields before the first head form an untitled lead group.
75
+ */
76
+ export function renderFieldCards(container, fields) {
77
+ let body = null;
78
+ const open = (headField) => {
79
+ if (headField) {
80
+ const label = headField.label;
81
+ // The mc-section-* classes are theme hooks (render/style.js can restyle
82
+ // the section chrome without touching this builder); the inline styles
83
+ // stay the baseline look.
84
+ const bar = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 14px', background: 'var(--ed-panel-2)', borderTop: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)', cursor: 'pointer', userSelect: 'none' }, { role: 'button', tabindex: '0', 'aria-expanded': String(!sectionCollapsed[label]), class: 'mc-section-bar' });
85
+ bar.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ed-muted)' }, { text: label, class: 'mc-section-label' }));
86
+ const chev = el('span', { display: 'flex', color: 'var(--ed-faint)', transition: 'transform 0.16s', transform: sectionCollapsed[label] ? 'rotate(-90deg)' : 'none' }, { class: 'mc-section-chevron' });
87
+ chev.appendChild(icon('chevronDown', 13));
88
+ bar.appendChild(chev);
89
+ const localBody = body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
90
+ if (sectionCollapsed[label]) localBody.hidden = true;
91
+ const toggle = () => {
92
+ sectionCollapsed[label] = !sectionCollapsed[label];
93
+ localBody.hidden = !!sectionCollapsed[label];
94
+ chev.style.transform = sectionCollapsed[label] ? 'rotate(-90deg)' : 'none';
95
+ bar.setAttribute('aria-expanded', String(!sectionCollapsed[label]));
96
+ };
97
+ bar.addEventListener('click', toggle);
98
+ bar.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
99
+ container.appendChild(bar);
100
+ container.appendChild(localBody);
101
+ return;
102
+ }
103
+ body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
104
+ container.appendChild(body);
105
+ };
106
+ fields.forEach((f) => {
107
+ if (f.isHead) { open(f); return; }
108
+ if (!body) open(null);
109
+ const node = renderField(f);
110
+ node.style.margin = '0';
111
+ body.appendChild(node);
112
+ });
113
+ }
114
+
115
+ /** Every platform the "Add network…" dropdown offers, with a sensible starter URL. Order roughly by how often they appear in email footers. */
116
+ const NETWORKS = [
117
+ ['Instagram', 'https://instagram.com/'], ['X', 'https://x.com/'], ['Facebook', 'https://facebook.com/'],
118
+ ['LinkedIn', 'https://linkedin.com/company/'], ['YouTube', 'https://youtube.com/@'], ['TikTok', 'https://tiktok.com/@'],
119
+ ['Pinterest', 'https://pinterest.com/'], ['WhatsApp', 'https://wa.me/'], ['Telegram', 'https://t.me/'],
120
+ ['Threads', 'https://threads.net/@'], ['Snapchat', 'https://snapchat.com/add/'], ['Discord', 'https://discord.gg/'],
121
+ ['Reddit', 'https://reddit.com/r/'], ['Spotify', 'https://open.spotify.com/'], ['Behance', 'https://behance.net/'],
122
+ ['Dribbble', 'https://dribbble.com/'], ['Email', 'mailto:hello@example.com'], ['Website', 'https://example.com'],
123
+ ];
124
+
125
+ /**
126
+ * The social block's network editor: one card per network (brand-colored icon
127
+ * chip, editable name, URL, remove) plus an "Add network…" dropdown -- what
128
+ * used to be a raw `Name|URL` textarea. The data stays that same string
129
+ * (`f.value` in, serialized back out through `f.onChange`), so the block,
130
+ * export and import are untouched; only the editing surface changed.
131
+ */
132
+ function renderSocialItems(f) {
133
+ const box = el('div');
134
+ box.appendChild(fieldLabel(f.label));
135
+ const items = parseItems(f.value || '');
136
+ // No coercion here: commits land while the user is still typing (debounced,
137
+ // see typeCommit) and the panel re-renders from the committed string, so
138
+ // padding an empty field with a fallback would snap "cleared to retype"
139
+ // inputs back mid-edit.
140
+ const commit = (next) => f.onChange(next.map((it) => (it.label || '') + '|' + (it.href || '')).join('\n'));
141
+ const list = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
142
+
143
+ items.forEach((it, i) => {
144
+ const row = el('div', { display: 'flex', alignItems: 'center', gap: '7px', padding: '6px 7px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
145
+ const key = socialKey(it.label);
146
+ const brand = SOCIAL_BRAND[key] || '';
147
+ const chip = el('span', { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', flex: 'none', borderRadius: '7px', background: brand || 'var(--ed-soft)', color: brand ? contrastInk(brand) : 'var(--ed-accent)' });
148
+ chip.appendChild(brandIcon(key, 13));
149
+ const name = el('input', { width: '70px', flex: 'none', boxSizing: 'border-box', background: 'transparent', border: '1px solid transparent', borderRadius: '5px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '3px 4px' }, { 'data-focus-key': `f${f.key}-n${i}`, title: 'Network name' });
150
+ name.value = it.label;
151
+ const nameCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: v, href: it.href }; commit(next); });
152
+ name.addEventListener('input', (e) => nameCommit.call(e.target.value));
153
+ name.addEventListener('focus', () => { name.style.borderColor = 'var(--ed-accent)'; name.style.background = 'var(--ed-panel)'; name.style.outline = 'none'; });
154
+ name.addEventListener('blur', () => { name.style.borderColor = 'transparent'; name.style.background = 'transparent'; nameCommit.flush(); });
155
+ const url = el('input', { flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '6px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '10.5px', padding: '4px 6px' }, { placeholder: 'https://', 'data-focus-key': `f${f.key}-u${i}`, title: it.href });
156
+ url.value = it.href;
157
+ const urlCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: it.label, href: v }; commit(next); });
158
+ url.addEventListener('input', (e) => urlCommit.call(e.target.value));
159
+ url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
160
+ url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
161
+ const del = el('button', { width: '22px', height: '22px', flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '13px', lineHeight: '1' }, { type: 'button', title: 'Remove ' + (it.label || 'network'), text: '✕' });
162
+ del.addEventListener('mouseenter', () => { del.style.background = 'var(--ed-danger-soft)'; del.style.color = 'var(--ed-danger)'; });
163
+ del.addEventListener('mouseleave', () => { del.style.background = 'transparent'; del.style.color = 'var(--ed-faint)'; });
164
+ del.addEventListener('click', () => commit(items.filter((x, xi) => xi !== i)));
165
+ row.append(chip, name, url, del);
166
+ list.appendChild(row);
167
+ });
168
+
169
+ if (!items.length) {
170
+ list.appendChild(el('div', { fontSize: '11px', color: 'var(--ed-faint)', padding: '8px 2px' }, { text: 'No networks yet — add one below.' }));
171
+ }
172
+
173
+ const used = new Set(items.map((it) => socialKey(it.label)));
174
+ const add = el('select', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px dashed var(--ed-line-2)', borderRadius: '9px', color: 'var(--ed-muted)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '7px 8px', cursor: 'pointer' }, { title: 'Add network' });
175
+ const ph = document.createElement('option'); ph.value = ''; ph.textContent = '+ Add network…'; add.appendChild(ph);
176
+ NETWORKS.filter(([label]) => !used.has(socialKey(label))).forEach(([label, href]) => {
177
+ const o = document.createElement('option'); o.value = label + '|' + href; o.textContent = label; add.appendChild(o);
178
+ });
179
+ const custom = document.createElement('option'); custom.value = 'Link|https://'; custom.textContent = 'Custom link…'; add.appendChild(custom);
180
+ add.addEventListener('change', (e) => {
181
+ if (!e.target.value) return;
182
+ const [label, href] = e.target.value.split('|');
183
+ commit(items.concat([{ label, href }]));
184
+ });
185
+ add.addEventListener('focus', () => { add.style.borderColor = 'var(--ed-accent)'; add.style.outline = 'none'; });
186
+ add.addEventListener('blur', () => { add.style.borderColor = 'var(--ed-line-2)'; });
187
+ list.appendChild(add);
188
+
189
+ box.appendChild(list);
190
+ return box;
191
+ }
192
+
193
+ /**
194
+ * Friendly link editing for rich-text blocks. The block still stores one HTML
195
+ * string; each commit clones that markup, updates only the indexed anchor and
196
+ * serializes it back, preserving all surrounding formatting and content.
197
+ */
198
+ function renderRichLinks(f) {
199
+ const box = el('div');
200
+ box.appendChild(fieldLabel(f.label));
201
+ const source = el('div');
202
+ source.innerHTML = f.html || '';
203
+ const anchors = Array.from(source.querySelectorAll('a'));
204
+ const list = el('div', { display: 'flex', flexDirection: 'column', gap: '7px' });
205
+ const change = (index, update) => {
206
+ const next = el('div');
207
+ next.innerHTML = f.html || '';
208
+ const anchor = next.querySelectorAll('a')[index];
209
+ if (!anchor) return;
210
+ update(anchor);
211
+ f.onChange(next.innerHTML);
212
+ };
213
+
214
+ anchors.forEach((anchor, i) => {
215
+ const card = el('div', { padding: '8px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
216
+ const head = el('div', { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '7px' });
217
+ const name = el('span', { flex: '1', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', color: 'var(--ed-text)' }, { text: (anchor.textContent || '').trim() || 'Link ' + (i + 1), title: (anchor.textContent || '').trim() });
218
+ const remove = el('button', { flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', padding: '3px 6px', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '600' }, { type: 'button', text: 'Remove', title: 'Remove link', 'aria-label': 'Remove link from ' + ((anchor.textContent || '').trim() || 'text'), 'data-focus-key': `f${f.key}-link-remove-${i}` });
219
+ remove.addEventListener('mouseenter', () => { remove.style.background = 'var(--ed-danger-soft)'; remove.style.color = 'var(--ed-danger)'; });
220
+ remove.addEventListener('mouseleave', () => { remove.style.background = 'transparent'; remove.style.color = 'var(--ed-faint)'; });
221
+ remove.addEventListener('click', () => change(i, (a) => a.replaceWith(...a.childNodes)));
222
+ head.append(name, remove);
223
+
224
+ const url = el('input', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '7px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '6px 8px' }, { placeholder: 'https://', 'aria-label': 'Link destination for ' + ((anchor.textContent || '').trim() || 'link ' + (i + 1)), 'data-focus-key': `f${f.key}-link-url-${i}` });
225
+ url.value = anchor.getAttribute('href') || '';
226
+ const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
227
+ url.addEventListener('input', (e) => urlCommit.call(e.target.value));
228
+ url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
229
+ url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
230
+
231
+ const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
232
+ const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
233
+ target.checked = anchor.target === '_blank';
234
+ target.addEventListener('change', (e) => change(i, (a) => {
235
+ if (e.target.checked) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
236
+ else { a.removeAttribute('target'); a.removeAttribute('rel'); }
237
+ }));
238
+ targetLabel.append(target, 'Open in new tab');
239
+ card.append(head, url, targetLabel);
240
+ list.appendChild(card);
241
+ });
242
+
243
+ box.appendChild(list);
244
+ return box;
245
+ }
246
+
247
+ /**
248
+ * The table block's cell editor: an actual grid of inputs mirroring the
249
+ * table (bold first row when the header toggle is on), with per-row/column
250
+ * remove buttons and add-row/add-column actions -- replacing the raw
251
+ * "one line per row, split by |" textarea. Data stays that same string.
252
+ */
253
+ function renderTableGrid(f) {
254
+ const box = el('div');
255
+ box.appendChild(fieldLabel(f.label));
256
+ const rows = String(f.value || '').split('\n').map((r) => r.split('|'));
257
+ const cols = Math.max(1, ...rows.map((r) => r.length));
258
+ rows.forEach((r) => { while (r.length < cols) r.push(''); });
259
+ // Pipes typed into a cell become '/' -- '|' is the column separator in the
260
+ // stored string (same rule as inline cell editing on the canvas).
261
+ const commit = (rs) => f.onChange(rs.map((r) => r.map((c) => String(c).replace(/\|/g, '/')).join('|')).join('\n'));
262
+ const grid = el('div', { display: 'flex', flexDirection: 'column', gap: '4px' });
263
+ const iconBtnStyle = { border: '0', borderRadius: '5px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '11px', lineHeight: '1', padding: '2px' };
264
+ const dangerHover = (btn) => {
265
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-danger-soft)'; btn.style.color = 'var(--ed-danger)'; });
266
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; btn.style.color = 'var(--ed-faint)'; });
267
+ };
268
+
269
+ if (cols > 1) {
270
+ // The right padding mirrors the per-row ✕ (18px + 4px gap) so the column
271
+ // ✕s line up over their columns -- but that ✕ only exists when there is
272
+ // more than one row, so pad only then or the strip drifts 22px left.
273
+ const strip = el('div', { display: 'flex', gap: '4px', paddingRight: rows.length > 1 ? '22px' : '0' });
274
+ for (let ci = 0; ci < cols; ci++) {
275
+ const cbtn = el('button', Object.assign({ flex: '1', minWidth: '0' }, iconBtnStyle), { type: 'button', title: 'Remove column ' + (ci + 1), text: '✕' });
276
+ dangerHover(cbtn);
277
+ cbtn.addEventListener('click', () => commit(rows.map((r) => r.filter((x, i) => i !== ci))));
278
+ strip.appendChild(cbtn);
279
+ }
280
+ grid.appendChild(strip);
281
+ }
282
+
283
+ rows.forEach((r, ri) => {
284
+ const rowEl = el('div', { display: 'flex', gap: '4px', alignItems: 'center' });
285
+ r.forEach((cell, ci) => {
286
+ const input = el('input', {
287
+ flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)',
288
+ borderRadius: '6px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11px',
289
+ fontWeight: f.header && ri === 0 ? '700' : '400', padding: '5px 6px',
290
+ }, { 'data-focus-key': `f${f.key}-r${ri}c${ci}` });
291
+ input.value = cell;
292
+ const cellCommit = typeCommit((v) => { const next = rows.map((row) => row.slice()); next[ri][ci] = v; commit(next); });
293
+ input.addEventListener('input', (e) => cellCommit.call(e.target.value));
294
+ input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
295
+ input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; cellCommit.flush(); });
296
+ rowEl.appendChild(input);
297
+ });
298
+ if (rows.length > 1) {
299
+ const del = el('button', Object.assign({ width: '18px', flex: 'none' }, iconBtnStyle), { type: 'button', title: 'Remove row ' + (ri + 1), text: '✕' });
300
+ dangerHover(del);
301
+ del.addEventListener('click', () => commit(rows.filter((x, i) => i !== ri)));
302
+ rowEl.appendChild(del);
303
+ }
304
+ grid.appendChild(rowEl);
305
+ });
306
+
307
+ const actions = el('div', { display: 'flex', gap: '6px', marginTop: '2px' });
308
+ [['+ Row', () => commit(rows.concat([new Array(cols).fill('')]))], ['+ Column', () => commit(rows.map((r) => r.concat('')))]].forEach(([label, fn]) => {
309
+ const btn = el('button', { flex: '1', border: '1px dashed var(--ed-line-2)', borderRadius: '8px', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '11px', fontWeight: '600', padding: '6px' }, { type: 'button', text: label });
310
+ btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
311
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line-2)'; btn.style.color = 'var(--ed-muted)'; btn.style.background = 'transparent'; });
312
+ btn.addEventListener('click', fn);
313
+ actions.appendChild(btn);
314
+ });
315
+ grid.appendChild(actions);
316
+ box.appendChild(grid);
317
+ return box;
318
+ }
319
+
320
+ /**
321
+ * The stepper: ONE bordered box holding bare -/+ hit areas flanking the
322
+ * centered value + unit. The previous design nested raised, bordered buttons
323
+ * inside an inset pill; the boxes-in-boxes read as visual noise, so the
324
+ * inner borders, shadows and track fill are gone -- a single line now
325
+ * carries the whole control. The focus ring lives on the box, not the inner
326
+ * input: the global `#mc input:focus` ring drew a stray box around the bare
327
+ * value in the middle of the control (`.mc-stepper-input` opts out,
328
+ * render/style.js).
329
+ */
330
+ function stepper(f, width) {
331
+ const ctl = el('div', { display: 'flex', alignItems: 'stretch', width: width || '100%', height: '32px', flex: 'none', boxSizing: 'border-box', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-stepper' });
332
+ const stepBtn = (name, title, onClick) => {
333
+ const b = el('button', { width: '27px', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0', border: '0', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', transition: 'background 0.14s, color 0.14s' }, { type: 'button', title });
334
+ b.appendChild(icon(name, 11));
335
+ b.addEventListener('mouseenter', () => { b.style.background = 'var(--ed-soft)'; b.style.color = 'var(--ed-accent)'; });
336
+ b.addEventListener('mouseleave', () => { b.style.background = 'transparent'; b.style.color = 'var(--ed-muted)'; });
337
+ b.addEventListener('click', onClick);
338
+ return b;
339
+ };
340
+ const dec = stepBtn('minus', 'Decrease', () => f.onDec());
341
+ const mid = el('div', { flex: '1', minWidth: '0', display: 'flex', alignItems: 'baseline', alignSelf: 'center', justifyContent: 'center', gap: '3px', padding: '0 2px' });
342
+ // type="text" + inputmode, NOT type="number": browsers refuse the
343
+ // selection APIs on number inputs, so the per-keystroke re-render could
344
+ // never restore the caret -- typing multi-digit values scrambled them.
345
+ // The -/+ buttons and blur clamp cover what the number type provided.
346
+ const input = el('input', { width: '44px', minWidth: '0', flex: '0 1 auto', textAlign: 'center', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'var(--ed-font)', fontSize: '12.5px', fontWeight: '600', padding: '0' }, { type: 'text', inputmode: 'decimal', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}` });
347
+ input.value = f.value;
348
+ const rangeCommit = typeCommit(f.onInput);
349
+ input.addEventListener('input', (e) => rangeCommit.call(e.target.value));
350
+ // Flush before the clamp: onBlur reads/normalizes committed state, so a
351
+ // still-pending keystroke commit firing after it would undo the clamp.
352
+ input.addEventListener('blur', (e) => { rangeCommit.flush(); f.onBlur(e.target.value); });
353
+ input.addEventListener('focus', () => { ctl.style.borderColor = 'var(--ed-accent)'; ctl.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
354
+ input.addEventListener('blur', () => { ctl.style.borderColor = 'var(--ed-line)'; ctl.style.boxShadow = 'none'; });
355
+ mid.appendChild(input);
356
+ if (f.unit) mid.appendChild(el('span', { flex: 'none', fontSize: '12px', fontFamily: 'var(--ed-font)', fontWeight: '500', color: 'var(--ed-text)' }, { text: f.unit }));
357
+ const inc = stepBtn('plus', 'Increase', () => f.onInc());
358
+ ctl.append(dec, mid, inc);
359
+ return ctl;
360
+ }
361
+
362
+ /**
363
+ * A grid of linked steppers for multi-side props (core/binder.js `group`):
364
+ * optional header row carrying the group label and a "More options" switch
365
+ * (which swaps the linked pair for per-side fields), then two columns of
366
+ * small-captioned flat steppers. One grid instead of a stack of full-width
367
+ * label+control rows: the sides read as one unit and the panel loses a
368
+ * border-to-border line per side.
369
+ */
370
+ function renderRangeGroup(f) {
371
+ const box = el('div');
372
+ if (f.label || f.toggle) {
373
+ const head = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px', marginBottom: '9px' });
374
+ head.appendChild(fieldLabel(f.label || '', true));
375
+ if (f.toggle) {
376
+ // Namespaced anim key so this switch's slide (see pendingSwitchAnim)
377
+ // never collides with a plain toggle that shares the group's label.
378
+ const animKey = (f.label || '') + '::more';
379
+ const more = el('div', { display: 'flex', alignItems: 'center', gap: '7px', flex: 'none', cursor: 'pointer' }, { class: 'mc-switch-row' });
380
+ more.addEventListener('click', () => { pendingSwitchAnim = animKey; f.toggle.onChange(); });
381
+ more.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' }, { text: 'More options', class: 'mc-field-label' }));
382
+ const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.toggle.on), 'aria-label': (f.label ? f.label + ' — ' : '') + 'more options' });
383
+ track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
384
+ if (pendingSwitchAnim === animKey) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
385
+ more.appendChild(track);
386
+ head.appendChild(more);
387
+ }
388
+ box.appendChild(head);
389
+ }
390
+ const grid = el('div', { display: 'grid', gridTemplateColumns: f.items.length === 1 ? '1fr' : '1fr 1fr', gap: '10px 12px' });
391
+ f.items.forEach((it) => {
392
+ const cell = el('div');
393
+ cell.appendChild(fieldLabel(it.label));
394
+ cell.appendChild(stepper(it));
395
+ grid.appendChild(cell);
396
+ });
397
+ box.appendChild(grid);
398
+ return box;
399
+ }
400
+
401
+ /**
402
+ * Builds one field row exactly as the template's `sc-if` cascade over
403
+ * `f.isHead/isArea/isBtn/isSeg/isRange/isToggle/isRow(isText/isNum/isColor/isSelect)`
404
+ * (template lines 172-236, and the theme-tab variant at 385-406 which only
405
+ * has isHead/isField(isNum/isColor/isSelect) -- `renderThemeField` covers that subset).
406
+ */
407
+ export function renderField(f) {
408
+ const wrap = el('div', { minWidth: '0' }, { class: 'mc-field' });
409
+
410
+ if (f.isSocial) {
411
+ wrap.appendChild(renderSocialItems(f));
412
+ return wrap;
413
+ }
414
+
415
+ if (f.isTableGrid) {
416
+ wrap.appendChild(renderTableGrid(f));
417
+ return wrap;
418
+ }
419
+
420
+ if (f.isRichLinks) {
421
+ wrap.appendChild(renderRichLinks(f));
422
+ return wrap;
423
+ }
424
+
425
+ // No isHead branch: heads never reach renderField -- renderFieldCards
426
+ // consumes them as card kickers. A field list rendered without the card
427
+ // grouper would drop its headings, which is the loud failure we want.
428
+
429
+ if (f.isArea) {
430
+ const box = el('div');
431
+ box.appendChild(fieldLabel(f.label));
432
+ const ta = el('textarea', { width: '100%', minHeight: '76px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11.5px', lineHeight: '1.5', padding: '7px 8px', resize: 'vertical' }, { placeholder: f.placeholder, 'data-focus-key': `f${f.key}` });
433
+ ta.value = f.value;
434
+ const taCommit = typeCommit(f.onChange);
435
+ ta.addEventListener('input', (e) => taCommit.call(e.target.value));
436
+ ta.addEventListener('focus', () => { ta.style.borderColor = 'var(--ed-accent)'; ta.style.outline = 'none'; });
437
+ ta.addEventListener('blur', () => { ta.style.borderColor = 'var(--ed-line)'; taCommit.flush(); });
438
+ box.appendChild(ta);
439
+ wrap.appendChild(box);
440
+ return wrap;
441
+ }
442
+
443
+ if (f.isBtn) {
444
+ // Soft accent action chip -- same recipe as the panel's other secondary
445
+ // actions, replacing the retired mono-uppercase ghost button that
446
+ // clashed with the card system it sits in.
447
+ const btn = el('button', { width: '100%', border: '0', borderRadius: '8px', background: 'var(--ed-soft)', color: 'var(--ed-accent)', cursor: 'pointer', padding: '8px 10px', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', transition: 'background 0.16s, color 0.16s' }, { type: 'button', text: f.label });
448
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent-ink)'; });
449
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; });
450
+ btn.addEventListener('click', f.onClick);
451
+ wrap.appendChild(btn);
452
+ return wrap;
453
+ }
454
+
455
+ if (f.isSeg) {
456
+ const box = el('div');
457
+ box.appendChild(fieldLabel(f.label));
458
+ const seg = el('div', { display: 'flex', gap: '3px', padding: '3px', border: '0', borderRadius: '10px', background: 'var(--ed-panel-2)' }, { class: 'mc-segment mc-field-segment' });
459
+ f.options.forEach((o) => {
460
+ const active = o.bg === 'var(--ed-accent)';
461
+ const b = el('button', { flex: '1', minWidth: '0', border: '0', borderRadius: '7px', cursor: 'pointer', background: o.bg, color: o.fg, fontFamily: 'var(--ed-font)', fontSize: '10px', fontWeight: '600', padding: '0 6px', height: '28px', textTransform: 'none', letterSpacing: '0.01em', transition: 'background 0.16s, color 0.16s' }, { type: 'button', text: o.label, 'aria-pressed': String(active) });
462
+ b.addEventListener('click', o.onClick);
463
+ seg.appendChild(b);
464
+ });
465
+ box.appendChild(seg);
466
+ wrap.appendChild(box);
467
+ return wrap;
468
+ }
469
+
470
+ if (f.isRangeGroup) {
471
+ wrap.appendChild(renderRangeGroup(f));
472
+ return wrap;
473
+ }
474
+
475
+ if (f.isSlider) {
476
+ // Drag-to-resize slider (core/binder.js `slider`): the value stays in a
477
+ // stable header badge instead of riding over the thumb, where it collided
478
+ // with the label/track at common widths. The fill and badge still update
479
+ // as pure DOM writes; the document commits once, on release.
480
+ wrap.classList.add('mc-slider-field');
481
+ const box = el('div');
482
+ const head = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' });
483
+ head.appendChild(fieldLabel(f.label, true));
484
+ const value = el('output', { minWidth: '58px', height: '25px', padding: '0 8px', boxSizing: 'border-box', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid var(--ed-line)', borderRadius: '7px', background: 'var(--ed-panel-2)', color: 'var(--ed-accent)', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', whiteSpace: 'nowrap' }, { 'aria-live': 'polite' });
485
+ head.appendChild(value);
486
+ const holder = el('div', { padding: '11px 0 4px' });
487
+ const input = el('input', { width: '100%', margin: '0', display: 'block', boxSizing: 'border-box' }, { type: 'range', min: f.min, max: f.max, step: f.step, class: 'mc-slider', 'aria-label': f.label, 'data-focus-key': `f${f.key}` });
488
+ input.value = f.value;
489
+ const place = () => {
490
+ const pct = (input.value - f.min) / (f.max - f.min || 1);
491
+ value.textContent = input.value + f.unit;
492
+ input.style.background = `linear-gradient(to right, var(--ed-accent) ${pct * 100}%, var(--ed-line-2) ${pct * 100}%)`;
493
+ };
494
+ place();
495
+ input.addEventListener('input', place);
496
+ input.addEventListener('change', (e) => f.onCommit(e.target.value));
497
+ holder.appendChild(input);
498
+ box.append(head, holder);
499
+ wrap.appendChild(box);
500
+ return wrap;
501
+ }
502
+
503
+ if (f.isRange) {
504
+ const box = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }, { class: 'mc-field-row' });
505
+ box.appendChild(fieldLabel(f.label, true));
506
+ box.appendChild(stepper(f, '132px'));
507
+ wrap.appendChild(box);
508
+ return wrap;
509
+ }
510
+
511
+ if (f.isToggle) {
512
+ // The pill track and its knob live in CSS (`.mc-switch` in render/style.js)
513
+ // rather than inline styles: the on/off look is driven purely off
514
+ // `aria-checked`, which keeps the accessible state and the visual state
515
+ // from ever drifting apart, and lets `:hover`/`:focus-visible` style it
516
+ // without a JS listener per state.
517
+ const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', minHeight: '32px', cursor: 'pointer' }, { class: 'mc-switch-row mc-field-row' });
518
+ // Only the row listens: a click on the switch bubbles up to it, so the
519
+ // handler still fires exactly once (including for keyboard Enter/Space,
520
+ // which the browser dispatches as a click on the button).
521
+ row.addEventListener('click', () => { pendingSwitchAnim = f.label; f.onChange(); });
522
+ const togLabel = fieldLabel(f.label, true);
523
+ togLabel.style.cursor = 'pointer';
524
+ row.appendChild(togLabel);
525
+ const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.on), 'aria-label': f.label });
526
+ track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
527
+ // See `pendingSwitchAnim`: this is the rebuilt switch for the click that
528
+ // just happened, so it (and only it) plays the slide.
529
+ if (pendingSwitchAnim === f.label) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
530
+ row.appendChild(track);
531
+ wrap.appendChild(row);
532
+ return wrap;
533
+ }
534
+
535
+ if (f.isColor) {
536
+ // Flat-accordion design: label at left, one bordered pill at right
537
+ // holding the swatch (which doubles as the native picker) and the hex
538
+ // field -- the swatch-and-loose-hex layout read as two stray controls.
539
+ const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' }, { class: 'mc-field-row' });
540
+ row.appendChild(fieldLabel(f.label, true));
541
+ const pill = el('div', { display: 'flex', alignItems: 'center', flex: 'none', height: '32px', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-color-control' });
542
+ const swatch = el('label', { position: 'relative', width: '29px', alignSelf: 'stretch', flex: 'none', borderRight: '1px solid var(--ed-line)', background: f.swatch, cursor: 'pointer' }, { title: f.label });
543
+ const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
544
+ picker.value = f.swatch;
545
+ // The native picker fires `input` continuously while dragging the hue
546
+ // wheel -- committed raw, that is a full re-render per mouse move.
547
+ const pickCommit = typeCommit(f.onChange);
548
+ picker.addEventListener('input', (e) => pickCommit.call(e.target.value));
549
+ picker.addEventListener('change', () => pickCommit.flush());
550
+ swatch.appendChild(picker);
551
+ const hex = el('input', { width: '82px', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '0 9px' }, { placeholder: 'inherit', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}` });
552
+ hex.value = f.value;
553
+ const hexCommit = typeCommit(f.onChange);
554
+ hex.addEventListener('input', (e) => hexCommit.call(e.target.value));
555
+ hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
556
+ hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
557
+ pill.append(swatch, hex);
558
+ row.appendChild(pill);
559
+ wrap.appendChild(row);
560
+ return wrap;
561
+ }
562
+
563
+ if (f.isRow) {
564
+ const row = el('div', { display: 'flex', alignItems: 'center', gap: '12px', justifyContent: 'space-between' }, { class: 'mc-field-row' });
565
+ row.appendChild(fieldLabel(f.label, true));
566
+ if (f.isText) {
567
+ const input = el('input', { width: '168px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { placeholder: f.placeholder, class: 'mc-field-control', 'data-focus-key': `f${f.key}` });
568
+ input.value = f.value;
569
+ const textInputCommit = typeCommit(f.onChange);
570
+ input.addEventListener('input', (e) => textInputCommit.call(e.target.value));
571
+ input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
572
+ input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; textInputCommit.flush(); });
573
+ row.appendChild(input);
574
+ } else if (f.isNum) {
575
+ // text + inputmode for the same caret-restoration reason as the range
576
+ // field above.
577
+ const input = el('input', { width: '78px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { type: 'text', inputmode: 'numeric', class: 'mc-field-control', 'data-focus-key': `f${f.key}` });
578
+ input.value = f.value;
579
+ const numCommit = typeCommit(f.onChange);
580
+ input.addEventListener('input', (e) => numCommit.call(e.target.value));
581
+ // Flush before the clamp, same reason as the stepper above.
582
+ input.addEventListener('blur', (e) => { input.style.borderColor = 'var(--ed-line)'; numCommit.flush(); if (f.onBlur) f.onBlur(e.target.value); });
583
+ input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
584
+ row.appendChild(input);
585
+ } else if (f.isSelect) {
586
+ // Focus key so changing a value (which rebuilds the panel) hands focus
587
+ // back to the rebuilt select -- without it every dropdown change threw
588
+ // keyboard users back to the top of the page.
589
+ const select = el('select', { width: '168px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { class: 'mc-field-control', 'data-focus-key': `f${f.key}` });
590
+ f.options.forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; select.appendChild(opt); });
591
+ // An unset prop arrives as undefined; assigning that to a <select>
592
+ // coerces to the string "undefined" and selects nothing -- coerce to ''
593
+ // so it lands on the empty-value option (e.g. Font's "Inherit").
594
+ select.value = f.value == null ? '' : f.value;
595
+ select.addEventListener('change', (e) => f.onChange(e.target.value));
596
+ select.addEventListener('focus', () => { select.style.borderColor = 'var(--ed-accent)'; select.style.outline = 'none'; });
597
+ select.addEventListener('blur', () => { select.style.borderColor = 'var(--ed-line)'; });
598
+ row.appendChild(select);
599
+ }
600
+ wrap.appendChild(row);
601
+ return wrap;
602
+ }
603
+
604
+ return wrap;
605
+ }
606
+
607
+ export function renderFieldList(container, fields) {
608
+ fields.forEach((f) => container.appendChild(renderField(f)));
609
+ }