@seliseblocks/mailcraft 0.2.0 → 0.2.2

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 (72) hide show
  1. package/DOCS.md +10 -8
  2. package/README.md +4 -3
  3. package/README.md.txt +4 -3
  4. package/dist/mailcraft-editor.bundle.js +90 -51
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/activate-your-account.html +1 -1
  7. package/examples/templates/back-in-stock.html +1 -1
  8. package/examples/templates/cart-left-behind.html +1 -1
  9. package/examples/templates/community-giveaway.html +1 -1
  10. package/examples/templates/frontend-futures-invite.html +1 -1
  11. package/examples/templates/give-25-get-25.html +1 -1
  12. package/examples/templates/invoice-paid.html +1 -1
  13. package/examples/templates/meet-nova-launch.html +1 -1
  14. package/examples/templates/mega-weekend-sale.html +1 -1
  15. package/examples/templates/order-confirmed.html +1 -1
  16. package/examples/templates/rate-your-headphones.html +1 -1
  17. package/examples/templates/reset-your-password.html +1 -1
  18. package/examples/templates/thankyou-promo-code.html +1 -1
  19. package/examples/templates/the-sunday-brief.html +1 -1
  20. package/examples/templates/welcome-to-your-workspace.html +1 -1
  21. package/examples/templates/your-order-shipped.html +1 -1
  22. package/examples/templates/your-password-was-changed.html +1 -1
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +27 -25
  25. package/package.json +1 -1
  26. package/src/core/binder.js +21 -1
  27. package/src/core/editor-core.js +12 -2
  28. package/src/core/export.js +19 -3
  29. package/src/core/i18n/ar.js +0 -1
  30. package/src/core/i18n/bg.js +0 -1
  31. package/src/core/i18n/bn.js +0 -1
  32. package/src/core/i18n/ca.js +0 -1
  33. package/src/core/i18n/cs.js +0 -1
  34. package/src/core/i18n/da.js +0 -1
  35. package/src/core/i18n/de-CH.js +0 -1
  36. package/src/core/i18n/de.js +0 -1
  37. package/src/core/i18n/dz.js +0 -1
  38. package/src/core/i18n/el.js +0 -1
  39. package/src/core/i18n/en.js +0 -1
  40. package/src/core/i18n/es.js +0 -1
  41. package/src/core/i18n/et.js +0 -1
  42. package/src/core/i18n/fi.js +0 -1
  43. package/src/core/i18n/fr.js +0 -1
  44. package/src/core/i18n/hr.js +0 -1
  45. package/src/core/i18n/hu.js +0 -1
  46. package/src/core/i18n/it.js +0 -1
  47. package/src/core/i18n/loaders.js +78 -0
  48. package/src/core/i18n/lt.js +0 -1
  49. package/src/core/i18n/lv.js +0 -1
  50. package/src/core/i18n/nb.js +0 -1
  51. package/src/core/i18n/nl.js +0 -1
  52. package/src/core/i18n/pl.js +0 -1
  53. package/src/core/i18n/pt.js +0 -1
  54. package/src/core/i18n/ro.js +0 -1
  55. package/src/core/i18n/ru.js +0 -1
  56. package/src/core/i18n/sk.js +0 -1
  57. package/src/core/i18n/sl.js +0 -1
  58. package/src/core/i18n/sv.js +0 -1
  59. package/src/core/i18n/tables.js +5 -4
  60. package/src/core/i18n/tr.js +0 -1
  61. package/src/core/i18n/uk.js +0 -1
  62. package/src/core/import-html.js +31 -0
  63. package/src/core/storage-limits.js +16 -11
  64. package/src/core/storage.js +1 -1
  65. package/src/core/theme.js +15 -1
  66. package/src/index.js +1 -0
  67. package/src/mailcraft-editor.js +79 -23
  68. package/src/render/canvas.js +43 -6
  69. package/src/render/fields.js +17 -0
  70. package/src/render/screenshot.js +7 -1
  71. package/src/render/style.js +503 -460
  72. package/types/index.d.ts +14 -2
