@seliseblocks/mailcraft 0.1.1 → 0.2.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 (69) hide show
  1. package/DOCS.md +238 -10
  2. package/README.md +11 -3
  3. package/README.md.txt +134 -0
  4. package/dist/mailcraft-editor.bundle.js +51 -39
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/order-confirmed.html +80 -80
  7. package/examples/vanilla.html +234 -22
  8. package/package.json +7 -2
  9. package/src/core/assets.js +10 -15
  10. package/src/core/binder.js +120 -118
  11. package/src/core/blocks.js +14 -1
  12. package/src/core/css-cascade.js +117 -117
  13. package/src/core/editor-core.js +1530 -1492
  14. package/src/core/export.js +142 -55
  15. package/src/core/i18n/ar.js +219 -177
  16. package/src/core/i18n/bg.js +196 -152
  17. package/src/core/i18n/bn.js +218 -176
  18. package/src/core/i18n/ca.js +196 -152
  19. package/src/core/i18n/cs.js +196 -152
  20. package/src/core/i18n/da.js +196 -152
  21. package/src/core/i18n/de-CH.js +196 -152
  22. package/src/core/i18n/de.js +196 -152
  23. package/src/core/i18n/dz.js +221 -179
  24. package/src/core/i18n/el.js +196 -152
  25. package/src/core/i18n/en.js +3 -10
  26. package/src/core/i18n/es.js +196 -152
  27. package/src/core/i18n/et.js +196 -152
  28. package/src/core/i18n/fi.js +196 -152
  29. package/src/core/i18n/fr.js +196 -152
  30. package/src/core/i18n/hr.js +196 -152
  31. package/src/core/i18n/hu.js +196 -152
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +196 -152
  34. package/src/core/i18n/lt.js +196 -152
  35. package/src/core/i18n/lv.js +196 -152
  36. package/src/core/i18n/nb.js +196 -152
  37. package/src/core/i18n/nl.js +196 -152
  38. package/src/core/i18n/pl.js +196 -152
  39. package/src/core/i18n/pt.js +196 -152
  40. package/src/core/i18n/ro.js +196 -152
  41. package/src/core/i18n/ru.js +196 -152
  42. package/src/core/i18n/sk.js +196 -152
  43. package/src/core/i18n/sl.js +196 -152
  44. package/src/core/i18n/sv.js +196 -152
  45. package/src/core/i18n/tables.js +50 -50
  46. package/src/core/i18n/tr.js +196 -152
  47. package/src/core/i18n/uk.js +196 -152
  48. package/src/core/icons.js +237 -235
  49. package/src/core/ids.js +1 -1
  50. package/src/core/import-html.js +1025 -959
  51. package/src/core/layout-style.js +100 -100
  52. package/src/core/parse.js +10 -10
  53. package/src/core/placeholder.js +15 -15
  54. package/src/core/sanitize.js +141 -141
  55. package/src/core/storage-limits.js +184 -184
  56. package/src/core/storage.js +85 -85
  57. package/src/core/theme.js +1 -1
  58. package/src/core/variables.js +11 -11
  59. package/src/index.js +9 -9
  60. package/src/mailcraft-editor.js +26 -14
  61. package/src/render/block-body.js +49 -6
  62. package/src/render/canvas.js +31 -2
  63. package/src/render/fields.js +602 -588
  64. package/src/render/focus-preserve.js +158 -158
  65. package/src/render/rte.js +241 -212
  66. package/src/render/screenshot.js +132 -132
  67. package/src/render/story.js +415 -415
  68. package/src/render/style.js +8 -0
  69. package/types/index.d.ts +419 -0
