@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,1862 @@
1
+ import { EditorCore, AI_GOALS, AI_TONES } from './core/editor-core.js';
2
+ import { GROUPS, LAYOUTS, DEF, PALETTE } from './core/blocks.js';
3
+ import { KB } from './core/assets.js';
4
+ import { acceptAttribute } from './core/storage-limits.js';
5
+ import { renderDoc } from './render/canvas.js';
6
+ import { renderRte } from './render/rte.js';
7
+ import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
8
+ import { icon } from './core/icons.js';
9
+ import { TOKEN } from './core/variables.js';
10
+ import { hl, cssUrl } from './core/sanitize.js';
11
+ import { withFocusPreserved } from './render/focus-preserve.js';
12
+ import { captureTemplatePng } from './render/screenshot.js';
13
+ import { resolveToolbar, toolbarKey } from './core/toolbar.js';
14
+ import { resolveFooter } from './core/footer.js';
15
+ import { createStoryViewer } from './render/story.js';
16
+ import { STYLE } from './render/style.js';
17
+ import { createTranslator, isRtl } from './core/i18n/index.js';
18
+ import { ACCENT_VARS, accentTokens, parseColor } from './core/accent.js';
19
+ import { LOCALE_TABLES } from './core/i18n/tables.js';
20
+
21
+ /** `elS` mirrors the template's literal `style="..."` attribute strings verbatim -- copied, not re-derived, to keep the port pixel-exact. */
22
+ function elS(tag, styleStr, attrs = {}, ...children) {
23
+ const node = document.createElement(tag);
24
+ if (styleStr) node.style.cssText = styleStr;
25
+ for (const [k, v] of Object.entries(attrs)) {
26
+ if (v === undefined) continue;
27
+ if (k === 'text') node.textContent = v;
28
+ else if (k === 'html') node.innerHTML = v;
29
+ else if (k === 'on') for (const [ev, fn] of Object.entries(v)) node.addEventListener(ev, fn);
30
+ else node.setAttribute(k, v);
31
+ }
32
+ children.flat().forEach((c) => { if (c != null && c !== false) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
33
+ return node;
34
+ }
35
+
36
+ /** Built from `t` at render time (not a module-level const) since the active translator can change at runtime via `.messages`/`.locale`. Icon-only tabs: `label` carries the full name for aria-label/tooltip, `title` is the native-title fallback/hint. */
37
+ function tabsFor(t) {
38
+ return [
39
+ { key: 'design', label: t('tab.design'), title: t('tab.designHint'), iconName: 'paint' },
40
+ { key: 'blocks', label: t('tab.blocks'), title: t('tab.blocksHint'), iconName: 'grid' },
41
+ { key: 'rows', label: t('tab.rows'), title: t('tab.rowsHint'), iconName: 'table' },
42
+ { key: 'files', label: t('tab.files'), title: t('tab.filesHint'), iconName: 'image' },
43
+ { key: 'layers', label: t('tab.layers'), title: t('tab.layersHint'), iconName: 'list' },
44
+ { key: 'theme', label: t('tab.settings'), title: t('tab.settingsHint'), iconName: 'sliders' },
45
+ { key: 'data', label: t('tab.data'), title: t('tab.dataHint'), iconName: 'data' },
46
+ ];
47
+ }
48
+
49
+ /**
50
+ * Appends a shared CSS-only tooltip bubble (`.mc-tooltip`, render/style.js) to
51
+ * `node` and marks it `data-tip` so `:hover`/`:focus-visible`
52
+ * on the node itself reveals it -- no JS show/hide logic. `dir` picks which
53
+ * side it renders on ('down' for tabs/segments, 'up' for anything hugging the
54
+ * bottom of the header). Also sets `aria-label` (icon-only controls have no
55
+ * visible text) and keeps `title` as the native-tooltip fallback.
56
+ */
57
+ function tip(node, label, dir, align) {
58
+ node.style.position = node.style.position || 'relative';
59
+ node.setAttribute('data-tip', '1');
60
+ if (!node.hasAttribute('aria-label')) node.setAttribute('aria-label', label);
61
+ // The bubble replaces the native tooltip -- with `title` left on the node
62
+ // both would pop on hover.
63
+ node.removeAttribute('title');
64
+ // align 'end' pins the bubble to the control's right edge instead of
65
+ // centering it -- for controls hugging a right edge, where a centered
66
+ // bubble would overflow its scroll container (see .mc-tooltip-end).
67
+ const direction = dir || 'down';
68
+ node.tipNode = elS('span', '', { class: `mc-tooltip mc-tooltip-${direction}${direction === 'down' ? ' mc-tab-tooltip' : ''}${align === 'end' ? ' mc-tooltip-end' : ''}`, text: label });
69
+ node.appendChild(node.tipNode);
70
+ return node;
71
+ }
72
+
73
+ /**
74
+ * `<mailcraft-editor>` -- a pixel-faithful port of the MailCraft prototype to
75
+ * a zero-dependency Web Component. Shadow DOM for isolation; no bundled
76
+ * framework -- `core/editor-core.js` owns all state and logic (ported
77
+ * near-verbatim from the original `Component` class), this file only builds
78
+ * and re-builds DOM from it.
79
+ *
80
+ * Attributes: `variables`, `locale` (picks the shipped UI language
81
+ * and, via `dir` defaulting, RTL), `dir`, `theme` ("light"/"dark" -- host-owned),
82
+ * `ui-font` (a CSS font-family value; use "inherit" for the host app's font),
83
+ * `accent` (the host's brand color -- a CSS color, `var(--your-token)`, or
84
+ * "inherit" to read the host's `accent-color`; the whole accent token set is
85
+ * derived from it, contrast-corrected per chrome),
86
+ * chrome; while present the built-in toggle is hidden), `footer` ("none" to
87
+ * remove the attribution strip, or any string to replace its text).
88
+ * Properties: `.variables`, `.toolbar` (which parts of the top bar are shown),
89
+ * `.aiProvider` (optional async fn, replaces the original's `window.claude.complete`),
90
+ * `.iconProvider` (optional social-icon override), `.storageProvider` (host-supplied
91
+ * file storage -- see `core/storage.js`), `.storageLimits` (host-set upload ceilings,
92
+ * required whenever a provider is set), `.messages` (UI string overrides --
93
+ * a host's own table, an imported locale from `core/i18n/`, or both merged via
94
+ * `defineMessages`; see `core/i18n/index.js`).
95
+ * Methods: `getContent()`, `setContent(doc)`, `importHtml(html)`, `exportHtml()`,
96
+ * `loadTemplate(tpl)`, `screenshotPng()`
97
+ * (full-template PNG as a Blob), `previewScreenshot()` (story-style viewer),
98
+ * `downloadScreenshot()`, `copyScreenshot()`, `undo()`, `redo()`.
99
+ * Events: `change` (detail: doc), `export` (detail: html string).
100
+ */
101
+ // `extends HTMLElement` is evaluated at module scope, so a bare
102
+ // `import 'mailcraft-editor'` threw ReferenceError anywhere there is no DOM --
103
+ // an SSR render pass, or a Node test runner reaching for a named export. The
104
+ // stand-in keeps the module importable there; the element is only ever
105
+ // constructed by the browser, which always has the real base class.
106
+ const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
107
+
108
+ export class MailCraftEditor extends ElementBase {
109
+ static get observedAttributes() { return ['variables', 'locale', 'dir', 'theme', 'ui-font', 'accent', 'toolbar', 'footer']; }
110
+
111
+ constructor() {
112
+ super();
113
+ this.attachShadow({ mode: 'open' });
114
+ // Created here (not connectedCallback) so `.variables` and the document
115
+ // work even before the element is inserted into the document -- a common pattern
116
+ // (`document.createElement(...)`, configure, then append).
117
+ this.core = new EditorCore({
118
+ variables: this.hasAttribute('variables') ? this.getAttribute('variables') : undefined,
119
+ });
120
+ }
121
+
122
+ connectedCallback() {
123
+ this.buildShell();
124
+ this.applyUiFont();
125
+ this.applyAccent();
126
+ this.applyDir();
127
+ this._toolbarKey = toolbarKey(this.toolbarConfig());
128
+ this.core.mount(this.shadowRoot);
129
+ // After mount() on purpose: mount restores the persisted chrome/doc, and
130
+ // a host-supplied `theme`/`locale` must win over what was persisted.
131
+ this.applyTheme();
132
+ this.refreshTranslator();
133
+ this.core.mountKeyboard((target) => {
134
+ const tag = (target && target.tagName || '').toLowerCase();
135
+ return tag === 'input' || tag === 'textarea' || (target && target.isContentEditable);
136
+ });
137
+ this.core.onFormatChange = () => this.scheduleRteRefresh();
138
+ this.core.onCodeSourceChange = () => this.refreshCodeSource();
139
+ this.core.onSavedChange = () => this.refreshSavedLabel();
140
+ this.core.onToast = () => this.refreshToast();
141
+ this.core.onTick = () => this.refreshCountdowns();
142
+ this.unsubscribe = this.core.subscribe(() => {
143
+ withFocusPreserved(this.shadowRoot, () => this.render());
144
+ this.dispatchEvent(new CustomEvent('change', { detail: this.core.state.doc }));
145
+ });
146
+ this.render();
147
+ }
148
+
149
+ disconnectedCallback() {
150
+ this.unsubscribe?.();
151
+ this.core.onFormatChange = null;
152
+ this.core.onCodeSourceChange = null;
153
+ this.core.onSavedChange = null;
154
+ this.core.onToast = null;
155
+ this.core.onTick = null;
156
+ if (this._rteFrame != null) cancelAnimationFrame(this._rteFrame);
157
+ this._rteFrame = null;
158
+ this.core.unmount();
159
+ }
160
+
161
+ attributeChangedCallback(name, _old, value) {
162
+ if (!this.core) return;
163
+ if (name === 'variables') { this.core.variablesRaw = value; this.core.emit(); }
164
+ if (name === 'locale' || name === 'dir') this.applyDir();
165
+ if (name === 'locale') this.refreshTranslator();
166
+ if (name === 'theme') this.applyTheme();
167
+ if (name === 'ui-font') this.applyUiFont();
168
+ // Forced: setting the attribute is also how a host asks for a re-derive
169
+ // after its own `--token` changed -- see applyAccent.
170
+ if (name === 'accent') this.applyAccent(true);
171
+ if (name === 'toolbar') this.applyToolbar();
172
+ if (name === 'footer') this.applyFooter();
173
+ }
174
+
175
+ /**
176
+ * Editor-chrome typography only. The default lives in `--ed-font`; a host
177
+ * may pass any valid CSS font-family string, or `inherit` to snapshot the
178
+ * host element's computed family. Email-document typography remains owned
179
+ * by the document theme and is deliberately unaffected.
180
+ */
181
+ applyUiFont() {
182
+ if (!this.mc) return;
183
+ const requested = (this.getAttribute('ui-font') || '').trim();
184
+ if (!requested) {
185
+ this.mc.style.removeProperty('--ed-font');
186
+ return;
187
+ }
188
+ const resolved = requested.toLowerCase() === 'inherit'
189
+ ? getComputedStyle(this).fontFamily
190
+ : requested;
191
+ this.mc.style.setProperty('--ed-font', resolved || 'inherit');
192
+ }
193
+
194
+ /**
195
+ * Resolves the `accent` attribute to a plain color string. Three spellings,
196
+ * because a host's brand color lives in a different place in each app:
197
+ *
198
+ * accent="#e11d48" a literal color
199
+ * accent="var(--brand)" a design-system token, read off the host
200
+ * element (custom properties inherit through
201
+ * the shadow boundary, so the host's value is
202
+ * the one that resolves), with the optional
203
+ * `var(--brand, #fallback)` fallback honored
204
+ * accent="inherit" the host's computed `accent-color` -- the
205
+ * standard CSS property for exactly this, so a
206
+ * host that already sets it gets the editor for
207
+ * free
208
+ *
209
+ * Returns '' when there is nothing usable, which leaves the built-in accent
210
+ * in place rather than guessing.
211
+ */
212
+ resolveAccentValue(requested) {
213
+ if (typeof getComputedStyle !== 'function') return requested;
214
+ const hostStyle = getComputedStyle(this);
215
+ if (requested.toLowerCase() === 'inherit') {
216
+ const declared = (hostStyle.accentColor || '').trim();
217
+ return !declared || declared === 'auto' ? '' : declared;
218
+ }
219
+ const ref = /^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)$/.exec(requested);
220
+ if (!ref) return requested;
221
+ return (hostStyle.getPropertyValue(ref[1]) || '').trim() || (ref[2] || '').trim();
222
+ }
223
+
224
+ /**
225
+ * Host brand color for the editor chrome. One attribute drives all four
226
+ * accent tokens (`core/accent.js` does the derivation) instead of asking a
227
+ * host to hand-pick a hover shade, a legible ink color and a wash alpha --
228
+ * and it re-derives per chrome, since the shade that reads on white panels
229
+ * is not the one that reads on the dark ones.
230
+ *
231
+ * Applied as inline custom properties on `#mc`: the stylesheet declares the
232
+ * defaults on that same element, so only an inline value can outrank them.
233
+ * Email-document colors are untouched -- those belong to the template, not
234
+ * to the app hosting the editor.
235
+ *
236
+ * `force` skips the memo. Nothing notifies an element that a CSS custom
237
+ * property it reads has changed, so a host whose own `--brand` moved (a
238
+ * brand switcher, a tenant theme) re-sets the attribute to the same string
239
+ * to ask for a re-derive -- and the memo would otherwise swallow that.
240
+ */
241
+ applyAccent(force) {
242
+ if (!this.mc) return;
243
+ const requested = (this.getAttribute('accent') || '').trim();
244
+ const chrome = this.core.state?.chrome || 'light';
245
+ // Both halves matter: the same brand color resolves to different tokens in
246
+ // light and dark chrome, so a toggle has to re-derive.
247
+ const key = requested + '|' + chrome;
248
+ if (!force && key === this._accentKey) return;
249
+ this._accentKey = key;
250
+ const clear = () => ACCENT_VARS.forEach((name) => this.mc.style.removeProperty(name));
251
+ if (!requested) { clear(); return; }
252
+
253
+ const value = this.resolveAccentValue(requested);
254
+ // Named colors ("rebeccapurple") and anything else only a browser can
255
+ // resolve go through a throwaway probe: set it as a color, read back what
256
+ // the engine computed. Parsing first keeps the common case DOM-free.
257
+ const color = parseColor(value) || parseColor(this.probeColor(value));
258
+ if (!color) {
259
+ clear();
260
+ console.warn('[mailcraft] accent="' + requested + '" is not a usable color -- keeping the built-in accent.');
261
+ return;
262
+ }
263
+ const tokens = accentTokens(color, chrome);
264
+ for (const [name, v] of Object.entries(tokens)) this.mc.style.setProperty(name, v);
265
+ }
266
+
267
+ /** Asks the engine what a color string computes to. Returns '' when there is no layout to ask (SSR, a test DOM) or the value was rejected. */
268
+ probeColor(value) {
269
+ if (!value || typeof getComputedStyle !== 'function' || typeof document === 'undefined') return '';
270
+ const probe = document.createElement('span');
271
+ probe.style.display = 'none';
272
+ probe.style.color = value;
273
+ // CSSOM drops a value it cannot parse, leaving `color` empty -- and a
274
+ // probe with no color of its own computes to the *inherited* one, which
275
+ // would sail through parseColor and repaint the chrome in --ed-text.
276
+ if (!probe.style.color) return '';
277
+ // A detached node computes to nothing in some engines -- park it inside
278
+ // the shadow root, where it can neither be seen nor affect layout.
279
+ this.mc.appendChild(probe);
280
+ const computed = (getComputedStyle(probe).color || '').trim();
281
+ probe.remove();
282
+ return computed;
283
+ }
284
+
285
+ /**
286
+ * Host-controlled chrome: a `theme` attribute ("light"/"dark") wins over
287
+ * whatever chrome was persisted, and while it's present the built-in
288
+ * light/dark toggle is hidden (see renderInner) -- the host owns the
289
+ * control, so the two can never fight. Removing the attribute hands
290
+ * control back to the built-in toggle.
291
+ */
292
+ applyTheme() {
293
+ const v = this.getAttribute('theme');
294
+ if (v) this.core.setState({ chrome: v === 'dark' ? 'dark' : 'light' });
295
+ else if (this.core.state) this.core.emit();
296
+ }
297
+
298
+ /**
299
+ * Resolves the `locale` attribute to its shipped message table
300
+ * (core/i18n/tables.js) and overlays any host-set `.messages` on top --
301
+ * attribute picks the language, `.messages` stays the documented way to
302
+ * override individual strings. With no locale attribute this degrades to
303
+ * the old behavior: `.messages` alone over built-in English.
304
+ */
305
+ refreshTranslator() {
306
+ const table = LOCALE_TABLES[this.getAttribute('locale')] || null;
307
+ const overrides = this.core.messages;
308
+ this.core.t = createTranslator(table ? Object.assign({}, table, overrides || {}) : overrides);
309
+ this.story?.retranslate();
310
+ this.core.emit();
311
+ }
312
+
313
+ /** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it never affects rendered content, only the CSS `dir`). */
314
+ applyDir() {
315
+ if (!this.mc) return;
316
+ const explicit = this.getAttribute('dir');
317
+ this.mc.setAttribute('dir', explicit || (isRtl(this.getAttribute('locale')) ? 'rtl' : 'ltr'));
318
+ }
319
+
320
+ // ---- public API ----------------------------------------------------
321
+
322
+ /**
323
+ * Internal, and deliberately undocumented for hosts: the document object is
324
+ * this package's own representation, not a format anyone else should have to
325
+ * store, version or migrate. The host-facing contract is HTML in, HTML out
326
+ * (`loadTemplate({ html })` / `exportHtml()`), and exported HTML is valid
327
+ * input to the importer, so round-tripping through it is how a draft is
328
+ * saved and restored.
329
+ *
330
+ * These stay because the editor needs them internally (undo, autosave, the
331
+ * tests) and because a host that has genuinely outgrown HTML has somewhere
332
+ * to go -- but nothing in README.md points here, and the shape is free to
333
+ * change between versions.
334
+ */
335
+ getContent() { return JSON.parse(JSON.stringify(this.core.state.doc)); }
336
+ setContent(doc) { this.core.loadDoc(doc); }
337
+ /** Parses arbitrary HTML (a full email, a fragment, MailCraft's own exported output) into native blocks wherever the shape is recognizable, replacing the current document. See `core/import-html.js` for the per-shape rules. */
338
+ importHtml(html) { return this.core.importHtml(html); }
339
+ get variables() { return this.core.vars(); }
340
+ set variables(value) { this.core.variablesRaw = value; this.core.emit(); }
341
+
342
+ /**
343
+ * Which parts of the editor's own top bar are shown -- `false` for no bar at
344
+ * all, or an object switching individual parts off:
345
+ *
346
+ * editor.toolbar = false; // no bar
347
+ * editor.toolbar = { logo: false, ai: false }; // keep the rest
348
+ * <mailcraft-editor toolbar="none"> // markup, no bar
349
+ * <mailcraft-editor toolbar="undo,redo,export"> // markup, only these
350
+ *
351
+ * Switchable parts: logo, status, device, undo, redo, theme, ai, code,
352
+ * preview, export. Hiding a control does not remove the capability -- every
353
+ * one of them is also a method here, so a host that renders its own bar
354
+ * keeps all of it. Keyboard shortcuts are unaffected either way.
355
+ *
356
+ * The property wins over the attribute once set, the same way `.messages`
357
+ * wins over `locale`.
358
+ */
359
+ get toolbar() { return this._toolbar !== undefined ? this._toolbar : (this.getAttribute('toolbar') || ''); }
360
+ set toolbar(value) { this._toolbar = value; this.applyToolbar(); }
361
+
362
+ /** The resolved `{ item: boolean }` map, or null when there is to be no bar. */
363
+ toolbarConfig() { return resolveToolbar(this._toolbar !== undefined ? this._toolbar : this.getAttribute('toolbar')); }
364
+
365
+ /**
366
+ * Rebuilds the shell when, and only when, the resolved config actually
367
+ * changed. The header is built once (it is not part of the per-state render
368
+ * path), so switching a part on or off has to go through buildShell -- and
369
+ * that also re-creates every modal and the story viewer, which is not
370
+ * something to do on a set that changes nothing.
371
+ */
372
+ applyToolbar() {
373
+ if (!this.core || !this.shadowRoot.firstChild) return;
374
+ const key = toolbarKey(this.toolbarConfig());
375
+ if (key === this._toolbarKey) return;
376
+ this._toolbarKey = key;
377
+ this.buildShell();
378
+ this.applyUiFont();
379
+ this.applyAccent();
380
+ this.applyDir();
381
+ this.applyTheme();
382
+ this.render();
383
+ }
384
+
385
+ /**
386
+ * The attribution strip: `false`/`"none"` removes it, a string replaces the
387
+ * line, `{ text, href, target }` gives it a link. Unset shows the built-in
388
+ * "Powered by SELISE Blocks" line, which follows `locale` and `.messages`
389
+ * (key `footer.poweredBy`) like every other label. See core/footer.js.
390
+ */
391
+ get footer() { return this._footer !== undefined ? this._footer : (this.getAttribute('footer') || ''); }
392
+ set footer(value) { this._footer = value; this.applyFooter(); }
393
+
394
+ get uiFont() { return this.getAttribute('ui-font') || ''; }
395
+ set uiFont(value) {
396
+ if (value == null || String(value).trim() === '') this.removeAttribute('ui-font');
397
+ else this.setAttribute('ui-font', String(value));
398
+ }
399
+
400
+ /** The host's brand color for the editor chrome -- a CSS color, `var(--your-token)`, or `inherit` (the host's `accent-color`). Setting '' or null hands the chrome back to the built-in accent. */
401
+ get accent() { return this.getAttribute('accent') || ''; }
402
+ set accent(value) {
403
+ if (value == null || String(value).trim() === '') this.removeAttribute('accent');
404
+ else this.setAttribute('accent', String(value));
405
+ }
406
+
407
+ get aiProvider() { return this.core.aiProvider; }
408
+ set aiProvider(fn) { this.core.aiProvider = fn; }
409
+
410
+ /** UI string overrides -- an overlay on the built-in English table (or whichever locale table the host also passed in), never a replacement, so a partial translation shows English where it's incomplete rather than a gap. Import a locale from `core/i18n/<tag>.js`, or merge one with your own overrides via `defineMessages` (`core/i18n/index.js`). */
411
+ get messages() { return this.core.messages; }
412
+ set messages(value) { this.core.messages = value; this.refreshTranslator(); }
413
+
414
+ /** Optional `(platformKey, {label, size, color}) => Node` -- lets a host app supply its own social-icon art (e.g. brand SVGs) instead of the built-in set. Falls back to the built-in icon whenever it's unset, throws, or returns something that isn't a DOM node. */
415
+ get iconProvider() { return this.core.iconProvider; }
416
+ set iconProvider(fn) { this.core.iconProvider = fn; this.core.emit(); }
417
+
418
+ /**
419
+ * Host-supplied file storage -- see `core/storage.js` for the contract, and
420
+ * README.md for a worked example. Setting one replaces the seeded demo
421
+ * library with the backend's own: folders, paging,
422
+ * upload and delete all go through it, and nothing is written to
423
+ * localStorage. Set `null` to go back to the built-in behaviour.
424
+ */
425
+ get storageProvider() { return this.core.storageProvider; }
426
+ set storageProvider(provider) { this.core.setStorageProvider(provider); }
427
+
428
+ /**
429
+ * What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
430
+ * Required alongside a provider and deliberately undefaulted -- the ceilings
431
+ * that suit an email template depend on the sending platform, the audience's
432
+ * mail clients and the host's own rules, none of which this package can know.
433
+ * See `core/storage-limits.js`.
434
+ */
435
+ get storageLimits() { return this.core.storageLimits; }
436
+ set storageLimits(limits) { this.core.setStorageLimits(limits); }
437
+
438
+ /**
439
+ * Applies one host-supplied template as an undoable edit. Template galleries
440
+ * are host UI -- the editor has no tab for them and ships no catalogue --
441
+ * so this is the whole seam: the host renders its own picker and pushes the
442
+ * chosen template in. `tpl` carries one of three sources: `doc` (a document
443
+ * object), `build()` (one made per use), or `html` (a raw email string, run
444
+ * through the importer). `examples/templates/` holds ready-made HTML ones.
445
+ * The other direction is `getContent()`, to capture the current document
446
+ * back out as a template of the host's own.
447
+ */
448
+ loadTemplate(tpl) { this.core.loadTemplate(tpl); }
449
+
450
+ exportHtml() {
451
+ const html = this.core.buildHtml();
452
+ this.dispatchEvent(new CustomEvent('export', { detail: html }));
453
+ return html;
454
+ }
455
+
456
+ /** Full-template screenshot as a PNG Blob (2x resolution, desktop width, independent of the current device/zoom view). See render/screenshot.js for the technique and its limits. */
457
+ screenshotPng() { return captureTemplatePng(this.core, this.mc); }
458
+
459
+ /** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. Nothing touches the filesystem until the user picks Download inside it. */
460
+ previewScreenshot() { this.story.open(); }
461
+
462
+ /** `screenshotPng()` plus the same save-a-file flow as the HTML export download, with success/failure toasts. Pass an already-captured blob (the story viewer does) to save that one instead of rendering a second time. */
463
+ async downloadScreenshot(blob) {
464
+ const t = this.core.t;
465
+ try {
466
+ const png = blob || await this.screenshotPng();
467
+ const a = document.createElement('a');
468
+ a.href = URL.createObjectURL(png);
469
+ a.download = 'email.png';
470
+ a.click();
471
+ setTimeout(() => URL.revokeObjectURL(a.href), 1500);
472
+ this.core.flash(t('toast.pngSaved'));
473
+ } catch {
474
+ this.core.flash(t('toast.pngFailed'));
475
+ }
476
+ }
477
+
478
+ /** The screenshot onto the system clipboard as an image. Needs a secure context and `ClipboardItem`; where either is missing this reports the failure rather than appearing to succeed. */
479
+ async copyScreenshot(blob) {
480
+ const t = this.core.t;
481
+ try {
482
+ const png = blob || await this.screenshotPng();
483
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
484
+ this.core.flash(t('toast.pngCopied'));
485
+ } catch {
486
+ this.core.flash(t('toast.pngCopyFailed'));
487
+ }
488
+ }
489
+
490
+ undo() { this.core.undo(); }
491
+ redo() { this.core.redo(); }
492
+
493
+ // ---- shell (built once) ---------------------------------------------
494
+
495
+ buildShell() {
496
+ const root = this.shadowRoot;
497
+ root.innerHTML = '';
498
+ root.appendChild(elS('style', '', { text: STYLE }));
499
+
500
+ // A rebuild throws away the `#mc` that carried the inline accent tokens,
501
+ // so the memo of what was last applied has to go with it.
502
+ this._accentKey = null;
503
+ this.mc = elS('div', 'height: 100%; padding: 16px; box-sizing: border-box; background: radial-gradient(circle at 8% 0%, var(--ed-soft), transparent 28%), var(--ed-bg); color: var(--ed-text); font-family: var(--ed-font); font-size: 14px; overflow-x: auto; overflow-y: hidden;', { id: 'mc' });
504
+ root.appendChild(this.mc);
505
+
506
+ const outer = elS('div', 'position: relative; height: 100%; min-width: 1180px;');
507
+ this.mc.appendChild(outer);
508
+
509
+ // Header nodes are re-created below (or not at all). Clearing them first
510
+ // means a rebuild that drops the bar cannot leave the refreshers pointing
511
+ // at detached nodes they would then keep writing to.
512
+ this.savedLabel = this.savedDot = this.deviceSeg = null;
513
+ this.undoBtn = this.redoBtn = this.chromeBtn = null;
514
+ this.aiBtn = this.codeBtn = this.previewBtn = this.exportBtn = null;
515
+
516
+ const bar = this.toolbarConfig();
517
+ this.frame = elS('div', 'position: relative; height: 100%; border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); background: var(--ed-panel); display: grid; grid-template-rows: ' + (bar ? '54px minmax(0, 1fr)' : 'minmax(0, 1fr)') + '; overflow: hidden; box-shadow: var(--ed-shadow-sm), var(--ed-shadow-md);', { class: 'mc-shell' + (bar ? '' : ' mc-no-header') });
518
+ outer.appendChild(this.frame);
519
+
520
+ if (bar) this.frame.appendChild(this.buildHeader(bar));
521
+
522
+ this.footerBar = this.footerText = this.footerLink = null;
523
+
524
+ this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0;', { class: 'mc-layout' });
525
+ this.frame.appendChild(this.body);
526
+ this.body.appendChild(this.buildMain());
527
+ this.body.appendChild(this.buildAside());
528
+ this.body.appendChild(this.buildFooter());
529
+
530
+ this.libraryModal = this.buildLibraryModal();
531
+ this.exportModal = this.buildExportModal();
532
+ this.codeModal = this.buildCodeModal();
533
+ this.aiModal = this.buildAiModal();
534
+ this.previewModal = this.buildPreviewModal();
535
+ // Not a state-driven modal like the others: the story viewer is opened
536
+ // imperatively and runs its own timers, so an editor re-render behind it
537
+ // (an autosave, a toast) can never interrupt playback mid-screen.
538
+ this.story = createStoryViewer(this.core, {
539
+ capture: () => this.screenshotPng(),
540
+ download: (blob) => this.downloadScreenshot(blob),
541
+ copy: (blob) => this.copyScreenshot(blob),
542
+ });
543
+ this.toastEl = elS('div', "position: absolute; bottom: 74px; left: 50%; transform: translateX(-50%); z-index: 80; background: var(--ed-text); color: var(--ed-bg); padding: 9px 16px; border-radius: var(--ed-radius-sm); font-family: ui-monospace, monospace; font-size: 10.5px; letter-spacing: 0.08em; box-shadow: var(--ed-shadow-md); animation: mcIn 0.18s ease; display: none;", { class: 'mc-toast' });
544
+ outer.append(this.libraryModal, this.exportModal, this.codeModal, this.aiModal, this.previewModal, this.story.node, this.toastEl);
545
+
546
+ this.fileInput = elS('input', 'display: none;', { type: 'file', accept: 'image/*', multiple: 'true' });
547
+ this.fileInput.addEventListener('change', (e) => { this.core.addFiles(e.target.files); e.target.value = ''; });
548
+ outer.appendChild(this.fileInput);
549
+ }
550
+
551
+ // ---- header -----------------------------------------------------------
552
+
553
+ buildHeader(on) {
554
+ const t = this.core.t;
555
+ const header = elS('header', "display: flex; align-items: center; gap: 14px; padding: 0 16px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2)); position: relative; z-index: 30;", { class: 'mc-header' });
556
+
557
+ const brandMark = elS('span', '', { class: 'mc-brand-mark' });
558
+ const markSvg = icon('mailSpark', 20);
559
+ markSvg.setAttribute('stroke-width', '1.8');
560
+ brandMark.appendChild(markSvg);
561
+ const brand = elS('div', 'display: flex; align-items: center; gap: 9px;');
562
+ brand.append(
563
+ brandMark,
564
+ elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; letter-spacing: -0.01em; line-height: 1;', { text: 'MailCraft', class: 'mc-brand-name' }),
565
+ );
566
+ if (on.logo) header.append(brand, elS('div', 'width: 1px; height: 22px; background: var(--ed-line);'));
567
+
568
+ this.savedLabel = elS('span', 'display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
569
+ // No infinite pulse: a forever-running animation keeps the compositor
570
+ // producing frames while the editor is idle -- constant CPU on low-end
571
+ // hardware for a decorative effect. A solid dot still reads "autosave on".
572
+ this.savedDot = elS('span', 'width: 5px; height: 5px; border-radius: 50%; background: var(--ed-accent);');
573
+ this.savedLabel.appendChild(this.savedDot);
574
+ this.savedLabel.appendChild(document.createTextNode(''));
575
+ if (on.status) header.appendChild(this.savedLabel);
576
+ else this.savedLabel = this.savedDot = null;
577
+
578
+ header.appendChild(elS('div', 'flex: 1;'));
579
+
580
+ if (on.device) {
581
+ this.deviceSeg = elS('div', '', { class: 'mc-segment mc-device-segment' });
582
+ header.appendChild(this.deviceSeg);
583
+ }
584
+
585
+ const iconButtons = elS('div', 'display: flex; align-items: center; gap: 3px;');
586
+ if (on.undo) this.undoBtn = this.iconBtn('undo', t('action.undoHint'), () => this.core.undo(), 'action.undoHint');
587
+ if (on.redo) this.redoBtn = this.iconBtn('redo', t('action.redoHint'), () => this.core.redo(), 'action.redoHint');
588
+ if (on.theme) this.chromeBtn = this.labelIconBtn('moon', t('action.chromeToDark'), t('action.chromeHint'), () => {
589
+ const chrome = this.core.state.chrome === 'light' ? 'dark' : 'light';
590
+ this.core.setState({ chrome }, () => this.core.persist(null, null, chrome));
591
+ }, 'action.chromeHint');
592
+ if (this.chromeBtn) {
593
+ this.chromeBtn.i18nTipKey = this.core.state.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
594
+ this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
595
+ }
596
+ if (on.ai) this.aiBtn = this.labelIconBtn('spark', t('action.aiDraft'), t('action.aiDraftHint'), () => this.core.setState({ aiOpen: true }), 'action.aiDraftHint', 'action.aiDraft');
597
+ if (on.code) this.codeBtn = this.labelIconBtn('code', t('action.code'), t('action.codeHint'), () => this.core.openCode(), 'action.codeHint', 'action.code');
598
+ if (on.preview) this.previewBtn = this.labelIconBtn('eye', t('action.preview'), t('action.previewHint') || '', () => this.core.setState({ previewOpen: true }), 'action.previewHint', 'action.preview');
599
+ if (on.export) this.exportBtn = this.labelIconBtn('download', t('action.export'), t('action.exportHint'), () => this.core.openExport(), 'action.exportHint', 'action.export');
600
+
601
+ const actions = [this.undoBtn, this.redoBtn, this.chromeBtn, this.aiBtn, this.codeBtn, this.previewBtn, this.exportBtn].filter(Boolean);
602
+ if (actions.length) {
603
+ iconButtons.append(...actions);
604
+ header.appendChild(iconButtons);
605
+ }
606
+ return header;
607
+ }
608
+
609
+ /** `titleKey` (when given) is written as `data-i18n-title` so `refreshStrings()` keeps the tooltip in sync with the active translator after `.messages` changes post-mount -- `title` itself only sets the initial value. */
610
+ iconBtn(name, title, onClick, titleKey) {
611
+ const btn = elS('button', 'border: 1px solid var(--ed-line); background: var(--ed-panel); color: var(--ed-text); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title, 'data-i18n-title': titleKey || undefined, class: 'mc-icon-button' });
612
+ btn.appendChild(icon(name, 15));
613
+ btn.addEventListener('mouseenter', () => { if (!btn.disabled) { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; } });
614
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel)'; });
615
+ btn.addEventListener('click', onClick);
616
+ tip(btn, title, 'down');
617
+ return btn;
618
+ }
619
+
620
+ /**
621
+ * Always creates a real text node for the label (even if empty) and exposes it as `btn.labelNode`, so a caller that updates the label later never has to guess at `lastChild` -- guessing wrong there once already corrupted the chrome-toggle icon (its `lastChild` was the `<svg>`, and `.textContent = ...` on it wiped out the icon's paths).
622
+ * `labelKey`/`titleKey` (when given) are stashed on the button itself (not as `data-i18n`, since a generic textContent sweep would wipe the icon the same way `lastChild` guessing used to) so `refreshStrings()` can target `labelNode`/`title` directly.
623
+ */
624
+ labelIconBtn(iconName, label, title, onClick, titleKey, labelKey) {
625
+ const btn = elS('button', 'border: 1px solid var(--ed-line); background: var(--ed-panel); color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 10px; display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: border-color 0.16s, background 0.16s;', { type: 'button', title, class: 'mc-icon-label' });
626
+ btn.i18nTitleKey = titleKey || null;
627
+ btn.i18nLabelKey = labelKey || null;
628
+ if (iconName) {
629
+ btn.iconNode = icon(iconName, 14);
630
+ btn.iconName = iconName;
631
+ btn.appendChild(btn.iconNode);
632
+ }
633
+ btn.labelNode = document.createTextNode(label || '');
634
+ btn.appendChild(btn.labelNode);
635
+ btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
636
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel)'; });
637
+ btn.addEventListener('click', onClick);
638
+ return tip(btn, title, 'down');
639
+ }
640
+
641
+ // ---- shared panel-design helpers (flat-accordion system) ----------------
642
+
643
+ /**
644
+ * Flat-accordion section chrome, identical to the Design/Settings tabs'
645
+ * (render/fields.js renderFieldCards): a full-width uppercase bar and a
646
+ * body directly on the panel surface. This replaced the floating white
647
+ * cards on a gray gutter -- three panel languages across the tabs read as
648
+ * three different products, and the card-in-card nesting was exactly the
649
+ * boxes-in-boxes the inspector redesign removed. `right` (element or
650
+ * string) sits flush right for counts/meta. The mc-section-* classes keep
651
+ * these restylable panel-wide (render/style.js).
652
+ */
653
+ sectionBar(label, right) {
654
+ const bar = elS('div', 'display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 11px 14px; background: var(--ed-panel-2); border-top: 1px solid var(--ed-line); border-bottom: 1px solid var(--ed-line); user-select: none;', { class: 'mc-section-bar' });
655
+ bar.appendChild(elS('span', 'font-family: var(--ed-font); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-muted);', { text: label, class: 'mc-section-label' }));
656
+ if (right) bar.appendChild(typeof right === 'string' ? elS('span', 'font-size: 10px; font-weight: 600; color: var(--ed-faint); flex: none;', { text: right }) : right);
657
+ return bar;
658
+ }
659
+
660
+ /** The section's content surface, matching renderFieldCards' body. */
661
+ sectionBody(extraStyle) {
662
+ return elS('div', 'padding: 12px 14px 16px; background: var(--ed-panel);' + (extraStyle || ''), { class: 'mc-section-body' });
663
+ }
664
+
665
+ /** Every non-field tab shares this shell: white panel to the bottom, sections stacked flush. */
666
+ tabSurface() {
667
+ return elS('div', 'background: var(--ed-panel); min-height: 100%; padding-bottom: 28px;', { class: 'mc-tab-surface' });
668
+ }
669
+
670
+ /** The soft accent action chip -- the panel's secondary button (field `btn` fields share the recipe in render/fields.js). */
671
+ softBtn(label, iconName, onClick) {
672
+ const btn = elS('button', 'width: 100%; border: 0; border-radius: 8px; background: var(--ed-soft); color: var(--ed-accent); cursor: pointer; padding: 8px 10px; display: flex; align-items: center; justify-content: center; gap: 6px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, color 0.16s;', { type: 'button' });
673
+ if (iconName) btn.appendChild(icon(iconName, 13));
674
+ btn.appendChild(elS('span', '', { text: label }));
675
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent-ink)'; });
676
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; });
677
+ btn.addEventListener('click', onClick);
678
+ return btn;
679
+ }
680
+
681
+ /** Show/hide the toast -- driven surgically by core.onToast so its 1.9s
682
+ * auto-dismissal never costs (or interrupts anything with) a full render. */
683
+ refreshToast() {
684
+ const s = this.core.state;
685
+ this.toastEl.style.display = s.toast ? 'block' : 'none';
686
+ this.toastEl.textContent = s.toast || '';
687
+ }
688
+
689
+ /** Repaint just the countdown digits on the canvas -- driven by the core's
690
+ * 1s tick (core.onTick), which used to re-render the whole editor. */
691
+ refreshCountdowns() {
692
+ const now = this.core.state.now;
693
+ this.canvasSlot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
694
+ const ms = Math.max(0, new Date(wrap.getAttribute('data-mc-countdown')).getTime() - now);
695
+ const vals = { days: Math.floor(ms / 86400000), hrs: Math.floor(ms / 3600000) % 24, min: Math.floor(ms / 60000) % 60, sec: Math.floor(ms / 1000) % 60 };
696
+ wrap.querySelectorAll('[data-mc-count]').forEach((n) => {
697
+ n.textContent = String(vals[n.getAttribute('data-mc-count')] ?? 0).padStart(2, '0');
698
+ });
699
+ });
700
+ }
701
+
702
+ /** The header's "Saved HH:MM" label -- autosave updates it through
703
+ * core.onSavedChange, surgically: it is the only thing autosave changes
704
+ * on screen, and a full render for it destroyed open dropdowns. */
705
+ refreshSavedLabel() {
706
+ if (!this.savedLabel) return;
707
+ const s = this.core.state; const t = this.core.t;
708
+ this.savedLabel.lastChild.textContent = s.savedStatus === 'error' ? t('status.saveFailed')
709
+ : s.savedStatus === 'saved' ? t('status.saved', { time: s.savedAt }) : t('status.autosaveOn');
710
+ this.savedDot.style.background = s.savedStatus === 'error' ? 'var(--ed-danger)' : s.savedStatus === 'saved' ? 'var(--ed-success)' : 'var(--ed-accent)';
711
+ this.savedLabel.style.color = s.savedStatus === 'error' ? 'var(--ed-danger)' : 'var(--ed-faint)';
712
+ }
713
+
714
+ /** Refresh only the floating rich-text toolbar after a caret/format change.
715
+ * The editable block itself remains mounted, so native selection, IME,
716
+ * pointer capture and scroll position stay untouched. */
717
+ scheduleRteRefresh() {
718
+ if (this._rteFrame != null) return;
719
+ this._rteFrame = requestAnimationFrame(() => {
720
+ this._rteFrame = null;
721
+ if (this.core.rendering || !this.core.state.editing) return;
722
+ const current = this.canvasSlot?.querySelector('[data-mc-rte="1"]');
723
+ const found = this.core.find(this.core.state.doc, this.core.state.editing);
724
+ if (!current || !found.block) return;
725
+ // Skip the rebuild when nothing the toolbar displays changed -- see
726
+ // formatFingerprint (editor-core.js). A drag selection fires this every
727
+ // frame, and rebuilding the toolbar each frame stuttered the selection.
728
+ const print = this.core.formatFingerprint(found.block);
729
+ if (print === this._rtePrint) return;
730
+ this._rtePrint = print;
731
+ current.replaceWith(renderRte(this.core, found.block));
732
+ });
733
+ }
734
+
735
+ // ---- footer (attribution strip) -----------------------------------------
736
+
737
+ /**
738
+ * Built once with the rest of the shell, then only refreshed -- like the
739
+ * header, it is not part of the state-driven render tree. Both a plain
740
+ * `<span>` and an `<a>` are created up front and toggled in `refreshFooter`:
741
+ * a host can add or drop a link at runtime, and swapping tag names mid-flight
742
+ * would strand whichever node the previous render had left in place.
743
+ */
744
+ buildFooter() {
745
+ this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; border-top: 1px solid var(--ed-line); background: var(--ed-panel-2); color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
746
+ this.footerText = elS('span', '', { class: 'mc-footer-text' });
747
+ this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
748
+ this.footerBar.appendChild(this.footerText);
749
+ this.footerBar.appendChild(this.footerLink);
750
+ return this.footerBar;
751
+ }
752
+
753
+ /** The resolved `{ text, href, target }`, or null when there is to be no strip. */
754
+ footerConfig() { return resolveFooter(this._footer !== undefined ? this._footer : this.getAttribute('footer')); }
755
+
756
+ /**
757
+ * Text, link and visibility in one pass, run every render: it is three
758
+ * property writes, and memoizing it would have to account for the
759
+ * translator too -- the default line follows `locale`/`.messages`, so a
760
+ * config-shaped memo would freeze the wrong string.
761
+ */
762
+ refreshFooter() {
763
+ if (!this.footerBar) return;
764
+ const cfg = this.footerConfig();
765
+ if (!cfg) { this.footerBar.style.display = 'none'; return; }
766
+ this.footerBar.style.display = 'flex';
767
+ this.footerBar.style.width = (this.core.state.device === 'mobile' ? 375 : this.core.state.doc.theme.width) + 'px';
768
+
769
+ const text = cfg.text == null ? this.core.t('footer.poweredBy') : cfg.text;
770
+ const linked = !!cfg.href;
771
+ this.footerText.style.display = linked ? 'none' : 'inline';
772
+ this.footerLink.style.display = linked ? 'inline' : 'none';
773
+ // The idle node is emptied, not just hidden: display:none still counts
774
+ // toward textContent, and a stale line there would reach anything reading
775
+ // the strip -- a screen reader's text pass included.
776
+ this.footerText.textContent = linked ? '' : text;
777
+ this.footerLink.textContent = linked ? text : '';
778
+ if (linked) {
779
+ this.footerLink.setAttribute('href', cfg.href);
780
+ // A footer link leaves the editor, and the editor is embedded in the
781
+ // host's page -- opening in place would take the user's unsaved work
782
+ // with it. `rel` goes with the new tab, not as an afterthought.
783
+ this.footerLink.setAttribute('target', cfg.target || '_blank');
784
+ this.footerLink.setAttribute('rel', 'noopener noreferrer');
785
+ }
786
+ }
787
+
788
+ /** Re-reads the config after an attribute or property change. Never rebuilds: the strip is one node whose contents are cheap to rewrite. */
789
+ applyFooter() { this.refreshFooter(); }
790
+
791
+ // ---- main (canvas) ------------------------------------------------------
792
+
793
+ buildMain() {
794
+ const main = elS('main', '', { class: 'mc-workspace' });
795
+ this.mainEl = main;
796
+ main.style.cssText = 'grid-column: 1; grid-row: 1; position: relative; overflow-y: auto; min-height: 0;';
797
+ main.addEventListener('click', () => { if (this.core.state.sel) this.core.setState({ sel: null }); });
798
+
799
+ const pad = elS('div', 'padding: 28px 40px 150px; display: flex; justify-content: center;', { class: 'mc-canvas-stage' });
800
+ this.zoomFrame = elS('div', 'position: relative; transform-origin: top center; transition: transform 0.22s cubic-bezier(0.22,0.61,0.36,1);');
801
+ this.canvasSlot = elS('div', '', { class: 'mc-sheet-wrap' });
802
+ this.zoomFrame.appendChild(this.canvasSlot);
803
+ pad.appendChild(this.zoomFrame);
804
+ main.appendChild(pad);
805
+
806
+ const dock = elS('div', 'position: sticky; bottom: 0; display: flex; justify-content: center; pointer-events: none; padding-bottom: 16px;');
807
+ const pill = elS('div', 'pointer-events: auto; display: flex; align-items: center; gap: 12px; border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); background: var(--ed-panel); padding: 6px 8px 6px 12px; box-shadow: var(--ed-shadow-md);', { class: 'mc-zoom' });
808
+ this.canvasMetaEl = elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
809
+ this.zoomSeg = elS('div', 'display: flex; gap: 1px;');
810
+ pill.append(this.canvasMetaEl, elS('span', 'width: 1px; height: 18px; background: var(--ed-line);'), this.zoomSeg);
811
+ dock.appendChild(pill);
812
+ main.appendChild(dock);
813
+ return main;
814
+ }
815
+
816
+ // ---- aside (tabbed inspector) -------------------------------------------
817
+
818
+ buildAside() {
819
+ const aside = elS('aside', 'grid-column: 2; grid-row: 1 / span 2; border-left: 1px solid var(--ed-line); background: var(--ed-panel); display: grid; grid-template-rows: auto 1fr; min-height: 0;', { class: 'mc-inspector' });
820
+ this.tabBar = elS('div', 'display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr);', { class: 'mc-tabbar', role: 'tablist' });
821
+ aside.appendChild(this.tabBar);
822
+ // overflow-x hidden: `overflow-y: auto` alone computes overflow-x to
823
+ // auto, so anything a pixel too wide (a tooltip's hidden box, a long
824
+ // unbreakable value) grew a horizontal scrollbar under the panel.
825
+ this.tabBody = elS('div', 'overflow-y: auto; overflow-x: hidden; min-height: 0;');
826
+ aside.appendChild(this.tabBody);
827
+ return aside;
828
+ }
829
+
830
+ // ---- per-state render ---------------------------------------------------
831
+
832
+ render() {
833
+ // The RTE-edited block's own props (e.g. `props.html`) only update on
834
+ // blur (see `blockCtx.onBlur`, canvas.js) -- that's what lets typing stay
835
+ // fast (no per-keystroke commit/undo-history entry). But a render still
836
+ // rebuilds that block's DOM node from `props.html` regardless, and
837
+ // renders can happen while an edit is active (for example when another
838
+ // editor control changes) -- so without syncing first, the next
839
+ // keystroke's re-render would blow away whatever was just typed, reverting
840
+ // it to the last-committed (pre-edit) value. Sync copies the live,
841
+ // uncommitted DOM content into the model right before the rebuild reads
842
+ // it -- a plain mutation, not `setProp`, so it doesn't push an undo entry.
843
+ this.syncLiveEdit();
844
+ // Marks the canvas rebuild below so `blockCtx.onBlur` (canvas.js) can tell
845
+ // a real blur apart from the spurious one fired when this method removes
846
+ // the still-focused block node while tearing down `canvasSlot`.
847
+ this.core.rendering = true;
848
+ // The full render below rebuilds the RTE toolbar on its own path, so the
849
+ // fingerprint that lets scheduleRteRefresh skip no-op rebuilds is stale.
850
+ this._rtePrint = null;
851
+ // The workspace <main> and the inspector tab body are the two scroll
852
+ // containers that survive a render (built once in buildShell) -- but the
853
+ // rebuild below can still zero them: if anything forces synchronous
854
+ // layout while a slot sits momentarily empty, the container's
855
+ // scrollHeight collapses and the browser clamps its scrollTop to 0, and
856
+ // re-appending the new tree never scrolls back. On a long template that
857
+ // read as "click a text block (or touch the RTE at all) and the canvas
858
+ // yanks to the top". Snapshot both here and put them back once the new
859
+ // tree is in place -- focus restoration (focus-preserve.js) already uses
860
+ // `preventScroll`, so nothing later in the same render fights this.
861
+ const mainTop = this.mainEl ? this.mainEl.scrollTop : 0;
862
+ const mainLeft = this.mainEl ? this.mainEl.scrollLeft : 0;
863
+ const tabBefore = this._renderedTab;
864
+ const tabTop = this.tabBody ? this.tabBody.scrollTop : 0;
865
+ try {
866
+ this.renderInner();
867
+ } finally {
868
+ this.core.rendering = false;
869
+ }
870
+ if (this.mainEl) {
871
+ if (this.mainEl.scrollTop !== mainTop) this.mainEl.scrollTop = mainTop;
872
+ if (this.mainEl.scrollLeft !== mainLeft) this.mainEl.scrollLeft = mainLeft;
873
+ }
874
+ // A genuine tab switch should start at the top -- only same-tab rebuilds
875
+ // (e.g. per-keystroke commits from a Design-tab field) restore.
876
+ if (this.tabBody && this.core.state.tab === tabBefore && this.tabBody.scrollTop !== tabTop) this.tabBody.scrollTop = tabTop;
877
+ this._renderedTab = this.core.state.tab;
878
+ }
879
+
880
+ syncLiveEdit() {
881
+ const c = this.core;
882
+ if (!c.state.editing || !c.editEl || !c.editEl.isConnected || !c.editKey) return;
883
+ const found = c.find(c.state.doc, c.state.editing);
884
+ if (!found.block) return;
885
+ const val = c.editPlain ? c.editEl.textContent : c.editEl.innerHTML;
886
+ if (val !== found.block.props[c.editKey]) found.block.props[c.editKey] = val;
887
+ }
888
+
889
+ /** Refreshes every static header/modal string tagged `data-i18n*` at build time, plus the handful of labelIconBtn buttons whose label lives in a `labelNode` text node (a generic textContent sweep would wipe their icon -- see `labelIconBtn`'s doc comment). Runs every render (cheap: property assignments only, no rebuild) so a `.messages`/`.locale` change after mount relabels chrome that was only ever built once. */
890
+ refreshStrings() {
891
+ const t = this.core.t;
892
+ const root = this.shadowRoot;
893
+ root.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.getAttribute('data-i18n')); });
894
+ root.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.getAttribute('data-i18n-title')); });
895
+ root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); });
896
+ [this.aiBtn, this.codeBtn, this.previewBtn, this.exportBtn].forEach((btn) => {
897
+ if (!btn) return;
898
+ if (btn.i18nTitleKey) btn.title = t(btn.i18nTitleKey);
899
+ if (btn.i18nLabelKey) btn.labelNode.textContent = t(btn.i18nLabelKey);
900
+ if (btn.i18nTitleKey && btn.tipNode) btn.tipNode.textContent = t(btn.i18nTitleKey);
901
+ });
902
+ if (this.chromeBtn?.i18nTitleKey) this.chromeBtn.title = t(this.chromeBtn.i18nTitleKey);
903
+ }
904
+
905
+ renderInner() {
906
+ const s = this.core.state;
907
+ const t = this.core.t;
908
+ this.refreshStrings();
909
+
910
+ this.mc.dataset.chrome = s.chrome;
911
+ // Re-derives only when the brand color or the chrome actually changed --
912
+ // the accent that clears contrast on white panels is not the one that
913
+ // clears it on the dark palette.
914
+ this.applyAccent();
915
+ this.refreshFooter();
916
+ this.refreshSavedLabel();
917
+
918
+ if (this.undoBtn) this.undoBtn.disabled = !s.history.length;
919
+ if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
920
+ // A host-supplied `theme` attribute owns light/dark (see applyTheme) --
921
+ // hide the built-in toggle rather than let it fight the host's control.
922
+ if (this.chromeBtn) {
923
+ this.chromeBtn.style.display = this.hasAttribute('theme') ? 'none' : 'flex';
924
+ this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
925
+ this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
926
+ this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
927
+ this.chromeBtn.setAttribute('aria-label', t(this.chromeBtn.i18nTipKey));
928
+ this.chromeBtn.title = t(this.chromeBtn.i18nTipKey);
929
+ const chromeIcon = s.chrome === 'light' ? 'moon' : 'sun';
930
+ if (this.chromeBtn.iconName !== chromeIcon) {
931
+ const nextIcon = icon(chromeIcon, 14);
932
+ this.chromeBtn.iconNode.replaceWith(nextIcon);
933
+ this.chromeBtn.iconNode = nextIcon;
934
+ this.chromeBtn.iconName = chromeIcon;
935
+ }
936
+ }
937
+
938
+ this.renderDeviceSeg();
939
+ this.renderZoomSeg();
940
+
941
+ this.zoomFrame.style.transform = `scale(${s.zoom})`;
942
+ // Build the new canvas tree *before* detaching the old one, and swap the
943
+ // two atomically (`replaceChildren` evaluates its argument first): while
944
+ // a block is being edited, `renderDoc` builds the RTE toolbar mid-tree,
945
+ // and its `document.queryCommandState` calls force a synchronous layout.
946
+ // With the previous `innerHTML = ''`-then-append order, that layout ran
947
+ // while the slot was empty -- the workspace's scrollHeight collapsed for
948
+ // an instant and the browser clamped its scrollTop to 0, which is what
949
+ // scrolled a long template to the top on every RTE interaction.
950
+ this.canvasSlot.replaceChildren(renderDoc(this.core, true));
951
+
952
+ const blockCount = s.doc.rows.reduce((n, r) => n + r.cols.reduce((m, c) => m + c.blocks.length, 0), 0);
953
+ this.canvasMetaEl.textContent = t('canvas.meta', { rows: s.doc.rows.length, blocks: blockCount, width: s.device === 'mobile' ? 375 : s.doc.theme.width });
954
+
955
+ this.renderTabs();
956
+ this.renderTabBody();
957
+
958
+ this.renderLibraryModal();
959
+ this.renderExportModal();
960
+ this.renderCodeModal();
961
+ this.renderAiModal();
962
+ this.renderPreviewModal(false);
963
+
964
+ this.refreshToast();
965
+ }
966
+
967
+ segBg(on) { return on ? 'var(--ed-accent)' : 'transparent'; }
968
+ segFg(on) { return on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)'; }
969
+
970
+ renderDeviceSeg() {
971
+ if (!this.deviceSeg) return;
972
+ const s = this.core.state;
973
+ const t = this.core.t;
974
+ this.deviceSeg.innerHTML = '';
975
+ [
976
+ { label: t('device.desktop'), title: t('device.desktopHint'), v: 'desktop', iconName: 'monitor' },
977
+ { label: t('device.mobile'), title: t('device.mobileHint'), v: 'mobile', iconName: 'phone' },
978
+ ].forEach((d) => {
979
+ const on = s.device === d.v;
980
+ const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)}; transition: background 0.16s, color 0.16s;`, { type: 'button', title: d.title, 'aria-pressed': String(on) });
981
+ btn.appendChild(icon(d.iconName, 15));
982
+ btn.addEventListener('mouseenter', () => { if (!on) { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; } else { btn.style.background = 'var(--ed-accent-strong)'; } });
983
+ btn.addEventListener('mouseleave', () => { btn.style.background = this.segBg(on); btn.style.color = this.segFg(on); });
984
+ btn.addEventListener('click', () => this.core.setState({ device: d.v }));
985
+ tip(btn, d.label, 'down');
986
+ this.deviceSeg.appendChild(btn);
987
+ });
988
+ }
989
+
990
+ renderZoomSeg() {
991
+ const s = this.core.state;
992
+ this.zoomSeg.innerHTML = '';
993
+ [0.75, 1, 1.25].forEach((z) => {
994
+ const on = s.zoom === z;
995
+ const btn = elS('button', `border: 0; border-radius: 7px; cursor: pointer; background: ${this.segBg(on)}; color: ${this.segFg(on)}; font-family: var(--ed-font); font-size: 10px; font-weight: 600; padding: 5px 9px; transition: background 0.16s, color 0.16s;`, { type: 'button', text: `${Math.round(z * 100)}%`, 'aria-pressed': String(on) });
996
+ btn.addEventListener('click', () => this.core.setState({ zoom: z }));
997
+ this.zoomSeg.appendChild(btn);
998
+ });
999
+ }
1000
+
1001
+ renderTabs() {
1002
+ const s = this.core.state;
1003
+ this.tabBar.innerHTML = '';
1004
+ tabsFor(this.core.t).forEach((tabDef) => {
1005
+ const on = s.tab === tabDef.key;
1006
+ const btn = elS('button', `border: 0; border-radius: 8px 8px 0 0; border-bottom: 2px solid ${on ? 'var(--ed-accent)' : 'transparent'}; background: ${on ? 'var(--ed-panel)' : 'transparent'}; color: ${on ? 'var(--ed-accent)' : 'var(--ed-muted)'}; cursor: pointer; height: 39px; display: flex; align-items: center; justify-content: center; transition: color 0.16s, background 0.16s;`, { type: 'button', title: tabDef.title, role: 'tab', 'aria-selected': String(on) });
1007
+ btn.appendChild(icon(tabDef.iconName, 16));
1008
+ btn.addEventListener('mouseenter', () => { if (!on) { btn.style.color = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; } });
1009
+ btn.addEventListener('mouseleave', () => { btn.style.color = on ? 'var(--ed-accent)' : 'var(--ed-muted)'; btn.style.background = on ? 'var(--ed-panel)' : 'transparent'; });
1010
+ btn.addEventListener('click', () => this.core.setState({ tab: tabDef.key }));
1011
+ tip(btn, tabDef.label, 'down');
1012
+ this.tabBar.appendChild(btn);
1013
+ });
1014
+ }
1015
+
1016
+ renderTabBody() {
1017
+ this.tabBody.innerHTML = '';
1018
+ const tab = this.core.state.tab;
1019
+ if (tab === 'design') this.tabBody.appendChild(this.renderDesignTab());
1020
+ else if (tab === 'blocks') this.tabBody.appendChild(this.renderBlocksTab());
1021
+ else if (tab === 'rows') this.tabBody.appendChild(this.renderRowsTab());
1022
+ else if (tab === 'files') this.tabBody.appendChild(this.renderFilesTab());
1023
+ else if (tab === 'layers') this.tabBody.appendChild(this.renderLayersTab());
1024
+ else if (tab === 'theme') this.tabBody.appendChild(this.renderThemeTab());
1025
+ else if (tab === 'data') this.tabBody.appendChild(this.renderDataTab());
1026
+ }
1027
+
1028
+ // ---- Design tab ----------------------------------------------------------
1029
+
1030
+ renderDesignTab() {
1031
+ const s = this.core.state;
1032
+ const t = this.core.t;
1033
+ const selO = this.core.selObj();
1034
+ // min-height so the workspace-toned body paints to the bottom of the
1035
+ // panel even when the field list is short (tabBody gives the 100%).
1036
+ const wrap = elS('div', 'min-height: 100%; display: flex; flex-direction: column;', { class: 'mc-tab-surface mc-design-panel' });
1037
+ // "Card groups" inspector (the design direction the user picked): the
1038
+ // sticky header carries a block-type icon chip; the body sits on the
1039
+ // workspace tone with each property group rendered as a white card
1040
+ // (renderFieldCards, render/fields.js).
1041
+ const head = elS('div', 'padding: 12px 14px; border-bottom: 1px solid var(--ed-line); display: flex; align-items: center; gap: 10px; position: sticky; top: 0; background: var(--ed-panel); z-index: 5;', { class: 'mc-inspector-head' });
1042
+ if (s.sel) {
1043
+ const chip = elS('div', 'width: 36px; height: 36px; flex: none; border-radius: 10px; background: var(--ed-soft); color: var(--ed-accent); display: flex; align-items: center; justify-content: center;');
1044
+ chip.appendChild(icon(selO.block ? selO.block.type : 'product', 18));
1045
+ head.appendChild(chip);
1046
+ }
1047
+ const headText = elS('div', 'flex: 1; min-width: 0;');
1048
+ headText.append(
1049
+ elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: selO.block ? DEF(selO.block.type).label : (selO.row ? t('inspector.sectionLabel') : t('inspector.nothingSelected')) }),
1050
+ elS('div', 'font-size: 11px; color: var(--ed-muted); margin-top: 1px;', { text: selO.block ? t('inspector.blockProperties') : (selO.row ? t('inspector.sectionProperties') : t('inspector.title')) }),
1051
+ );
1052
+ head.appendChild(headText);
1053
+ if (s.sel) {
1054
+ const actions = elS('div', 'display: flex; gap: 4px;');
1055
+ const dup = elS('button', 'border: 0; border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-muted); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: background 0.16s, color 0.16s;', { type: 'button', title: t('action.duplicateHint'), class: 'mc-icon-button' });
1056
+ dup.appendChild(icon('copy', 14));
1057
+ dup.addEventListener('mouseenter', () => { dup.style.background = 'var(--ed-soft)'; dup.style.color = 'var(--ed-accent)'; });
1058
+ dup.addEventListener('mouseleave', () => { dup.style.background = 'var(--ed-panel-2)'; dup.style.color = 'var(--ed-muted)'; });
1059
+ dup.addEventListener('click', () => this.core.dupSel());
1060
+ tip(dup, t('action.duplicateHint'), 'down', 'end');
1061
+ const del = elS('button', 'border: 0; border-radius: 8px; background: var(--ed-danger-soft); color: var(--ed-danger); cursor: pointer; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; transition: filter 0.16s;', { type: 'button', title: t('action.deleteHint'), class: 'mc-icon-button' });
1062
+ del.appendChild(icon('trash', 14));
1063
+ del.addEventListener('mouseenter', () => { del.style.filter = 'brightness(0.94)'; });
1064
+ del.addEventListener('mouseleave', () => { del.style.filter = ''; });
1065
+ del.addEventListener('click', () => this.core.delSel());
1066
+ tip(del, t('action.deleteHint'), 'down', 'end');
1067
+ actions.append(dup, del);
1068
+ head.appendChild(actions);
1069
+ }
1070
+ wrap.appendChild(head);
1071
+
1072
+ // Flat accordion surface (Beefree-style): the fields render straight on
1073
+ // the panel with full-width section bars -- no workspace-toned gutter,
1074
+ // no floating cards.
1075
+ const body = elS('div', 'background: var(--ed-panel); flex: 1; padding-bottom: 28px;', { class: 'mc-panel-content' });
1076
+ if (!s.sel) {
1077
+ const empty = elS('div', 'margin: 12px 14px; background: var(--ed-panel); border: 1px solid var(--ed-line); border-radius: 12px; padding: 14px; font-size: 12.5px; color: var(--ed-muted); line-height: 1.6;');
1078
+ const p = elS('p', 'margin: 0 0 14px;');
1079
+ p.innerHTML = t('inspector.emptyBody');
1080
+ empty.appendChild(p);
1081
+ const box = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: 10px; padding: 12px;', { class: 'mc-shortcuts' });
1082
+ box.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); margin-bottom: 8px;', { text: t('inspector.shortcuts') }));
1083
+ const grid = elS('div', 'display: grid; gap: 5px; font-family: ui-monospace, monospace; font-size: 10.5px; color: var(--ed-muted);');
1084
+ [t('shortcut.undoRedo'), t('shortcut.duplicate'), t('shortcut.delete'), t('shortcut.export'), t('shortcut.escape')].forEach((txt) => grid.appendChild(elS('div', '', { text: txt })));
1085
+ box.appendChild(grid);
1086
+ empty.appendChild(box);
1087
+ body.appendChild(empty);
1088
+ }
1089
+ const fields = this.core.fields().concat(this.core.boxFields());
1090
+ renderFieldCards(body, fields);
1091
+ wrap.appendChild(body);
1092
+ return wrap;
1093
+ }
1094
+
1095
+ // ---- Blocks tab ------------------------------------------------------
1096
+
1097
+ renderBlocksTab() {
1098
+ const t = this.core.t;
1099
+ const wrap = this.tabSurface();
1100
+ wrap.appendChild(this.sectionBar(t('blocks.dragHint'), t('blocks.count', { count: PALETTE.length })));
1101
+ const body = this.sectionBody();
1102
+
1103
+ const grid = elS('div', 'display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;');
1104
+ paletteTiles(this.core).forEach((tile) => {
1105
+ const cell = elS('div', 'border: 1px solid var(--ed-line); border-radius: 10px; background: var(--ed-panel-2); cursor: grab; padding: 11px 4px 8px; display: flex; flex-direction: column; align-items: center; gap: 7px; user-select: none; transition: border-color 0.16s, transform 0.16s, background 0.16s, box-shadow 0.16s;', { draggable: 'true', title: tile.hint });
1106
+ const applyHover = () => { cell.style.borderColor = 'var(--ed-accent)'; cell.style.background = 'var(--ed-soft)'; cell.style.transform = 'translateY(-2px)'; cell.style.boxShadow = 'var(--ed-shadow-sm)'; };
1107
+ cell.addEventListener('mouseenter', applyHover);
1108
+ cell.addEventListener('mouseleave', () => { cell.style.borderColor = 'var(--ed-line)'; cell.style.background = 'var(--ed-panel-2)'; cell.style.transform = ''; cell.style.boxShadow = ''; });
1109
+ cell.addEventListener('mousedown', () => { cell.style.cursor = 'grabbing'; cell.style.transform = ''; });
1110
+ cell.addEventListener('mouseup', () => { cell.style.cursor = 'grab'; applyHover(); });
1111
+ cell.addEventListener('dragstart', tile.onDragStart);
1112
+ cell.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1113
+ cell.addEventListener('dblclick', tile.onAdd);
1114
+ const iconSpan = elS('span', 'color: var(--ed-accent); display: block;');
1115
+ iconSpan.appendChild(tile.icon());
1116
+ cell.appendChild(iconSpan);
1117
+ cell.appendChild(elS('span', 'font-size: 10.5px; color: var(--ed-muted); text-align: center; line-height: 1.2;', { text: tile.label }));
1118
+ grid.appendChild(cell);
1119
+ });
1120
+ body.appendChild(grid);
1121
+ wrap.appendChild(body);
1122
+ return wrap;
1123
+ }
1124
+
1125
+ // ---- Rows tab --------------------------------------------------------
1126
+
1127
+ renderRowsTab() {
1128
+ const s = this.core.state;
1129
+ const t = this.core.t;
1130
+ const wrap = this.tabSurface();
1131
+ wrap.appendChild(this.sectionBar(t('rows.sectionLayouts')));
1132
+ const layoutBody = this.sectionBody();
1133
+
1134
+ const grid = elS('div', 'display: grid; gap: 6px;');
1135
+ LAYOUTS.forEach((l) => {
1136
+ const cell = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; background: var(--ed-panel-2); cursor: grab; padding: 10px; display: flex; align-items: center; gap: 12px; user-select: none; transition: border-color 0.16s, background 0.16s;', { draggable: 'true' });
1137
+ cell.addEventListener('mouseenter', () => { cell.style.borderColor = 'var(--ed-accent)'; cell.style.background = 'var(--ed-soft)'; });
1138
+ cell.addEventListener('mouseleave', () => { cell.style.borderColor = 'var(--ed-line)'; cell.style.background = 'var(--ed-panel-2)'; });
1139
+ cell.addEventListener('dragstart', this.core.startDrag({ kind: 'row', spans: l.spans }));
1140
+ cell.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1141
+ cell.addEventListener('dblclick', () => this.core.insertRow(l.spans, s.doc.rows.length));
1142
+ const cellsWrap = elS('div', 'display: flex; gap: 3px; flex: 1; height: 26px;');
1143
+ l.spans.forEach((sp) => cellsWrap.appendChild(elS('div', `flex: ${sp}; border: 1px solid var(--ed-line-2); background: repeating-linear-gradient(45deg, transparent, transparent 4px, var(--ed-line) 4px, var(--ed-line) 5px);`)));
1144
+ cell.append(cellsWrap, elS('span', 'font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-muted); white-space: nowrap;', { text: l.label }));
1145
+ grid.appendChild(cell);
1146
+ });
1147
+ layoutBody.appendChild(grid);
1148
+ wrap.appendChild(layoutBody);
1149
+
1150
+ wrap.appendChild(this.sectionBar(t('rows.customMarkup')));
1151
+ const markupBody = this.sectionBody();
1152
+ const htmlRow = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; background: var(--ed-panel-2); cursor: grab; padding: 11px 12px; display: flex; align-items: center; gap: 11px; user-select: none; transition: border-color 0.16s, background 0.16s;', { draggable: 'true', title: t('rows.htmlRowHint') });
1153
+ htmlRow.addEventListener('mouseenter', () => { htmlRow.style.borderColor = 'var(--ed-accent)'; htmlRow.style.background = 'var(--ed-soft)'; });
1154
+ htmlRow.addEventListener('mouseleave', () => { htmlRow.style.borderColor = 'var(--ed-line)'; htmlRow.style.background = 'var(--ed-panel-2)'; });
1155
+ htmlRow.addEventListener('dragstart', this.core.startDrag({ kind: 'row', spans: [100], html: true }));
1156
+ htmlRow.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1157
+ htmlRow.addEventListener('click', () => this.core.insertRow([100], s.doc.rows.length, true));
1158
+ const htmlIcon = elS('span', 'color: var(--ed-accent); display: block;');
1159
+ htmlIcon.appendChild(icon('code', 20));
1160
+ const htmlText = elS('span', 'flex: 1;');
1161
+ htmlText.append(
1162
+ elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 13px; line-height: 1.2;', { text: t('rows.rawHtmlSection') }),
1163
+ elS('span', 'display: block; font-size: 11px; color: var(--ed-muted); line-height: 1.4;', { text: t('rows.rawHtmlDesc') }),
1164
+ );
1165
+ htmlRow.append(htmlIcon, htmlText);
1166
+ markupBody.appendChild(htmlRow);
1167
+ wrap.appendChild(markupBody);
1168
+
1169
+ wrap.appendChild(this.sectionBar(t('rows.canvasModel')));
1170
+ const modelBody = this.sectionBody();
1171
+ const modeSeg = elS('div', '', { class: 'mc-segment' });
1172
+ [{ label: t('rows.modeRowBased'), v: 'rows' }, { label: t('rows.modeFreeStack'), v: 'stack' }].forEach((m) => {
1173
+ const on = s.mode === m.v;
1174
+ const btn = elS('button', `flex: 1; background: ${this.segBg(on)}; color: ${this.segFg(on)}; font-family: var(--ed-font); font-size: 10px; font-weight: 600; padding: 8px 4px; transition: background 0.16s, color 0.16s;`, { type: 'button', text: m.label, 'aria-pressed': String(on) });
1175
+ btn.addEventListener('click', () => this.core.setState({ mode: m.v }));
1176
+ modeSeg.appendChild(btn);
1177
+ });
1178
+ modelBody.appendChild(modeSeg);
1179
+ const hint = s.mode === 'rows' ? t('rows.hintRows') : t('rows.hintStack');
1180
+ modelBody.appendChild(elS('p', 'margin: 10px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: hint }));
1181
+ wrap.appendChild(modelBody);
1182
+ return wrap;
1183
+ }
1184
+
1185
+ // ---- Files tab -------------------------------------------------------
1186
+
1187
+ renderFilesTab() {
1188
+ const s = this.core.state;
1189
+ const t = this.core.t;
1190
+ const wrap = this.tabSurface();
1191
+ wrap.appendChild(this.sectionBar(t('files.libraryCount', { count: s.assets.length })));
1192
+ const body = this.sectionBody();
1193
+ const openBtn = elS('button', 'width: 100%; border: 0; border-radius: 8px; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; padding: 9px 12px; display: flex; align-items: center; justify-content: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
1194
+ openBtn.append(icon('folder', 14), elS('span', '', { text: t('files.openManager') }));
1195
+ openBtn.addEventListener('mouseenter', () => { openBtn.style.filter = 'brightness(1.1)'; });
1196
+ openBtn.addEventListener('mouseleave', () => { openBtn.style.filter = ''; });
1197
+ openBtn.addEventListener('click', () => this.core.openLibrary());
1198
+ body.appendChild(openBtn);
1199
+
1200
+ const grid = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px;');
1201
+ s.assets.slice(0, 6).forEach((a) => {
1202
+ const tile = elS('div', 'border: 1px solid var(--ed-line); border-radius: 9px; overflow: hidden; cursor: grab; background: var(--ed-panel-2); transition: border-color 0.16s;', { draggable: 'true', title: a.name });
1203
+ tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--ed-accent)'; });
1204
+ tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; });
1205
+ tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
1206
+ tile.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1207
+ tile.appendChild(elS('div', `width: 100%; height: 64px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work);`, { role: 'img', 'aria-label': a.name }));
1208
+ tile.appendChild(elS('div', 'font-family: ui-monospace, monospace; font-size: 9px; color: var(--ed-muted); padding: 4px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
1209
+ grid.appendChild(tile);
1210
+ });
1211
+ body.appendChild(grid);
1212
+ body.appendChild(elS('p', 'margin: 12px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('files.dragToCanvas') }));
1213
+ wrap.appendChild(body);
1214
+ return wrap;
1215
+ }
1216
+
1217
+ // ---- Layers tab --------------------------------------------------------
1218
+
1219
+ renderLayersTab() {
1220
+ const s = this.core.state;
1221
+ const t = this.core.t;
1222
+ const wrap = this.tabSurface();
1223
+ wrap.appendChild(this.sectionBar(t('layers.structure')));
1224
+ const body = this.sectionBody('display: grid; gap: 0;');
1225
+ body.classList.add('mc-layer-tree');
1226
+ // Mirror the document model as a real tree: section -> column -> block.
1227
+ // Keeping columns visible matters for multi-column rows; flattening every
1228
+ // block beneath the section made the structure ambiguous.
1229
+ s.doc.rows.forEach((r, i) => {
1230
+ const rSel = s.sel && s.sel.id === r.id;
1231
+ const card = elS('div', 'background: transparent; padding: 0 0 8px;', { class: `mc-tree-section${rSel ? ' is-selected' : ''}` });
1232
+ const blockN = r.cols.reduce((n, c) => n + c.blocks.length, 0);
1233
+ const head = elS('div', `display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 6px 7px; border-radius: 7px; background: ${rSel ? 'var(--ed-soft)' : 'transparent'}; transition: background 0.16s;`, { class: 'mc-tree-node mc-tree-section-node' });
1234
+ head.addEventListener('mouseenter', () => { head.style.background = 'var(--ed-soft)'; });
1235
+ head.addEventListener('mouseleave', () => { head.style.background = rSel ? 'var(--ed-soft)' : 'transparent'; });
1236
+ head.addEventListener('click', () => this.core.select('row', r.id));
1237
+ head.append(
1238
+ elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-family: ui-monospace, monospace; font-size: 10px; font-weight: 600; background: ${rSel ? 'var(--ed-accent)' : 'var(--ed-panel-2)'}; color: ${rSel ? 'var(--ed-accent-ink)' : 'var(--ed-muted)'};`, { text: String(i + 1).padStart(2, '0'), class: 'mc-tree-index' }),
1239
+ elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; color: var(--ed-text);', { text: r.cols.length === 1 ? t('layers.sectionSingle') : t('layers.sectionMulti', { count: r.cols.length }), class: 'mc-tree-title' }),
1240
+ elS('span', 'flex: 1;'),
1241
+ elS('span', 'font-family: var(--ed-font); font-size: 10px; font-weight: 500; color: var(--ed-muted);', { text: t('layers.blockCount', { count: blockN }), class: 'mc-tree-count' }),
1242
+ );
1243
+ card.appendChild(head);
1244
+ const columns = elS('div', '', { class: 'mc-tree-children mc-tree-columns' });
1245
+ r.cols.forEach((c, colIndex) => {
1246
+ const branch = elS('div', '', { class: 'mc-tree-branch' });
1247
+ const colHead = elS('div', 'display: flex; align-items: center; gap: 7px; min-height: 28px; padding: 3px 7px; color: var(--ed-muted);', { class: 'mc-tree-node mc-tree-column-node' });
1248
+ const colChip = elS('span', 'width: 20px; height: 20px; flex: none; border-radius: 5px; display: flex; align-items: center; justify-content: center; background: var(--ed-panel-2); color: var(--ed-faint);');
1249
+ colChip.appendChild(icon('table', 11));
1250
+ colHead.append(
1251
+ colChip,
1252
+ elS('span', 'font-family: var(--ed-font); font-size: 11.5px; font-weight: 600;', { text: t('layers.column', { index: colIndex + 1 }), class: 'mc-tree-title' }),
1253
+ elS('span', 'flex: 1;'),
1254
+ elS('span', 'font-family: var(--ed-font); font-size: 10px; font-weight: 500; color: var(--ed-muted);', { text: t('layers.blockCount', { count: c.blocks.length }), class: 'mc-tree-count' }),
1255
+ );
1256
+ branch.appendChild(colHead);
1257
+ if (c.blocks.length) {
1258
+ const list = elS('div', '', { class: 'mc-tree-children mc-tree-blocks' });
1259
+ c.blocks.forEach((b) => {
1260
+ const bSel = s.sel && s.sel.id === b.id;
1261
+ const typeName = DEF(b.type).label;
1262
+ const text = b.type === 'text' ? String(b.props.html || '').replace(/<[^>]+>/g, '').trim().slice(0, 34) : (b.props.label || b.props.title || b.props.caption || '');
1263
+ const line = elS('div', `display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 4px 7px; border-radius: 8px; background: ${bSel ? 'var(--ed-soft)' : 'transparent'}; box-shadow: ${bSel ? 'inset 0 0 0 1px var(--ed-accent)' : 'none'}; transition: background 0.16s;`, { class: 'mc-tree-node mc-tree-block-node' });
1264
+ line.addEventListener('mouseenter', () => { if (!(s.sel && s.sel.id === b.id)) line.style.background = 'var(--ed-panel)'; });
1265
+ line.addEventListener('mouseleave', () => { line.style.background = bSel ? 'var(--ed-soft)' : 'transparent'; });
1266
+ line.addEventListener('click', () => this.core.select('block', b.id));
1267
+ const chip = elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: ${bSel ? 'var(--ed-accent)' : 'var(--ed-soft)'}; color: ${bSel ? 'var(--ed-accent-ink)' : 'var(--ed-accent)'};`);
1268
+ chip.appendChild(icon(b.type, 12));
1269
+ line.append(chip, elS('span', 'font-family: var(--ed-font); font-size: 12px; font-weight: 500; color: var(--ed-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: text || typeName, class: 'mc-tree-title' }));
1270
+ if (text) {
1271
+ line.append(
1272
+ elS('span', 'flex: 1; min-width: 8px;'),
1273
+ elS('span', 'flex: none; font-family: var(--ed-font); font-size: 9.5px; font-weight: 600; letter-spacing: 0.035em; text-transform: uppercase; color: var(--ed-muted);', { text: typeName, class: 'mc-tree-type' }),
1274
+ );
1275
+ }
1276
+ list.appendChild(line);
1277
+ });
1278
+ branch.appendChild(list);
1279
+ }
1280
+ columns.appendChild(branch);
1281
+ });
1282
+ card.appendChild(columns);
1283
+ body.appendChild(card);
1284
+ });
1285
+ wrap.appendChild(body);
1286
+ return wrap;
1287
+ }
1288
+
1289
+ // ---- Theme tab -------------------------------------------------------
1290
+
1291
+ renderThemeTab() {
1292
+ // Same flat-accordion treatment as the Design tab, so the two style
1293
+ // surfaces read as one system; the section bars carry the structure.
1294
+ const wrap = this.tabSurface();
1295
+ renderFieldCards(wrap, this.core.themeFields());
1296
+ return wrap;
1297
+ }
1298
+
1299
+ // ---- Data (variables) tab ----------------------------------------------
1300
+
1301
+ renderDataTab() {
1302
+ const s = this.core.state;
1303
+ const t = this.core.t;
1304
+ const wrap = this.tabSurface();
1305
+ wrap.appendChild(this.sectionBar(t('vars.title'), t('vars.fromCode', { count: this.core.vars().length })));
1306
+ const card = this.sectionBody();
1307
+ card.appendChild(elS('p', 'margin: 0 0 12px; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.declaredNote') }));
1308
+ const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query' });
1309
+ search.value = s.varQuery || '';
1310
+ search.addEventListener('focus', () => { search.style.borderColor = 'var(--ed-accent)'; search.style.outline = 'none'; });
1311
+ search.addEventListener('blur', () => { search.style.borderColor = 'var(--ed-line)'; });
1312
+ // Debounced: each setState is a full re-render (see typeCommit, fields.js).
1313
+ const varSearchCommit = typeCommit((v) => this.core.setState({ varQuery: v }));
1314
+ search.addEventListener('input', (e) => varSearchCommit.call(e.target.value));
1315
+ card.appendChild(search);
1316
+
1317
+ const all = this.core.vars();
1318
+ const filtered = all.filter((v) => !s.varQuery || v.toLowerCase().includes(String(s.varQuery).toLowerCase()));
1319
+ const grid = elS('div', 'display: grid; gap: 3px;');
1320
+ filtered.forEach((v) => {
1321
+ const name = v.replace(/[_.]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
1322
+ const btn = elS('button', 'display: flex; align-items: center; gap: 9px; text-align: left; border: 1px solid var(--ed-line); border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-text); cursor: pointer; padding: 8px 10px; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('vars.insertHint') });
1323
+ btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
1324
+ btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line)'; btn.style.background = 'var(--ed-panel-2)'; });
1325
+ btn.addEventListener('click', () => this.core.insertTag(v));
1326
+ const textSpan = elS('span', 'flex: 1; min-width: 0;');
1327
+ textSpan.append(
1328
+ elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; line-height: 1.2;', { text: name }),
1329
+ elS('span', 'display: block; font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-accent); margin-top: 2px;', { text: TOKEN(v) }),
1330
+ );
1331
+ btn.append(textSpan, elS('span', 'font-family: var(--ed-font); font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-faint);', { text: t('vars.insert') }));
1332
+ grid.appendChild(btn);
1333
+ });
1334
+ card.appendChild(grid);
1335
+ if (!filtered.length) {
1336
+ card.appendChild(elS('p', 'margin: 10px 0 0; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: all.length ? t('vars.noMatch') : t('vars.noneDeclared') }));
1337
+ }
1338
+ card.appendChild(elS('p', 'margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--ed-line); font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.rteHint') }));
1339
+ wrap.appendChild(card);
1340
+ return wrap;
1341
+ }
1342
+
1343
+ // ---- modals: library ----------------------------------------------------
1344
+
1345
+ buildLibraryModal() {
1346
+ const t = this.core.t;
1347
+ const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 34px; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
1348
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
1349
+ const modal = elS('div', 'width: 100%; height: 100%; max-width: 1000px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; display: grid; grid-template-rows: auto 1fr auto; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
1350
+ modal.addEventListener('click', (e) => e.stopPropagation());
1351
+ overlay.appendChild(modal);
1352
+
1353
+ const head = elS('div', 'display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
1354
+ this.libraryTitleEl = elS('div', 'flex: 1;');
1355
+ this.libraryTitleEl.append(
1356
+ elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.fileManager'), 'data-i18n': 'modal.fileManager' }),
1357
+ (this.libraryTitleText = elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;')),
1358
+ );
1359
+ this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query' });
1360
+ this.assetSearch.addEventListener('focus', () => { this.assetSearch.style.borderColor = 'var(--ed-accent)'; this.assetSearch.style.outline = 'none'; });
1361
+ this.assetSearch.addEventListener('blur', () => { this.assetSearch.style.borderColor = 'var(--ed-line)'; });
1362
+ // Debounced re-render; setAssetQuery separately debounces the backend call.
1363
+ const assetSearchCommit = typeCommit((v) => this.core.setAssetQuery(v));
1364
+ this.assetSearch.addEventListener('input', (e) => assetSearchCommit.call(e.target.value));
1365
+ const uploadBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 32px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
1366
+ uploadBtn.append(icon('upload', 14), elS('span', '', { text: t('action.upload'), 'data-i18n': 'action.upload' }));
1367
+ uploadBtn.addEventListener('mouseenter', () => { uploadBtn.style.filter = 'brightness(1.1)'; });
1368
+ uploadBtn.addEventListener('mouseleave', () => { uploadBtn.style.filter = ''; });
1369
+ uploadBtn.addEventListener('click', () => this.fileInput.click());
1370
+ const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
1371
+ closeBtn.appendChild(icon('x', 14));
1372
+ closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
1373
+ closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1374
+ closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1375
+ tip(closeBtn, t('action.close'), 'down', 'end');
1376
+ head.append(this.libraryTitleEl, this.assetSearch, uploadBtn, closeBtn);
1377
+ modal.appendChild(head);
1378
+
1379
+ const body = elS('div', 'display: grid; grid-template-columns: 186px 1fr; min-height: 0;');
1380
+ this.folderList = elS('div', 'border-right: 1px solid var(--ed-line); padding: 14px; overflow-y: auto;');
1381
+ const foldersHead = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); margin-bottom: 10px;', { text: t('library.folders'), 'data-i18n': 'library.folders' });
1382
+ this.folderGrid = elS('div', 'display: grid; gap: 2px;');
1383
+ this.folderList.append(foldersHead, this.folderGrid);
1384
+ this.assetGridWrap = elS('div', 'overflow-y: auto; padding: 16px; transition: background 0.16s;');
1385
+ this.assetGridWrap.addEventListener('dragover', (e) => { if (this.core.drag) return; e.preventDefault(); if (!this.core.state.libHot) this.core.setState({ libHot: true }); });
1386
+ this.assetGridWrap.addEventListener('drop', (e) => { e.preventDefault(); this.core.setState({ libHot: false }); if (e.dataTransfer?.files?.length) this.core.addFiles(e.dataTransfer.files); });
1387
+ this.assetStatus = elS('div', 'font-size: 12.5px; color: var(--ed-muted); padding: 0 2px 12px;');
1388
+ this.assetGrid = elS('div', 'display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 14px;');
1389
+ this.assetEmpty = elS('div', 'padding: 28px 2px; color: var(--ed-muted); font-size: 12.5px;', { text: t('library.empty'), 'data-i18n': 'library.empty' });
1390
+ this.assetMore = elS('button', 'margin-top: 16px; border: 0; border-radius: 8px; background: var(--ed-soft); color: var(--ed-accent); cursor: pointer; height: 30px; padding: 0 14px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, color 0.16s;', { type: 'button', text: t('library.loadMore'), 'data-i18n': 'library.loadMore' });
1391
+ this.assetMore.addEventListener('mouseenter', () => { this.assetMore.style.background = 'var(--ed-accent)'; this.assetMore.style.color = 'var(--ed-accent-ink)'; });
1392
+ this.assetMore.addEventListener('mouseleave', () => { this.assetMore.style.background = 'var(--ed-soft)'; this.assetMore.style.color = 'var(--ed-accent)'; });
1393
+ this.assetMore.addEventListener('click', () => this.core.loadMoreAssets());
1394
+ this.assetGridWrap.append(this.assetStatus, this.assetGrid, this.assetEmpty, this.assetMore);
1395
+ this.assetGridWrap.appendChild(elS('div', 'margin-top: 20px; border: 1px dashed var(--ed-line-2); padding: 22px; text-align: center; color: var(--ed-muted); font-size: 12.5px;', { text: t('library.dropHint'), 'data-i18n': 'library.dropHint' }));
1396
+ body.append(this.folderList, this.assetGridWrap);
1397
+ modal.appendChild(body);
1398
+
1399
+ const foot = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); display: flex; justify-content: space-between; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
1400
+ this.libraryHintEl = elS('span');
1401
+ this.storageLabelEl = elS('span');
1402
+ foot.append(this.libraryHintEl, this.storageLabelEl);
1403
+ modal.appendChild(foot);
1404
+ return overlay;
1405
+ }
1406
+
1407
+ renderLibraryModal() {
1408
+ const s = this.core.state;
1409
+ const t = this.core.t;
1410
+ this.libraryModal.style.display = s.libraryOpen ? 'flex' : 'none';
1411
+ if (!s.libraryOpen) return;
1412
+ this.libraryTitleText.textContent = s.assetTarget ? t('modal.replaceImage') : t('modal.assetsTitle');
1413
+ this.libraryHintEl.textContent = s.assetTarget ? t('library.clickToReplace') : t('library.clickToPlace');
1414
+ this.storageLabelEl.textContent = t('library.storageLabel', { count: s.assets.length, size: KB(s.assets.reduce((n, a) => n + (a.size || 0), 0)) });
1415
+ if (this.assetSearch.value !== (s.assetQuery || '')) this.assetSearch.value = s.assetQuery || '';
1416
+ // The OS file dialog greys out what validation would refuse anyway, so the
1417
+ // accept list tracks whatever limits the host has set.
1418
+ this.fileInput.accept = acceptAttribute(this.core.limits());
1419
+
1420
+ this.folderGrid.innerHTML = '';
1421
+ this.core.folderOptions().forEach((f) => {
1422
+ const on = s.assetFolder === f.id;
1423
+ const btn = elS('button', `text-align: left; border: 0; border-left: 2px solid ${on ? 'var(--ed-accent)' : 'transparent'}; background: ${on ? 'var(--ed-soft)' : 'transparent'}; color: ${on ? 'var(--ed-accent)' : 'var(--ed-text)'}; cursor: pointer; padding: 8px 10px; font-size: 12.5px; display: flex; justify-content: space-between; gap: 8px; transition: background 0.16s;`, { type: 'button' });
1424
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-soft)'; });
1425
+ btn.addEventListener('mouseleave', () => { btn.style.background = on ? 'var(--ed-soft)' : 'transparent'; });
1426
+ btn.addEventListener('click', () => this.core.setAssetFolder(f.id));
1427
+ btn.append(
1428
+ elS('span', '', { text: f.name }),
1429
+ elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint);', { text: f.count == null ? '' : String(f.count) }),
1430
+ );
1431
+ this.folderGrid.appendChild(btn);
1432
+ });
1433
+
1434
+ // One line carries all three transient states -- they can't co-occur, and a
1435
+ // separate row for each would push the grid down every time one appeared.
1436
+ const status = s.uploading ? t('storage.uploading', { count: s.uploading })
1437
+ : s.assetsError ? t('storage.errLoadFailed', { reason: s.assetsError })
1438
+ : s.assetsLoading ? t('storage.loading') : '';
1439
+ this.assetStatus.textContent = status;
1440
+ this.assetStatus.style.display = status ? 'block' : 'none';
1441
+ this.assetStatus.style.color = s.assetsError ? 'var(--ed-danger)' : 'var(--ed-muted)';
1442
+
1443
+ this.assetGridWrap.style.background = s.libHot ? 'var(--ed-soft)' : 'transparent';
1444
+ const visible = this.core.visibleAssets();
1445
+ this.assetGrid.innerHTML = '';
1446
+ visible.forEach((a) => {
1447
+ const tile = elS('div', 'border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); overflow: hidden; background: var(--ed-panel-2); cursor: pointer; transition: border-color 0.16s, transform 0.16s, box-shadow 0.16s;', { draggable: 'true' });
1448
+ tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--ed-accent)'; tile.style.transform = 'translateY(-1px)'; tile.style.boxShadow = 'var(--ed-shadow-md)'; });
1449
+ tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; tile.style.transform = ''; tile.style.boxShadow = ''; });
1450
+ tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
1451
+ tile.addEventListener('click', () => this.core.useAsset(a));
1452
+ tile.appendChild(elS('div', `width: 100%; height: 120px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work); border-bottom: 1px solid var(--ed-line);`, { role: 'img', 'aria-label': a.name }));
1453
+ const meta = elS('div', 'padding: 8px 9px;');
1454
+ meta.appendChild(elS('div', 'font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
1455
+ const metaRow = elS('div', 'display: flex; justify-content: space-between; gap: 6px; margin-top: 3px;');
1456
+ metaRow.appendChild(elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint);', { text: `${a.w ? a.w + '×' + a.ht + ' · ' : ''}${KB(a.size || 0)}` }));
1457
+ const del = elS('span', 'font-family: ui-monospace, monospace; font-size: 9.5px; color: var(--ed-faint); cursor: pointer;', { title: t('library.deleteFileHint'), text: t('library.del') });
1458
+ del.addEventListener('mouseenter', () => { del.style.color = 'var(--ed-danger)'; });
1459
+ del.addEventListener('mouseleave', () => { del.style.color = 'var(--ed-faint)'; });
1460
+ del.addEventListener('click', (e) => { e.stopPropagation(); this.core.removeAsset(a); });
1461
+ metaRow.appendChild(del);
1462
+ meta.appendChild(metaRow);
1463
+ tile.appendChild(meta);
1464
+ this.assetGrid.appendChild(tile);
1465
+ });
1466
+
1467
+ // "Nothing here" only once there is an answer -- during the first load, and
1468
+ // after a failure, an empty grid means "not known yet", not "no files".
1469
+ this.assetEmpty.style.display = (!visible.length && !s.assetsLoading && !s.assetsError) ? 'block' : 'none';
1470
+ this.assetMore.style.display = s.assetCursor ? 'block' : 'none';
1471
+ this.assetMore.disabled = !!s.assetsLoading;
1472
+ }
1473
+
1474
+ // ---- modals: export ----------------------------------------------------
1475
+
1476
+ buildExportModal() {
1477
+ const t = this.core.t;
1478
+ const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 34px; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
1479
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
1480
+ const modal = elS('div', 'width: 100%; height: 100%; max-width: 900px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; display: grid; grid-template-rows: auto 1fr auto; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
1481
+ modal.addEventListener('click', (e) => e.stopPropagation());
1482
+ overlay.appendChild(modal);
1483
+
1484
+ const head = elS('div', 'display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
1485
+ const headText = elS('div', 'flex: 1;');
1486
+ headText.append(
1487
+ elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.export'), 'data-i18n': 'modal.export' }),
1488
+ elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;', { text: t('modal.exportTitle'), 'data-i18n': 'modal.exportTitle' }),
1489
+ );
1490
+ this.copyBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 12px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', class: 'mc-icon-label' });
1491
+ this.copyBtn.iconNode = icon('copy', 14);
1492
+ this.copyBtn.labelNode = elS('span');
1493
+ this.copyBtn.append(this.copyBtn.iconNode, this.copyBtn.labelNode);
1494
+ this.copyBtn.addEventListener('mouseenter', () => { this.copyBtn.style.borderColor = 'var(--ed-accent)'; this.copyBtn.style.background = 'var(--ed-soft)'; });
1495
+ this.copyBtn.addEventListener('mouseleave', () => { this.copyBtn.style.borderColor = 'var(--ed-line)'; this.copyBtn.style.background = 'transparent'; });
1496
+ this.copyBtn.addEventListener('click', () => this.core.copyExport());
1497
+ this.shotBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 12px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.screenshotHint'), 'data-i18n-title': 'action.screenshotHint', class: 'mc-icon-label' });
1498
+ this.shotBtn.append(icon('image', 14), elS('span', '', { text: t('action.screenshot'), 'data-i18n': 'action.screenshot' }));
1499
+ this.shotBtn.addEventListener('mouseenter', () => { this.shotBtn.style.borderColor = 'var(--ed-accent)'; this.shotBtn.style.background = 'var(--ed-soft)'; });
1500
+ this.shotBtn.addEventListener('mouseleave', () => { this.shotBtn.style.borderColor = 'var(--ed-line)'; this.shotBtn.style.background = 'transparent'; });
1501
+ // Opens the viewer first and renders into it: the capture takes a beat on
1502
+ // a long template, and a modal that is already up with a skeleton reads as
1503
+ // progress, where a button that just sits there reads as a dead click.
1504
+ this.shotBtn.addEventListener('click', () => this.previewScreenshot());
1505
+ const downloadBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 32px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
1506
+ downloadBtn.append(icon('download', 14), elS('span', '', { text: t('action.download'), 'data-i18n': 'action.download' }));
1507
+ downloadBtn.addEventListener('mouseenter', () => { downloadBtn.style.filter = 'brightness(1.1)'; });
1508
+ downloadBtn.addEventListener('mouseleave', () => { downloadBtn.style.filter = ''; });
1509
+ downloadBtn.addEventListener('click', () => this.core.downloadExport());
1510
+ const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
1511
+ closeBtn.appendChild(icon('x', 14));
1512
+ closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
1513
+ closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1514
+ closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1515
+ tip(closeBtn, t('action.close'), 'down', 'end');
1516
+ head.append(headText, this.copyBtn, this.shotBtn, downloadBtn, closeBtn);
1517
+ modal.appendChild(head);
1518
+
1519
+ this.exportTextarea = elS('textarea', 'width: 100%; height: 100%; box-sizing: border-box; border: 0; background: var(--ed-work); color: var(--ed-text); font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.55; padding: 18px 20px; resize: none;', { readonly: 'true' });
1520
+ modal.appendChild(this.exportTextarea);
1521
+ this.exportMetaEl = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
1522
+ modal.appendChild(this.exportMetaEl);
1523
+ return overlay;
1524
+ }
1525
+
1526
+ renderExportModal() {
1527
+ const s = this.core.state;
1528
+ const t = this.core.t;
1529
+ this.exportModal.style.display = s.exportOpen ? 'flex' : 'none';
1530
+ if (!s.exportOpen) return;
1531
+ if (this.exportTextarea.value !== s.exportCode) this.exportTextarea.value = s.exportCode;
1532
+ this.copyBtn.labelNode.textContent = s.copied ? t('export.copied') : t('export.copy');
1533
+ this.exportMetaEl.textContent = t('export.meta', { kb: (s.exportCode.length / 1024).toFixed(1) });
1534
+ }
1535
+
1536
+ // ---- modals: code view --------------------------------------------------
1537
+
1538
+ buildCodeModal() {
1539
+ const t = this.core.t;
1540
+ const overlay = elS('div', 'position: absolute; inset: 0; background: var(--ed-panel); z-index: 55; display: grid; grid-template-rows: 58px minmax(0, 1fr) auto; animation: mcFade 0.16s ease; display: none;', { class: 'mc-fullscreen-panel mc-code-panel' });
1541
+ const head = elS('div', 'display: flex; align-items: center; gap: 10px; padding: 0 18px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2));', { class: 'mc-code-toolbar' });
1542
+ const headIcon = elS('span', 'width: 32px; height: 32px; border-radius: 9px; background: var(--ed-soft); color: var(--ed-accent); display: flex; align-items: center; justify-content: center; flex: none;', { class: 'mc-code-badge' });
1543
+ headIcon.appendChild(icon('code', 17));
1544
+ const headText = elS('div', 'flex: 1; min-width: 0;');
1545
+ this.codeStatusEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-code-kicker' });
1546
+ headText.append(this.codeStatusEl, elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: t('modal.rawHtmlTitle'), 'data-i18n': 'modal.rawHtmlTitle', class: 'mc-code-heading' }));
1547
+ this.codeWidthSeg = elS('div', '', { class: 'mc-segment' });
1548
+ const reloadBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1549
+ reloadBtn.appendChild(icon('refresh', 14));
1550
+ reloadBtn.appendChild(elS('span', '', { text: t('action.reload'), 'data-i18n': 'action.reload' }));
1551
+ reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.borderColor = 'var(--ed-accent)'; reloadBtn.style.background = 'var(--ed-soft)'; });
1552
+ reloadBtn.addEventListener('mouseleave', () => { reloadBtn.style.borderColor = 'var(--ed-line)'; reloadBtn.style.background = 'transparent'; });
1553
+ reloadBtn.addEventListener('click', () => {
1554
+ const src = this.core.buildHtml();
1555
+ this.core.setState({ codeSrc: src, codeLive: src, codeDirty: false });
1556
+ this.core.flash(this.core.t('toast.sourceReloaded'));
1557
+ });
1558
+ const applyBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; height: 30px; padding: 0 12px; display: flex; align-items: center; gap: 7px; font-family: var(--ed-font); font-weight: 600; font-size: 11px; transition: filter 0.16s;', { type: 'button', title: t('action.applyToCanvasHint'), 'data-i18n-title': 'action.applyToCanvasHint' });
1559
+ applyBtn.appendChild(icon('check', 14));
1560
+ applyBtn.appendChild(elS('span', '', { text: t('action.applyToCanvas'), 'data-i18n': 'action.applyToCanvas' }));
1561
+ applyBtn.addEventListener('mouseenter', () => { applyBtn.style.filter = 'brightness(1.1)'; });
1562
+ applyBtn.addEventListener('mouseleave', () => { applyBtn.style.filter = ''; });
1563
+ applyBtn.addEventListener('click', () => this.core.applyCode());
1564
+ const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s;', { type: 'button', title: t('action.closeWithoutApplyingHint'), 'data-i18n-title': 'action.closeWithoutApplyingHint', class: 'mc-icon-button' });
1565
+ closeBtn.appendChild(icon('x', 14));
1566
+ closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
1567
+ closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1568
+ closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1569
+ tip(closeBtn, t('action.closeWithoutApplyingHint'), 'down', 'end');
1570
+ head.append(headIcon, headText, this.codeWidthSeg, reloadBtn, applyBtn, closeBtn);
1571
+ overlay.appendChild(head);
1572
+
1573
+ const cols = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; min-height: 0; background: var(--ed-work);', { class: 'mc-code-split' });
1574
+ const left = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-source' });
1575
+ const leftHead = elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); display: flex; justify-content: space-between;', { class: 'mc-pane-label' });
1576
+ this.codeMetaEl = elS('span');
1577
+ leftHead.append(elS('span', '', { text: t('code.source'), 'data-i18n': 'code.source' }), this.codeMetaEl);
1578
+ left.appendChild(leftHead);
1579
+ const editorWrap = elS('div', 'position: relative; min-height: 0;');
1580
+ editorWrap.appendChild(this.buildCodeEditor());
1581
+ left.appendChild(editorWrap);
1582
+ cols.appendChild(left);
1583
+
1584
+ const right = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-preview' });
1585
+ right.appendChild(elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('code.livePreview'), 'data-i18n': 'code.livePreview', class: 'mc-pane-label' }));
1586
+ const previewWrap = elS('div', 'overflow: auto; padding: 20px; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1587
+ this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 1px solid var(--ed-line-2); border-radius: 6px; background: #ffffff; box-shadow: 0 6px 18px rgba(15,23,42,0.10); transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
1588
+ this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
1589
+ previewWrap.appendChild(this.codeFrame);
1590
+ right.appendChild(previewWrap);
1591
+ cols.appendChild(right);
1592
+ overlay.appendChild(cols);
1593
+
1594
+ overlay.appendChild(elS('div', 'padding: 8px 18px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);', { text: t('code.applyNote'), 'data-i18n': 'code.applyNote', class: 'mc-code-footer' }));
1595
+ return overlay;
1596
+ }
1597
+
1598
+ /** Ported from `codeEditorEl()`: numbered syntax-highlighted rows sit behind
1599
+ * a transparent-text/visible-caret `<textarea>`. Both layers share the same
1600
+ * fixed width and wrapping rules, so long HTML stays on screen without the
1601
+ * highlight or caret drifting away from the editable text. */
1602
+ buildCodeEditor() {
1603
+ const MONO = 'font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 20px; tab-size: 2;';
1604
+ const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);');
1605
+ const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
1606
+ this.codePre = elS('pre', `${MONO} margin: 0; padding: 14px 0 80px; width: 100%; min-width: 0; min-height: 100%; white-space: normal; color: var(--ed-text); pointer-events: none; background: linear-gradient(to right, var(--ed-panel-2) 0 52px, var(--ed-work) 52px);`, { 'aria-hidden': 'true' });
1607
+ this.codeTextarea = elS('textarea', `${MONO} position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 14px 18px 80px 70px; border: 0; background: transparent; color: transparent; caret-color: var(--ed-accent); white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; overflow: hidden; resize: none; outline: none;`, { spellcheck: 'false', wrap: 'soft', 'data-focus-key': 'code-src' });
1608
+ this.codeTextarea.addEventListener('input', (e) => this.core.setCodeSrc(e.target.value));
1609
+ this.codeTextarea.addEventListener('keydown', (e) => {
1610
+ if (e.key !== 'Tab') return;
1611
+ e.preventDefault();
1612
+ const ta = e.target; const a = ta.selectionStart; const b = ta.selectionEnd;
1613
+ const next = ta.value.slice(0, a) + ' ' + ta.value.slice(b);
1614
+ this.core.setCodeSrc(next);
1615
+ ta.value = next;
1616
+ ta.selectionStart = ta.selectionEnd = a + 2;
1617
+ });
1618
+ inner.append(this.codePre, this.codeTextarea);
1619
+ root.appendChild(inner);
1620
+ return root;
1621
+ }
1622
+
1623
+ /** The iframe owns a separate document, so the Shadow DOM scrollbar rules
1624
+ * cannot reach its viewport. Mirror the editor scrollbar as preview chrome
1625
+ * only; the source string and the HTML applied/exported by the host remain
1626
+ * byte-for-byte untouched. */
1627
+ syncCodePreviewScrollbar() {
1628
+ const doc = this.codeFrame?.contentDocument;
1629
+ if (!doc?.head) return;
1630
+ const view = this.ownerDocument?.defaultView;
1631
+ const thumb = view?.getComputedStyle(this.mc).getPropertyValue('--ed-line-2').trim() || 'rgba(15,23,42,0.16)';
1632
+ let style = doc.head.querySelector('[data-mc-preview-scrollbar]');
1633
+ if (!style) {
1634
+ style = doc.createElement('style');
1635
+ style.setAttribute('data-mc-preview-scrollbar', '');
1636
+ doc.head.appendChild(style);
1637
+ }
1638
+ style.textContent = `html { scrollbar-width: thin !important; scrollbar-color: ${thumb} transparent !important; } *::-webkit-scrollbar { width: 8px !important; height: 8px !important; } *::-webkit-scrollbar-thumb { background: ${thumb} !important; border-radius: 999px !important; } *::-webkit-scrollbar-track { background: transparent !important; } *::-webkit-scrollbar-button { display: none !important; width: 0 !important; height: 0 !important; }`;
1639
+ }
1640
+
1641
+ renderCodeModal() {
1642
+ const s = this.core.state;
1643
+ const t = this.core.t;
1644
+ this.codeModal.style.display = s.codeOpen ? 'grid' : 'none';
1645
+ if (!s.codeOpen) return;
1646
+ this.refreshCodeSource();
1647
+ if (this._codeFrameSrc !== s.codeLive) {
1648
+ this.codeFrame.srcdoc = s.codeLive;
1649
+ this._codeFrameSrc = s.codeLive;
1650
+ }
1651
+ this.syncCodePreviewScrollbar();
1652
+ this.codeWidthSeg.innerHTML = '';
1653
+ [{ label: t('device.desktop'), v: 'desktop', iconName: 'monitor' }, { label: t('device.mobile'), v: 'mobile', iconName: 'phone' }].forEach((w) => {
1654
+ const on = s.codeDevice === w.v;
1655
+ const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)};`, { type: 'button', title: w.label, 'aria-pressed': String(on) });
1656
+ btn.appendChild(icon(w.iconName, 15));
1657
+ btn.addEventListener('click', () => this.core.setState({ codeDevice: w.v }));
1658
+ tip(btn, w.label, 'down');
1659
+ this.codeWidthSeg.appendChild(btn);
1660
+ });
1661
+ this.codeFrame.style.width = s.codeDevice === 'mobile' ? '390px' : '100%';
1662
+ }
1663
+
1664
+ /** Repaint the syntax layer, gutter and metadata without touching the
1665
+ * canvas, inspector, iframe, or focused textarea node. */
1666
+ refreshCodeSource() {
1667
+ if (!this.core.state.codeOpen || !this.codeTextarea) return;
1668
+ const s = this.core.state;
1669
+ const t = this.core.t;
1670
+ this.codeStatusEl.textContent = s.codeDirty ? t('code.statusEdited') : t('code.statusSynced');
1671
+ if (this.codeTextarea.value !== s.codeSrc) this.codeTextarea.value = s.codeSrc;
1672
+ const lines = s.codeSrc.split('\n');
1673
+ this.codePre.innerHTML = lines.map((line, index) => `<span style="display:grid;grid-template-columns:52px minmax(0,1fr);min-height:20px"><span style="padding-right:10px;text-align:right;color:var(--ed-faint);border-right:1px solid var(--ed-line);user-select:none">${index + 1}</span><span style="min-width:0;padding:0 18px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word">${hl(line) || '&#8203;'}</span></span>`).join('');
1674
+ const lineCount = lines.length;
1675
+ this.codeMetaEl.textContent = t('code.meta', { kb: (s.codeSrc.length / 1024).toFixed(1), lines: lineCount });
1676
+ }
1677
+
1678
+ // ---- modals: AI copy --------------------------------------------------
1679
+
1680
+ buildAiModal() {
1681
+ const t = this.core.t;
1682
+ const overlay = elS('div', 'position: absolute; inset: 0; background: rgba(10,12,14,0.6); z-index: 50; display: flex; align-items: flex-start; justify-content: center; padding: 34px; overflow-y: auto; animation: mcFade 0.16s ease; display: none;', { class: 'mc-modal-backdrop' });
1683
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }); });
1684
+ const modal = elS('div', 'width: 100%; max-width: 660px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: var(--ed-radius); overflow: hidden; box-shadow: var(--ed-shadow-lg); animation: mcIn 0.2s cubic-bezier(0.22,0.61,0.36,1);', { class: 'mc-modal' });
1685
+ modal.addEventListener('click', (e) => e.stopPropagation());
1686
+ overlay.appendChild(modal);
1687
+
1688
+ const head = elS('div', 'display: flex; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--ed-line);');
1689
+ const headText = elS('div', 'flex: 1;');
1690
+ headText.append(
1691
+ elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.aiDraft'), 'data-i18n': 'modal.aiDraft' }),
1692
+ elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;', { text: t('modal.aiDraftTitle'), 'data-i18n': 'modal.aiDraftTitle' }),
1693
+ );
1694
+ const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.close'), class: 'mc-icon-button' });
1695
+ closeBtn.appendChild(icon('x', 14));
1696
+ closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; });
1697
+ closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1698
+ closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1699
+ tip(closeBtn, t('action.close'), 'down', 'end');
1700
+ head.append(headText, closeBtn);
1701
+ modal.appendChild(head);
1702
+
1703
+ const body = elS('div', 'padding: 20px; display: grid; gap: 14px;');
1704
+ body.appendChild(elS('p', 'margin: 0; padding: 10px 12px; border: 1px solid var(--ed-line); border-radius: 8px; background: var(--ed-panel-2); color: var(--ed-muted); font-size: 12px; line-height: 1.5;', { text: t('ai.disclosure'), 'data-i18n': 'ai.disclosure' }));
1705
+ const row = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 14px;');
1706
+
1707
+ const goalWrap = elS('div');
1708
+ goalWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.goal'), 'data-i18n': 'ai.goal' }));
1709
+ this.aiGoalSelect = elS('select', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 8px;');
1710
+ this.aiGoalSelect.addEventListener('change', (e) => this.core.setState({ aiGoal: e.target.value }));
1711
+ // Filled once here rather than on every render: the lists are static, and
1712
+ // sixteen goals under two <optgroup> headings stay scannable where one flat
1713
+ // run of sixteen would not.
1714
+ AI_GOALS.forEach((g) => {
1715
+ const grp = document.createElement('optgroup');
1716
+ grp.label = g.group;
1717
+ g.items.forEach((name) => { const o = document.createElement('option'); o.value = name; o.textContent = name; grp.appendChild(o); });
1718
+ this.aiGoalSelect.appendChild(grp);
1719
+ });
1720
+ goalWrap.appendChild(this.aiGoalSelect);
1721
+
1722
+ const toneWrap = elS('div');
1723
+ toneWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.tone'), 'data-i18n': 'ai.tone' }));
1724
+ this.aiToneSelect = elS('select', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 8px;');
1725
+ this.aiToneSelect.addEventListener('change', (e) => this.core.setState({ aiTone: e.target.value }));
1726
+ AI_TONES.forEach((tone) => { const o = document.createElement('option'); o.value = tone; o.textContent = tone; this.aiToneSelect.appendChild(o); });
1727
+ toneWrap.appendChild(this.aiToneSelect);
1728
+ row.append(goalWrap, toneWrap);
1729
+ body.appendChild(row);
1730
+
1731
+ const briefWrap = elS('div');
1732
+ briefWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.briefLabel'), 'data-i18n': 'ai.briefLabel' }));
1733
+ this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief' });
1734
+ this.aiBriefInput.addEventListener('focus', () => { this.aiBriefInput.style.borderColor = 'var(--ed-accent)'; this.aiBriefInput.style.outline = 'none'; });
1735
+ // Debounced with a blur flush: runAi reads state.aiBrief, and clicking
1736
+ // Generate blurs this field first -- the flush lands the last keystrokes
1737
+ // before the read.
1738
+ const briefCommit = typeCommit((v) => this.core.setState({ aiBrief: v }));
1739
+ this.aiBriefInput.addEventListener('blur', () => { this.aiBriefInput.style.borderColor = 'var(--ed-line)'; briefCommit.flush(); });
1740
+ this.aiBriefInput.addEventListener('input', (e) => briefCommit.call(e.target.value));
1741
+ briefWrap.appendChild(this.aiBriefInput);
1742
+ body.appendChild(briefWrap);
1743
+
1744
+ this.aiRunBtn = elS('button', 'border: 0; background: var(--ed-accent); color: var(--ed-accent-ink); cursor: pointer; min-height: 36px; padding: 9px 12px; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--ed-font); font-weight: 600; font-size: 12px; transition: filter 0.16s;', { type: 'button', class: 'mc-icon-label' });
1745
+ this.aiRunBtn.iconNode = icon('spark', 14);
1746
+ this.aiRunBtn.labelNode = elS('span');
1747
+ this.aiRunBtn.append(this.aiRunBtn.iconNode, this.aiRunBtn.labelNode);
1748
+ this.aiRunBtn.addEventListener('mouseenter', () => { this.aiRunBtn.style.filter = 'brightness(1.1)'; });
1749
+ this.aiRunBtn.addEventListener('mouseleave', () => { this.aiRunBtn.style.filter = ''; });
1750
+ this.aiRunBtn.addEventListener('click', () => this.core.runAi());
1751
+ body.appendChild(this.aiRunBtn);
1752
+
1753
+ this.aiResultsEl = elS('div');
1754
+ body.appendChild(this.aiResultsEl);
1755
+ modal.appendChild(body);
1756
+ return overlay;
1757
+ }
1758
+
1759
+ renderAiModal() {
1760
+ const s = this.core.state;
1761
+ const t = this.core.t;
1762
+ this.aiModal.style.display = s.aiOpen ? 'flex' : 'none';
1763
+ if (!s.aiOpen) return;
1764
+ // Options are built once in buildAiModal; render only reflects state.
1765
+ this.aiGoalSelect.value = s.aiGoal;
1766
+ this.aiToneSelect.value = s.aiTone;
1767
+ if (this.aiBriefInput.value !== s.aiBrief) this.aiBriefInput.value = s.aiBrief;
1768
+ this.aiRunBtn.disabled = s.aiBusy;
1769
+ this.aiRunBtn.labelNode.textContent = s.aiBusy ? t('ai.writing') : t('ai.generate');
1770
+
1771
+ this.aiResultsEl.innerHTML = '';
1772
+ s.aiResults.forEach((r) => {
1773
+ const card = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); padding: 15px;');
1774
+ card.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-accent); margin-bottom: 7px;', { text: r.kind }));
1775
+ card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text }));
1776
+ const useBtn = elS('button', 'margin-top: 12px; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; padding: 6px 11px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: background 0.16s, color 0.16s, border-color 0.16s;', { type: 'button', text: r.action });
1777
+ useBtn.addEventListener('mouseenter', () => { useBtn.style.background = 'var(--ed-accent)'; useBtn.style.color = 'var(--ed-accent-ink)'; useBtn.style.borderColor = 'var(--ed-accent)'; });
1778
+ useBtn.addEventListener('mouseleave', () => { useBtn.style.background = 'transparent'; useBtn.style.color = 'var(--ed-text)'; useBtn.style.borderColor = 'var(--ed-line)'; });
1779
+ useBtn.addEventListener('click', r.onUse);
1780
+ card.appendChild(useBtn);
1781
+ this.aiResultsEl.appendChild(card);
1782
+ });
1783
+ }
1784
+
1785
+ // ---- modals: preview ----------------------------------------------------
1786
+
1787
+ buildPreviewModal() {
1788
+ const t = this.core.t;
1789
+ const overlay = elS('div', '', { class: 'mc-fullscreen-panel mc-preview-panel' });
1790
+ overlay.style.cssText = 'position: absolute; inset: 0; z-index: 60; display: grid; grid-template-rows: 58px minmax(0, 1fr); animation: mcFade 0.16s ease; display: none;';
1791
+ const head = elS('div', 'display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel);', { class: 'mc-preview-toolbar' });
1792
+ const headText = elS('div', 'flex: 1; min-width: 0;');
1793
+ this.previewKickerEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-preview-kicker' });
1794
+ headText.append(this.previewKickerEl);
1795
+ this.previewDeviceSeg = elS('div', '', { class: 'mc-segment' });
1796
+ const closeBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 13px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button' });
1797
+ closeBtn.appendChild(icon('x', 14));
1798
+ closeBtn.appendChild(elS('span', '', { text: t('action.close'), 'data-i18n': 'action.close' }));
1799
+ closeBtn.addEventListener('mouseenter', () => { closeBtn.style.borderColor = 'var(--ed-accent)'; closeBtn.style.background = 'var(--ed-soft)'; });
1800
+ closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; closeBtn.style.background = 'transparent'; });
1801
+ closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
1802
+ head.append(headText, this.previewDeviceSeg, closeBtn);
1803
+ overlay.appendChild(head);
1804
+ const body = elS('div', 'overflow-y: auto; padding: 32px 24px;', { class: 'mc-preview-body' });
1805
+ this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
1806
+ this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
1807
+ body.appendChild(this.previewSlot);
1808
+ overlay.appendChild(body);
1809
+ return overlay;
1810
+ }
1811
+
1812
+ renderPreviewModal() {
1813
+ const s = this.core.state;
1814
+ const t = this.core.t;
1815
+ this.previewModal.style.display = s.previewOpen ? 'grid' : 'none';
1816
+ if (!s.previewOpen) return;
1817
+ this.previewKickerEl.textContent = s.device === 'mobile' ? t('preview.kickerMobile') : t('preview.kickerDesktop', { width: s.doc.theme.width });
1818
+ this.previewDeviceSeg.innerHTML = '';
1819
+ [
1820
+ { label: t('device.desktop'), v: 'desktop', iconName: 'monitor' }, { label: t('device.mobile'), v: 'mobile', iconName: 'phone' },
1821
+ ].forEach((d) => {
1822
+ const on = s.device === d.v;
1823
+ const btn = elS('button', `width: 32px; height: 26px; display: flex; align-items: center; justify-content: center; background: ${this.segBg(on)}; color: ${this.segFg(on)};`, { type: 'button', title: d.label, 'aria-pressed': String(on) });
1824
+ btn.appendChild(icon(d.iconName, 15));
1825
+ btn.addEventListener('click', () => this.core.setState({ device: d.v }));
1826
+ tip(btn, d.label, 'down');
1827
+ this.previewDeviceSeg.appendChild(btn);
1828
+ });
1829
+ this.previewSlot.innerHTML = '';
1830
+ this.previewSlot.appendChild(renderDoc(this.core, false));
1831
+ }
1832
+ }
1833
+
1834
+ /** Ported from `blockTiles` inside `renderVals()` -- `icon` is a factory (not a built node) since a DOM node can only ever be attached in one place. */
1835
+ function paletteTiles(core) {
1836
+ const rowCount = () => core.state.doc.rows.length;
1837
+ return PALETTE.map((e) => {
1838
+ if (e.g) {
1839
+ const gr = GROUPS[e.g];
1840
+ return {
1841
+ label: gr.label, icon: () => icon(gr.icon, 20),
1842
+ hint: gr.label + ' — drops a row of ordinary blocks you can edit and rearrange one by one',
1843
+ onDragStart: core.startDrag({ kind: 'group', id: e.g }),
1844
+ onAdd: () => core.insertGroup(e.g, null, 0, rowCount()),
1845
+ };
1846
+ }
1847
+ const b = DEF(e.t); if (!b) return null;
1848
+ return {
1849
+ label: b.label, icon: () => icon(b.type, 20), hint: b.hint + ' — drag onto the canvas, or double-click to append',
1850
+ onDragStart: core.startDrag({ kind: 'block', type: b.type }),
1851
+ onAdd: () => core.insertBlock(b.type, null, 0, rowCount()),
1852
+ };
1853
+ }).filter(Boolean);
1854
+ }
1855
+
1856
+ // Guarded on both sides. `customElements` is absent under SSR (Next.js, Nuxt,
1857
+ // Angular Universal) and in any Node-based test runner, where importing the
1858
+ // package must not throw; and `define` throws NotSupportedError if the module
1859
+ // is evaluated twice -- HMR, or a bundled and an ESM copy loaded side by side.
1860
+ if (typeof customElements !== 'undefined' && !customElements.get('mailcraft-editor')) {
1861
+ customElements.define('mailcraft-editor', MailCraftEditor);
1862
+ }