@@ -17,7 +17,7 @@ import { createStoryViewer } from './render/story.js';
17
17
  import { STYLE } from './render/style.js';
18
18
  import { createTranslator, isRtl } from './core/i18n/index.js';
19
19
  import { ACCENT_VARS, accentTokens, parseColor } from './core/accent.js';
20
- import { LOCALE_TABLES } from './core/i18n/tables.js';
20
+ import { localeTable, loadLocale } from './core/i18n/loaders.js';
21
21
 
22
22
  /** `elS` mirrors the template's literal `style="..."` attribute strings verbatim -- copied, not re-derived, to keep the port pixel-exact. */
23
23
  function elS(tag, styleStr, attrs = {}, ...children) {
@@ -298,13 +298,26 @@ export class MailCraftEditor extends ElementBase {
298
298
 
299
299
  /**
300
300
  * Resolves the `locale` attribute to its shipped message table
301
- * (core/i18n/tables.js) and overlays any host-set `.messages` on top --
301
+ * (core/i18n/loaders.js) and overlays any host-set `.messages` on top --
302
302
  * attribute picks the language, `.messages` stays the documented way to
303
303
  * override individual strings. With no locale attribute this degrades to
304
304
  * the old behavior: `.messages` alone over built-in English.
305
+ *
306
+ * Tables load lazily (a literal dynamic import per tag, so app bundlers
307
+ * code-split them instead of shipping all 31). A not-yet-loaded locale
308
+ * renders English for the tick the chunk takes to arrive, then this method
309
+ * re-runs; the load is a no-op re-render if the attribute changed again
310
+ * meanwhile -- the guard re-reads the live attribute, never the old tag.
305
311
  */
306
312
  refreshTranslator() {
307
- const table = LOCALE_TABLES[this.getAttribute('locale')] || null;
313
+ const tag = this.getAttribute('locale') || '';
314
+ let table = tag ? localeTable(tag) : null;
315
+ if (table === undefined) {
316
+ table = null;
317
+ loadLocale(tag).then(() => {
318
+ if ((this.getAttribute('locale') || '') === tag) this.refreshTranslator();
319
+ });
320
+ }
308
321
  const overrides = this.core.messages;
309
322
  this.core.t = createTranslator(table ? Object.assign({}, table, overrides || {}) : overrides);
310
323
  this.story?.retranslate();
@@ -429,10 +442,10 @@ export class MailCraftEditor extends ElementBase {
429
442
 
430
443
  /**
431
444
  * What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
432
- * Required alongside a provider and deliberately undefaulted -- the ceilings
433
- * that suit an email template depend on the sending platform, the audience's
434
- * mail clients and the host's own rules, none of which this package can know.
435
- * See `core/storage-limits.js`.
445
+ * Only `maxBytes` is required alongside a provider -- sizes depend on the
446
+ * sending platform and the host's own rules, none of which this package can
447
+ * know. `accept` is optional and defaults to every image type the validator
448
+ * can recognize; list MIME types to narrow it. See `core/storage-limits.js`.
436
449
  */
437
450
  get storageLimits() { return this.core.storageLimits; }
438
451
  set storageLimits(limits) { this.core.setStorageLimits(limits); }
@@ -523,7 +536,7 @@ export class MailCraftEditor extends ElementBase {
523
536
 
524
537
  this.footerBar = this.footerText = this.footerLink = null;
525
538
 
526
- 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' });
539
+ this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; background: var(--ed-work);', { class: 'mc-layout' });
527
540
  this.frame.appendChild(this.body);
528
541
  this.body.appendChild(this.buildMain());
529
542
  this.body.appendChild(this.buildAside());
@@ -688,17 +701,19 @@ export class MailCraftEditor extends ElementBase {
688
701
  this.toastEl.textContent = s.toast || '';
689
702
  }
690
703
 
691
- /** Repaint just the countdown digits on the canvas -- driven by the core's
692
- * 1s tick (core.onTick), which used to re-render the whole editor. */
704
+ /** Repaint just the countdown digits on the canvas -- and in the preview,
705
+ * whose sheet is now memoized rather than rebuilt every render -- driven by
706
+ * the core's 1s tick (core.onTick), which used to re-render the whole editor. */
693
707
  refreshCountdowns() {
694
708
  const now = this.core.state.now;
695
- this.canvasSlot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
709
+ const slots = this.core.state.previewOpen && this.previewSlot ? [this.canvasSlot, this.previewSlot] : [this.canvasSlot];
710
+ slots.forEach((slot) => slot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
696
711
  const ms = Math.max(0, new Date(wrap.getAttribute('data-mc-countdown')).getTime() - now);
697
712
  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 };
698
713
  wrap.querySelectorAll('[data-mc-count]').forEach((n) => {
699
714
  n.textContent = String(vals[n.getAttribute('data-mc-count')] ?? 0).padStart(2, '0');
700
715
  });
701
- });
716
+ }));
702
717
  }