@@ -1,477 +1,482 @@
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
-
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', dir: 'auto' });
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, dir: 'ltr' });
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(), dir: 'auto' });
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}`, dir: 'ltr' });
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}`, dir: 'auto' });
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
+ // dir=auto here and on the other content fields below: these hold the
433
+ // email's own text, whose language is independent of the editor chrome's
434
+ // locale -- inheriting the chrome's `dir=rtl` bidi-scrambled English copy
435
+ // (trailing periods jumping to the line start). Value-shaped fields
436
+ // (URLs, color codes) pin dir=ltr instead, since they are never RTL.
437
+ 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}`, dir: 'auto' });
438
+ ta.value = f.value;
439
+ const taCommit = typeCommit(f.onChange);
440
+ ta.addEventListener('input', (e) => taCommit.call(e.target.value));
441
+ ta.addEventListener('focus', () => { ta.style.borderColor = 'var(--ed-accent)'; ta.style.outline = 'none'; });
442
+ ta.addEventListener('blur', () => { ta.style.borderColor = 'var(--ed-line)'; taCommit.flush(); });
443
+ box.appendChild(ta);
444
+ wrap.appendChild(box);
445
+ return wrap;
446
+ }
447
+
448
+ if (f.isBtn) {
449
+ // Soft accent action chip -- same recipe as the panel's other secondary
450
+ // actions, replacing the retired mono-uppercase ghost button that
451
+ // clashed with the card system it sits in.
452
+ 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 });
453
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent-ink)'; });
454
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; });
455
+ btn.addEventListener('click', f.onClick);
456
+ wrap.appendChild(btn);
457
+ return wrap;
458
+ }
459
+
460
+ if (f.isSeg) {
461
+ const box = el('div');
462
+ box.appendChild(fieldLabel(f.label));
463
+ 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' });
464
+ f.options.forEach((o) => {
465
+ const active = o.bg === 'var(--ed-accent)';
466
+ 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) });
467
+ b.addEventListener('click', o.onClick);
468
+ seg.appendChild(b);
469
+ });
470
+ box.appendChild(seg);
471
+ wrap.appendChild(box);
472
+ return wrap;
473
+ }
474
+
475
+ if (f.isRangeGroup) {
476
+ wrap.appendChild(renderRangeGroup(f));
477
+ return wrap;
478
+ }
479
+
475
480
  if (f.isSlider) {
476
481
  // Drag-to-resize slider (core/binder.js `slider`): the value stays in a
477
482
  // stable header badge instead of riding over the thumb, where it collided
@@ -491,119 +496,128 @@ export function renderField(f) {
491
496
  value.textContent = input.value + f.unit;
492
497
  input.style.background = `linear-gradient(to right, var(--ed-accent) ${pct * 100}%, var(--ed-line-2) ${pct * 100}%)`;
493
498
  };
494
- place();
495
- input.addEventListener('input', place);
496
- input.addEventListener('change', (e) => f.onCommit(e.target.value));
499
+ place();
500
+ input.addEventListener('input', place);
501
+ input.addEventListener('change', (e) => f.onCommit(e.target.value));
497
502
  holder.appendChild(input);
498
503
  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
- }
504
+ wrap.appendChild(box);
505
+ return wrap;
506
+ }
507
+
508
+ if (f.isRange) {
509
+ const box = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }, { class: 'mc-field-row' });
510
+ box.appendChild(fieldLabel(f.label, true));
511
+ box.appendChild(stepper(f, '132px'));
512
+ wrap.appendChild(box);
513
+ return wrap;
514
+ }
515
+
516
+ if (f.isToggle) {
517
+ // The pill track and its knob live in CSS (`.mc-switch` in render/style.js)
518
+ // rather than inline styles: the on/off look is driven purely off
519
+ // `aria-checked`, which keeps the accessible state and the visual state
520
+ // from ever drifting apart, and lets `:hover`/`:focus-visible` style it
521
+ // without a JS listener per state.
522
+ const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', minHeight: '32px', cursor: 'pointer' }, { class: 'mc-switch-row mc-field-row' });
523
+ // Only the row listens: a click on the switch bubbles up to it, so the
524
+ // handler still fires exactly once (including for keyboard Enter/Space,
525
+ // which the browser dispatches as a click on the button).
526
+ row.addEventListener('click', () => { pendingSwitchAnim = f.label; f.onChange(); });
527
+ const togLabel = fieldLabel(f.label, true);
528
+ togLabel.style.cursor = 'pointer';
529
+ row.appendChild(togLabel);
530
+ const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.on), 'aria-label': f.label });
531
+ track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
532
+ // See `pendingSwitchAnim`: this is the rebuilt switch for the click that
533
+ // just happened, so it (and only it) plays the slide.
534
+ if (pendingSwitchAnim === f.label) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
535
+ row.appendChild(track);
536
+ wrap.appendChild(row);
537
+ return wrap;
538
+ }
539
+
540
+ if (f.isColor) {
541
+ // Flat-accordion design: label at left, one bordered pill at right
542
+ // holding the swatch (which doubles as the native picker) and the hex
543
+ // field -- the swatch-and-loose-hex layout read as two stray controls.
544
+ const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' }, { class: 'mc-field-row' });
545
+ row.appendChild(fieldLabel(f.label, true));
546
+ 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' });
547
+ 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 });
548
+ const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
549
+ picker.value = f.swatch;
550
+ // The native picker fires `input` continuously while dragging the hue
551
+ // wheel -- committed raw, that is a full re-render per mouse move.
552
+ const pickCommit = typeCommit(f.onChange);
553
+ picker.addEventListener('input', (e) => pickCommit.call(e.target.value));
554
+ picker.addEventListener('change', () => pickCommit.flush());
555
+ swatch.appendChild(picker);
556
+ 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}`, dir: 'ltr' });
557
+ hex.value = f.value;
558
+ const hexCommit = typeCommit(f.onChange);
559
+ hex.addEventListener('input', (e) => hexCommit.call(e.target.value));
560
+ hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
561
+ hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
562
+ pill.append(swatch, hex);
563
+ row.appendChild(pill);
564
+ wrap.appendChild(row);
565
+ return wrap;
566
+ }
567
+
568
+ if (f.isRow) {
569
+ const row = el('div', { display: 'flex', alignItems: 'center', gap: '12px', justifyContent: 'space-between' }, { class: 'mc-field-row' });
570
+ row.appendChild(fieldLabel(f.label, true));
571
+ if (f.isText) {
572
+ 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}`, dir: 'auto' });
573
+ input.value = f.value;
574
+ // Optional suggestions (e.g. the host's merge variables on a marker's
575
+ // expression field): a datalist keeps the input free-text -- pick one
576
+ // of the host's names from the dropdown, or type any other.
577
+ if (f.suggestions) {
578
+ const dl = el('datalist', null, { id: `mc-dl-f${f.key}` });
579
+ f.suggestions.forEach((s) => { const opt = document.createElement('option'); opt.value = s; dl.appendChild(opt); });
580
+ input.setAttribute('list', dl.id);
581
+ row.appendChild(dl);
582
+ }
583
+ const textInputCommit = typeCommit(f.onChange);
584
+ input.addEventListener('input', (e) => textInputCommit.call(e.target.value));
585
+ input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
586
+ input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; textInputCommit.flush(); });
587
+ row.appendChild(input);
588
+ } else if (f.isNum) {
589
+ // text + inputmode for the same caret-restoration reason as the range
590
+ // field above.
591
+ 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}` });
592
+ input.value = f.value;
593
+ const numCommit = typeCommit(f.onChange);
594
+ input.addEventListener('input', (e) => numCommit.call(e.target.value));
595
+ // Flush before the clamp, same reason as the stepper above.
596
+ input.addEventListener('blur', (e) => { input.style.borderColor = 'var(--ed-line)'; numCommit.flush(); if (f.onBlur) f.onBlur(e.target.value); });
597
+ input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
598
+ row.appendChild(input);
599
+ } else if (f.isSelect) {
600
+ // Focus key so changing a value (which rebuilds the panel) hands focus
601
+ // back to the rebuilt select -- without it every dropdown change threw
602
+ // keyboard users back to the top of the page.
603
+ 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}` });
604
+ f.options.forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; select.appendChild(opt); });
605
+ // An unset prop arrives as undefined; assigning that to a <select>
606
+ // coerces to the string "undefined" and selects nothing -- coerce to ''
607
+ // so it lands on the empty-value option (e.g. Font's "Inherit").
608
+ select.value = f.value == null ? '' : f.value;
609
+ select.addEventListener('change', (e) => f.onChange(e.target.value));
610
+ select.addEventListener('focus', () => { select.style.borderColor = 'var(--ed-accent)'; select.style.outline = 'none'; });
611
+ select.addEventListener('blur', () => { select.style.borderColor = 'var(--ed-line)'; });
612
+ row.appendChild(select);
613
+ }
614
+ wrap.appendChild(row);
615
+ return wrap;
616
+ }
617
+
618
+ return wrap;
619
+ }
620
+
621
+ export function renderFieldList(container, fields) {
622
+ fields.forEach((f) => container.appendChild(renderField(f)));
623
+ }