703
718
 
704
719
  /** The header's "Saved HH:MM" label -- autosave updates it through
@@ -744,7 +759,7 @@ export class MailCraftEditor extends ElementBase {
744
759
  * would strand whichever node the previous render had left in place.
745
760
  */
746
761
  buildFooter() {
747
- 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' });
762
+ 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; background: transparent; 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' });
748
763
  this.footerText = elS('span', '', { class: 'mc-footer-text' });
749
764
  this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
750
765
  this.footerBar.appendChild(this.footerText);
@@ -921,8 +936,11 @@ export class MailCraftEditor extends ElementBase {
921
936
  if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
922
937
  // A host-supplied `theme` attribute owns light/dark (see applyTheme) --
923
938
  // hide the built-in toggle rather than let it fight the host's control.
939
+ // setProperty(..., 'important'): a plain assignment loses to the
940
+ // `!important` on `.mc-icon-label` (render/style.js) that keeps every
941
+ // other label-icon button visible, leaving the toggle rendered anyway.
924
942
  if (this.chromeBtn) {
925
- this.chromeBtn.style.display = this.hasAttribute('theme') ? 'none' : 'flex';
943
+ this.chromeBtn.style.setProperty('display', this.hasAttribute('theme') ? 'none' : 'flex', 'important');
926
944
  this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
927
945
  this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
928
946
  this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
@@ -961,7 +979,7 @@ export class MailCraftEditor extends ElementBase {
961
979
  this.renderExportModal();
962
980
  this.renderCodeModal();
963
981
  this.renderAiModal();
964
- this.renderPreviewModal(false);
982
+ this.renderPreviewModal();
965
983
 
966
984
  this.refreshToast();
967
985
  }
@@ -1589,8 +1607,16 @@ export class MailCraftEditor extends ElementBase {
1589
1607
 
1590
1608
  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' });
1591
1609
  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' }));
1592
- const previewWrap = elS('div', 'overflow: auto; padding: 20px; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1593
- 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' });
1610
+ // Flush, not a card on a mat: the document inside the frame paints its
1611
+ // own page background edge to edge, so padding the wrap and framing the
1612
+ // iframe stacked a second and third rectangle around a template that
1613
+ // already has one -- the pane, a mat, a bordered card, then the email's
1614
+ // own page, then its content column. `is-device` (renderCodeModal) puts
1615
+ // the mat and the frame back for the 390px phone width, where the card
1616
+ // *is* the point.
1617
+ const previewWrap = elS('div', 'overflow: auto; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1618
+ this.codePreviewBody = previewWrap;
1619
+ this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 0; background: #ffffff; 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' });
1594
1620
  this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
1595
1621
  previewWrap.appendChild(this.codeFrame);
1596
1622
  right.appendChild(previewWrap);
@@ -1670,7 +1696,9 @@ export class MailCraftEditor extends ElementBase {
1670
1696
  tip(btn, w.label, 'down');
1671
1697
  this.codeWidthSeg.appendChild(btn);
1672
1698
  });
1673
- this.codeFrame.style.width = s.codeDevice === 'mobile' ? '390px' : '100%';
1699
+ const phone = s.codeDevice === 'mobile';
1700
+ this.codeFrame.style.width = phone ? '390px' : '100%';
1701
+ this.codePreviewBody.classList.toggle('is-device', phone);
1674
1702
  }
1675
1703
 
1676
1704
  /** Repaint the syntax layer, gutter and metadata without touching the
@@ -1799,7 +1827,7 @@ export class MailCraftEditor extends ElementBase {
1799
1827
  buildPreviewModal() {
1800
1828
  const t = this.core.t;
1801
1829
  const overlay = elS('div', '', { class: 'mc-fullscreen-panel mc-preview-panel' });
1802
- 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;';
1830
+ overlay.style.cssText = 'position: absolute; inset: 0; z-index: 60; background: var(--ed-work); display: grid; grid-template-rows: 58px minmax(0, 1fr); animation: mcFade 0.16s ease; display: none;';
1803
1831
  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' });
1804
1832
  const headText = elS('div', 'flex: 1; min-width: 0;');
1805
1833
  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' });
@@ -1813,7 +1841,16 @@ export class MailCraftEditor extends ElementBase {
1813
1841
  closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
1814
1842
  head.append(headText, this.previewDeviceSeg, closeBtn);
1815
1843
  overlay.appendChild(head);
1816
- const body = elS('div', 'overflow-y: auto; padding: 32px 24px;', { class: 'mc-preview-body' });
1844
+ // This body *is* the sent email's page: the theme's page background is
1845
+ // applied on each render (renderPreviewModal), and the stylesheet gives
1846
+ // it container padding painted in that background -- breathing room the
1847
+ // way a mail client's viewport provides it, with the sheet itself, and
1848
+ // everything exportHtml() renders, untouched.
1849
+ const body = elS('div', 'overflow-y: auto;', { class: 'mc-preview-body' });
1850
+ // Kept on the instance: renderPreviewModal repaints it from the document's
1851
+ // page background on every render, and it runs long after this builder's
1852
+ // local has gone out of scope.
1853
+ this.previewBody = body;
1817
1854
  this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
1818
1855
  this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
1819
1856
  body.appendChild(this.previewSlot);
@@ -1825,7 +1862,7 @@ export class MailCraftEditor extends ElementBase {
1825
1862
  const s = this.core.state;
1826
1863
  const t = this.core.t;
1827
1864
  this.previewModal.style.display = s.previewOpen ? 'grid' : 'none';
1828
- if (!s.previewOpen) return;
1865
+ if (!s.previewOpen) { this._previewDoc = null; return; }
1829
1866
  this.previewKickerEl.textContent = s.device === 'mobile' ? t('preview.kickerMobile') : t('preview.kickerDesktop', { width: s.doc.theme.width });
1830
1867
  this.previewDeviceSeg.innerHTML = '';
1831
1868
  [
@@ -1838,8 +1875,27 @@ export class MailCraftEditor extends ElementBase {
1838
1875
  tip(btn, d.label, 'down');
1839
1876
  this.previewDeviceSeg.appendChild(btn);
1840
1877
  });
1841
- this.previewSlot.innerHTML = '';
1842
- this.previewSlot.appendChild(renderDoc(this.core, false));
1878
+ // The preview body doubles as the sent page: the theme's page background
1879
+ // paints it, exactly as the exported <body style="background:..."> does.
1880
+ // Falls back to the empty string -- and so to the stylesheet's dotted
1881
+ // grid -- for a transparent page, which is exactly what a client with no
1882
+ // page colour of its own shows behind the email.
1883
+ const pageBg = s.doc.theme.bg || '';
1884
+ this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
1885
+ // Rebuild the sheet only when what it renders from actually changed.
1886
+ // renderPreviewModal runs on *every* render pass, and rebuilding the
1887
+ // whole email document each time made the open preview visibly rough:
1888
+ // every unrelated setState re-created every node (images re-decoded and
1889
+ // flashed, the scroll anchor jumped). The three inputs are cheap
1890
+ // identity checks -- commit/undo/redo always replace `doc` with a fresh
1891
+ // parse, refreshTranslator always replaces `t` -- so a stale hit is
1892
+ // impossible.
1893
+ if (s.doc !== this._previewDoc || s.device !== this._previewDevice || t !== this._previewT) {
1894
+ this._previewDoc = s.doc;
1895
+ this._previewDevice = s.device;
1896
+ this._previewT = t;
1897
+ this.previewSlot.replaceChildren(renderDoc(this.core, false));
1898
+ }
1843
1899
  }
1844
1900
  }
1845
1901
 
@@ -175,21 +175,57 @@ export function renderDoc(core, live) {
175
175
  // clients render it LTR -- letting the chrome's direction cascade in here
176
176
  // flipped bidi punctuation and default alignment on the canvas and made it
177
177
  // disagree with the exported result.
178
+ const radius = Number(theme.radius) || 0;
178
179
  const root = el('div', {
179
- width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
180
- transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s',
180
+ width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
181
+ // Only when the document actually asks for a shape: an unconditional
182
+ // `0px` would override the editor chrome's own soft corner on the sheet
183
+ // (style.js) and square off every template that never touched the field.
184
+ borderRadius: radius ? radius + 'px' : '',
185
+ // Clipping the rows to that corner is right for the sent email (export
186
+ // emits `overflow:hidden` alongside the radius) and for the static
187
+ // preview, but not for the editable canvas: the sheet is also what the
188
+ // row grip straddles and what the floating RTE toolbar overhangs
189
+ // (top:-82px on the first block), and hiding the overflow cuts both off.
190
+ overflow: !live && radius ? 'hidden' : '',
191
+ transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
181
192
  }, { 'data-mc-sheet': '1', dir: 'ltr' });
182
193
 
194
+ /*
195
+ * The page: the full-width section the email sits on, painted from the
196
+ * document's own `bg`/`padY`/`padX` rather than left to the workspace
197
+ * chrome. It is the band a mail client shows around the content column --
198
+ * the one part of the template that used to exist only in the exported
199
+ * HTML, so changing its colour did nothing visible in the editor and its
200
+ * size could not be changed at all. With the padding at its 0 default the
201
+ * page hugs the sheet exactly and nothing about the canvas changes.
202
+ */
203
+ const padY = Number(theme.padY) || 0;
204
+ const padX = Number(theme.padX) || 0;
205
+ const page = el('div', {
206
+ background: theme.bg || 'transparent',
207
+ padding: padY + 'px ' + padX + 'px',
208
+ boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
209
+ transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
210
+ // `is-padded` is what moves the editor's frame outward (style.js): with
211
+ // no padding the page hugs the sheet exactly, so the sheet keeps the
212
+ // frame and an untouched canvas looks exactly as it always did.
213
+ }, { 'data-mc-page': '1', class: 'mc-page' + (padY || padX ? ' is-padded' : ''), dir: 'ltr' });
214
+
183
215
  const rowLines = [];
184
216
  if (live) {
185
- root.addEventListener('dragover', (e) => {
217
+ // On the page, not the sheet: a drag held over the page padding is still
218
+ // aimed at the template, and these fire for the sheet's own events too
219
+ // (they bubble). Column drops stop propagation before they get here, as
220
+ // they did when these listeners sat on the sheet.
221
+ page.addEventListener('dragover', (e) => {
186
222
  const dr = core.drag; if (!dr) return;
187
223
  e.preventDefault();
188
224
  const index = core.indexFromPoint(root, e.clientY);
189
225
  showLine(rowDropTracker, rowLines[index]);
190
226
  });
191
- root.addEventListener('dragleave', (e) => { if (e.target === root) hideActive(rowDropTracker); });
192
- root.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
227
+ page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
228
+ page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
193
229
  }
194
230
 
195
231
  d.rows.forEach((r, ri) => {
@@ -309,5 +345,6 @@ export function renderDoc(core, live) {
309
345
  root.appendChild(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', padding: '90px 20px', textAlign: 'center', fontFamily: 'ui-monospace,monospace', fontSize: '10.5px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--ed-faint)' }, { text: 'drag a row or block here' }));
310
346
  }
311
347
 
312
- return root;
348
+ page.appendChild(root);
349
+ return page;
313
350
  }
@@ -545,6 +545,17 @@ export function renderField(f) {
545
545
  row.appendChild(fieldLabel(f.label, true));
546
546
  const pill = el('div', { display: 'flex', alignItems: 'center', flex: 'none', height: '32px', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-color-control' });
547
547
  const swatch = el('label', { position: 'relative', width: '29px', alignSelf: 'stretch', flex: 'none', borderRight: '1px solid var(--ed-line)', background: f.swatch, cursor: 'pointer' }, { title: f.label });
548
+ // A transparent value has no colour to show, so the swatch shows the
549
+ // conventional checkerboard instead -- otherwise "no fill" and "white"
550
+ // are the same white square.
551
+ if (f.transparent) {
552
+ Object.assign(swatch.style, {
553
+ backgroundColor: '#ffffff',
554
+ backgroundImage: 'linear-gradient(45deg,#c3c9d2 25%,transparent 25%,transparent 75%,#c3c9d2 75%),linear-gradient(45deg,#c3c9d2 25%,transparent 25%,transparent 75%,#c3c9d2 75%)',
555
+ backgroundSize: '8px 8px',
556
+ backgroundPosition: '0 0, 4px 4px',
557
+ });
558
+ }
548
559
  const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
549
560
  picker.value = f.swatch;
550
561
  // The native picker fires `input` continuously while dragging the hue
@@ -560,6 +571,12 @@ export function renderField(f) {
560
571
  hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
561
572
  hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
562
573
  pill.append(swatch, hex);
574
+ if (f.clearable) {
575
+ const none = el('button', { flex: 'none', alignSelf: 'stretch', width: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '0', borderLeft: '1px solid var(--ed-line)', background: f.transparent ? 'var(--ed-accent)' : 'transparent', color: f.transparent ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', cursor: 'pointer', padding: '0' }, { type: 'button', title: f.transparent ? 'Transparent — click for a solid colour' : 'Make transparent (no fill)', 'aria-pressed': String(!!f.transparent) });
576
+ none.appendChild(icon(f.transparent ? 'check' : 'clear', 12));
577
+ none.addEventListener('click', () => f.onToggleTransparent());
578
+ pill.appendChild(none);
579
+ }
563
580
  row.appendChild(pill);
564
581
  wrap.appendChild(row);
565
582
  return wrap;
@@ -80,7 +80,13 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
80
80
  const wrapper = document.createElement('div');
81
81
  // Off-screen but attached (fixed keeps it out of any scroll container's
82
82
  // scrollHeight, so capturing never moves the user's canvas position).
83
- wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${theme.width + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
83
+ // The document's own page padding sits inside renderDoc's page element, so
84
+ // the wrapper has to be wide enough for it -- sized off the content width
85
+ // alone, `max-width:100%` would shrink the sheet below `theme.width` by
86
+ // exactly the padding and the capture would come out narrower than the
87
+ // template really is.
88
+ const pageW = theme.width + (Number(theme.padX) || 0) * 2;
89
+ wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${pageW + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
84
90
  wrapper.setAttribute('aria-hidden', 'true');
85
91
  // renderDoc sizes the sheet from `state.device`, so with the mobile toggle
86
92
  // on it would return a 375px sheet floating inside this desktop-width