@seliseblocks/mailcraft 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/DOCS.md +70 -67
  3. package/README.md +8 -8
  4. package/README.md.txt +8 -8
  5. package/dist/mailcraft-editor.bundle.js +84 -76
  6. package/dist/mailcraft-editor.bundle.js.map +3 -3
  7. package/examples/templates/activate-your-account.html +1 -1
  8. package/examples/templates/back-in-stock.html +4 -4
  9. package/examples/templates/cart-left-behind.html +3 -3
  10. package/examples/templates/community-giveaway.html +3 -3
  11. package/examples/templates/frontend-futures-invite.html +3 -3
  12. package/examples/templates/give-25-get-25.html +2 -2
  13. package/examples/templates/invoice-paid.html +3 -3
  14. package/examples/templates/meet-nova-launch.html +7 -7
  15. package/examples/templates/mega-weekend-sale.html +1 -1
  16. package/examples/templates/order-confirmed.html +6 -6
  17. package/examples/templates/rate-your-headphones.html +3 -3
  18. package/examples/templates/reset-your-password.html +1 -1
  19. package/examples/templates/thankyou-promo-code.html +3 -3
  20. package/examples/templates/the-sunday-brief.html +4 -4
  21. package/examples/templates/welcome-to-your-workspace.html +4 -4
  22. package/examples/templates/your-password-was-changed.html +2 -2
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +63 -63
  25. package/package.json +1 -1
  26. package/src/core/binder.js +10 -0
  27. package/src/core/blocks.js +2 -2
  28. package/src/core/editor-core.js +195 -6
  29. package/src/core/export.js +311 -22
  30. package/src/core/i18n/ar.js +2 -0
  31. package/src/core/i18n/bg.js +2 -0
  32. package/src/core/i18n/bn.js +2 -0
  33. package/src/core/i18n/ca.js +2 -0
  34. package/src/core/i18n/cs.js +2 -0
  35. package/src/core/i18n/da.js +2 -0
  36. package/src/core/i18n/de-CH.js +2 -0
  37. package/src/core/i18n/de.js +2 -0
  38. package/src/core/i18n/dz.js +2 -0
  39. package/src/core/i18n/el.js +2 -0
  40. package/src/core/i18n/en.js +2 -0
  41. package/src/core/i18n/es.js +2 -0
  42. package/src/core/i18n/et.js +2 -0
  43. package/src/core/i18n/fi.js +2 -0
  44. package/src/core/i18n/fr.js +2 -0
  45. package/src/core/i18n/hr.js +2 -0
  46. package/src/core/i18n/hu.js +2 -0
  47. package/src/core/i18n/it.js +2 -0
  48. package/src/core/i18n/lt.js +2 -0
  49. package/src/core/i18n/lv.js +2 -0
  50. package/src/core/i18n/nb.js +2 -0
  51. package/src/core/i18n/nl.js +2 -0
  52. package/src/core/i18n/pl.js +2 -0
  53. package/src/core/i18n/pt.js +2 -0
  54. package/src/core/i18n/ro.js +2 -0
  55. package/src/core/i18n/ru.js +2 -0
  56. package/src/core/i18n/sk.js +2 -0
  57. package/src/core/i18n/sl.js +2 -0
  58. package/src/core/i18n/sv.js +2 -0
  59. package/src/core/i18n/tr.js +2 -0
  60. package/src/core/i18n/uk.js +2 -0
  61. package/src/core/import-html.js +390 -20
  62. package/src/core/layout-style.js +25 -0
  63. package/src/core/sanitize.js +4 -1
  64. package/src/core/theme.js +22 -1
  65. package/src/mailcraft-editor.js +8 -0
  66. package/src/render/block-body.js +129 -17
  67. package/src/render/canvas.js +35 -6
  68. package/src/render/fields.js +19 -0
  69. package/src/render/screenshot.js +26 -3
@@ -1,7 +1,7 @@
1
1
  import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
2
  import { pad } from '../core/layout-style.js';
3
3
  import { parseItems, cellsOf } from '../core/parse.js';
4
- import { linkHref } from '../core/sanitize.js';
4
+ import { linkHref, cssUrl } from '../core/sanitize.js';
5
5
 
6
6
  function el(tag, style, attrs) {
7
7
  const node = document.createElement(tag);
@@ -93,7 +93,7 @@ function overrideLinkColor(root, link) {
93
93
  * toolbar node or null), `ctx.onTableCellBlur(block, ri, ci, value)`, `ctx.now`
94
94
  * (for the countdown), `ctx.vars` and `ctx.onInsertVariable` for the code-view escape hatch.
95
95
  */
96
- export function blockBody(b, theme, live, ctx) {
96
+ export function blockBody(b, theme, live, ctx, colPx) {
97
97
  const p = b.props; const t = theme;
98
98
  const attr = live ? { 'data-mc-content': b.id } : {};
99
99
 
@@ -119,20 +119,87 @@ export function blockBody(b, theme, live, ctx) {
119
119
  case 'image': {
120
120
  const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
121
121
  const imgHref = linkHref(p.href);
122
+ /*
123
+ * Two things every exported image needs and had neither of.
124
+ *
125
+ * `width` as an ATTRIBUTE, in px: the Word engine behind Classic
126
+ * Outlook does not resolve a percentage width on an image and falls
127
+ * back to the file's intrinsic size, so a 1200px hero authored at 60%
128
+ * shipped 1200px wide and tore the layout open. The CSS percentage
129
+ * stays for every client that can do better -- the attribute is only
130
+ * the floor Word reads -- and `height:auto` keeps the aspect ratio once
131
+ * a width is pinned.
132
+ *
133
+ * A real `font-size` on the IMG: the wrapper sets `font-size:0` to kill
134
+ * the inline-block whitespace gap, and alt text inherits it, so with
135
+ * images blocked (Outlook's default) the alt text rendered at 0px and
136
+ * the recipient saw nothing at all. The wrapper keeps its 0; the image
137
+ * carries type of its own for the blocked state.
138
+ */
139
+ /*
140
+ * Two ways to size an image, and the second one is why a logo used to
141
+ * drift.
142
+ *
143
+ * The default is a PERCENTAGE of the column -- responsive, and the
144
+ * right answer for a hero. But a percentage cannot express "88px", and
145
+ * an import that met one had to round it into the nearest whole
146
+ * percent (88 of 600 -> 15% -> 93px). Every save re-derived the
147
+ * percent from the new pixel width, so the logo crept a little wider
148
+ * each time and never landed back on 88.
149
+ *
150
+ * `wUnit: 'px'` with `wpx` pins the real number instead: the CSS width,
151
+ * the Word `width` attribute and the read-back all carry the same
152
+ * integer, so the value is a fixed point. `max-width:100%` (plus the
153
+ * stylesheet's own `img` rule in the sent mail) keeps a pinned image
154
+ * from holding a narrow phone column open.
155
+ */
156
+ const fixed = p.wUnit === 'px' && Number(p.wpx) > 0;
157
+ const pxW = fixed
158
+ ? Math.max(1, Math.round(Number(p.wpx)))
159
+ : Math.max(1, Math.round(((Number(colPx) || Number(t.width) || 620)) * ((Number(p.width) || 100) / 100)));
160
+ const cssW = fixed ? pxW + 'px' : (Number(p.width) || 100) + '%';
161
+ /*
162
+ * `height` as an attribute, derived from the source's own aspect ratio
163
+ * rather than invented: with images blocked (Outlook's default, and
164
+ * Gmail's for an unknown sender) a width-only image collapses to a
165
+ * one-line strip and the layout below it jumps when the image loads.
166
+ * A width AND a height reserve the real box. The CSS `height:auto`
167
+ * still wins in every client that reads CSS, so a client that scales
168
+ * the image down keeps its proportions -- the attribute is only the
169
+ * floor Word and a blocked-image placeholder read.
170
+ *
171
+ * Absent unless the ratio is known (an import that carried both
172
+ * dimensions, or a library asset), so no image gains a guessed height.
173
+ */
174
+ const ratio = Number(p.ratio) > 0 ? Number(p.ratio) : 0;
175
+ const hAttr = ratio ? String(Math.max(1, Math.round(pxW * ratio))) : undefined;
176
+ // Retina and responsive sources ride through untouched. Apple Mail and
177
+ // iOS pick from `srcset`; every other client ignores both attributes
178
+ // and uses `src`, which is why `src` stays the 1x fallback.
179
+ const srcAttrs = {
180
+ srcset: p.srcset || undefined,
181
+ sizes: p.srcset && p.sizes ? p.sizes : undefined,
182
+ title: p.title || undefined,
183
+ };
184
+ const altType = { fontSize: '13px', lineHeight: '1.4', fontFamily: t.font, color: t.text };
122
185
  if (imgHref) {
123
186
  // The % width must live on the anchor, not the img: a percentage on a
124
187
  // child of a shrink-to-fit inline-block resolves against the image's
125
188
  // own intrinsic size (i.e. not at all), which rendered every linked
126
189
  // logo/icon at full intrinsic width no matter what `width` said.
127
- const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: imgHref });
190
+ // `max-width` only in pixel mode: a percentage anchor is already
191
+ // capped by its container, and adding the declaration unconditionally
192
+ // would rewrite the markup of every linked image that never asked for
193
+ // this feature.
194
+ const a = el('a', { display: 'inline-block', width: cssW, ...(fixed ? { maxWidth: '100%' } : {}) }, { href: imgHref });
128
195
  // Same guard as every other anchor the canvas draws: without it a
129
196
  // click on a linked logo navigates the host application away from the
130
197
  // editor, taking the uncommitted document with it.
131
198
  a.addEventListener('click', (e) => e.preventDefault());
132
- a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
199
+ a.appendChild(el('img', { width: '100%', height: 'auto', borderRadius: p.radius + 'px', display: 'block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
133
200
  wrap.appendChild(a);
134
201
  } else {
135
- wrap.appendChild(el('img', { width: p.width + '%', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0' }, { src: p.src, alt: p.alt }));
202
+ wrap.appendChild(el('img', { width: cssW, height: 'auto', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
136
203
  }
137
204
  return wrap;
138
205
  }
@@ -197,11 +264,29 @@ export function blockBody(b, theme, live, ctx) {
197
264
  }
198
265
  case 'divider': {
199
266
  const wrap = el('div', { padding: p.py + 'px 0' }, attr);
200
- wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color }));
267
+ const rule = p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color;
268
+ // Word gives a zero-height div nothing to draw the border on, so the
269
+ // same rule ships twice: once on this div, which every other client
270
+ // renders and the importer reads (classifyDivider: a DIV bar with a
271
+ // border-top), and once on a <td> inside an MSO-only table, the shape
272
+ // Word does paint. `<!--[if !mso]><!-->` keeps the div out of Word so
273
+ // it never draws both. Comments are inert to the importer.
274
+ const doc = wrap.ownerDocument;
275
+ wrap.appendChild(doc.createComment('[if !mso]><!'));
276
+ wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: rule, fontSize: '1px', lineHeight: '1px' }, { html: '&nbsp;' }));
277
+ wrap.appendChild(doc.createComment('<![endif]'));
278
+ wrap.appendChild(doc.createComment('[if mso]><table role="presentation" width="' + p.width + '%" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-top:' + rule + ';font-size:1px;line-height:1px;">&nbsp;</td></tr></table><![endif]'));
201
279
  return wrap;
202
280
  }
203
- case 'spacer':
204
- return el('div', { height: p.height + 'px' }, attr);
281
+ case 'spacer': {
282
+ // An empty div has no height in Word: it needs a character to give a
283
+ // line to, and an exact line-height to size that line (msoHarden adds
284
+ // mso-line-height-rule:exactly wherever a px line-height stands). This
285
+ // is mj-spacer's shape. The NBSP trims to nothing for the importer, so
286
+ // classifySpacer still sees an empty div with a height.
287
+ const h = Math.max(0, Number(p.height) || 0);
288
+ return el('div', { height: h + 'px', lineHeight: h + 'px', fontSize: '1px' }, { ...attr, html: '&nbsp;' });
289
+ }
205
290
  case 'social': {
206
291
  // Two independent axes cover every style the block offers without a
207
292
  // second hand-drawn icon set per platform: `palette` picks the source
@@ -241,14 +326,32 @@ export function blockBody(b, theme, live, ctx) {
241
326
  }
242
327
  case 'video': {
243
328
  const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
244
- const a = el('a', { display: 'block', position: 'relative' }, { href: linkHref(p.href) });
329
+ /*
330
+ * The play badge used to float over the thumbnail with
331
+ * position:absolute. Gmail strips `position` outright and Word ignores
332
+ * it, so in both the badge fell to a line below the picture. Now the
333
+ * thumbnail is the cell's background (the one overlay mechanism email
334
+ * has) and the badge is plain centred content inside it. The cell needs
335
+ * a height, since a background gives none: 16:9 of the column width,
336
+ * which is what a video thumbnail is. The whole cell is the link.
337
+ */
338
+ const vw = Math.max(120, Number(colPx) || Number(t.width) || 620);
339
+ const vh = Math.round(vw * 9 / 16);
340
+ const vurl = cssUrl(p.src);
341
+ const table = el('table', { width: '100%', borderCollapse: 'collapse' }, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', width: '100%' });
342
+ const tr = el('tr'); table.appendChild(tr);
343
+ const cell = el('td', {
344
+ height: vh + 'px', textAlign: 'center', verticalAlign: 'middle',
345
+ backgroundColor: '#111111', backgroundImage: vurl ? 'url("' + vurl + '")' : 'none',
346
+ backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat',
347
+ }, { height: String(vh), align: 'center', valign: 'middle', background: p.src || undefined, bgcolor: '#111111' });
348
+ const a = el('a', { display: 'inline-block', textDecoration: 'none' }, { href: linkHref(p.href), title: p.caption || undefined });
245
349
  a.addEventListener('click', (e) => e.preventDefault());
246
- a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
247
- const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
248
- const circle = el('span', { width: '54px', height: '54px', borderRadius: '50%', background: p.badge, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', paddingLeft: '4px' }, { text: '▶' });
249
- badge.appendChild(circle);
250
- a.appendChild(badge);
251
- wrap.appendChild(a);
350
+ const circle = el('span', { display: 'inline-block', width: '54px', height: '54px', lineHeight: '54px', borderRadius: '50%', background: p.badge, color: '#fff', textAlign: 'center', fontSize: '18px', fontFamily: 'Arial, sans-serif' }, { text: '▶' });
351
+ a.appendChild(circle);
352
+ cell.appendChild(a);
353
+ tr.appendChild(cell);
354
+ wrap.appendChild(table);
252
355
  wrap.appendChild(el('div', { fontFamily: t.font, fontSize: '12.5px', color: p.badge, opacity: '0.7', marginTop: '8px' }, { text: p.caption }));
253
356
  return wrap;
254
357
  }
@@ -385,8 +488,17 @@ export function blockBody(b, theme, live, ctx) {
385
488
  const edit = live;
386
489
  const borderSide = (on) => (p.border && on !== false ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + p.lineColor : '0');
387
490
  const box = el('div', {
388
- background: p.bgImage ? 'linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)), url("' + p.bgImage + '")' : p.bg,
389
- backgroundSize: 'cover', backgroundPosition: 'center',
491
+ // Colour and image as separate longhands, never one shorthand. The
492
+ // shorthand made `p.bg` unreachable the moment an image was set --
493
+ // there was no colour left underneath when a client refused the image
494
+ // -- and it opened with a no-op `linear-gradient()` that outlook.com's
495
+ // sanitiser drops the whole declaration over, taking the photo with
496
+ // it. Same rule as a row's background (core/export.js).
497
+ backgroundColor: p.bg || 'transparent',
498
+ backgroundImage: p.bgImage ? 'url("' + cssUrl(p.bgImage) + '")' : 'none',
499
+ // The same three controls a row has; hard-coded cover/center meant a
500
+ // box background could never tile or pin to an edge.
501
+ backgroundSize: p.bgSize || 'cover', backgroundPosition: p.bgPos || 'center', backgroundRepeat: p.bgRepeat || 'no-repeat',
390
502
  borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
391
503
  borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
392
504
  borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
@@ -1,6 +1,6 @@
1
1
  import { DEF } from '../core/blocks.js';
2
- import { boxStyle, rowBg, rowPad, colsWrap, colStyle } from '../core/layout-style.js';
3
- import { scopeCss } from '../core/sanitize.js';
2
+ import { boxStyle, rowBg, rowPad, colsWrap, colStyle, colBg } from '../core/layout-style.js';
3
+ import { scopeCss, cssUrl } from '../core/sanitize.js';
4
4
  import { cellsOf } from '../core/parse.js';
5
5
  import { icon } from '../core/icons.js';
6
6
  import { blockBody } from './block-body.js';
@@ -189,7 +189,14 @@ export function renderDoc(core, live) {
189
189
  const radius = Number(theme.radius) || 0;
190
190
  const borderW = Number(theme.borderW) || 0;
191
191
  const root = el('div', {
192
- width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
192
+ width: width + 'px', maxWidth: '100%', color: theme.text, fontFamily: theme.font,
193
+ // Colour and image as separate longhands, mirroring what the exporter
194
+ // writes -- the canvas has to show the same paint the recipient gets.
195
+ backgroundColor: theme.contentBg || 'transparent',
196
+ backgroundImage: theme.contentBgImage ? 'url("' + cssUrl(theme.contentBgImage) + '")' : 'none',
197
+ backgroundSize: theme.contentBgSize || 'cover',
198
+ backgroundPosition: theme.contentBgPos || 'center',
199
+ backgroundRepeat: theme.contentBgRepeat || 'no-repeat',
193
200
  // Only when the document actually asks for a shape: an unconditional
194
201
  // `0px` would override the editor chrome's own soft corner on the sheet
195
202
  // (style.js) and square off every template that never touched the field.
@@ -224,6 +231,15 @@ export function renderDoc(core, live) {
224
231
  const padX = Number(theme.padX) || 0;
225
232
  const page = el('div', {
226
233
  background: theme.bg || 'transparent',
234
+ // The page image, only when there is one: the longhands are added AFTER
235
+ // the shorthand so they override it, and are absent otherwise so a
236
+ // document with no image paints exactly as it always did.
237
+ ...(theme.bgImage ? {
238
+ backgroundImage: 'url("' + cssUrl(theme.bgImage) + '")',
239
+ backgroundSize: theme.bgSize || 'cover',
240
+ backgroundPosition: theme.bgPos || 'center',
241
+ backgroundRepeat: theme.bgRepeat || 'no-repeat',
242
+ } : {}),
227
243
  padding: padY + 'px ' + padX + 'px',
228
244
  boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
229
245
  transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
@@ -310,7 +326,20 @@ export function renderDoc(core, live) {
310
326
  }
311
327
  : { display: 'block' })
312
328
  : colsWrap(r.props));
329
+ /*
330
+ * What this column measures in a sent email, in px. Word cannot resolve a
331
+ * percentage width on an image (it falls back to the file's intrinsic
332
+ * size, which is how a 1200px hero tore the layout open in Outlook), so
333
+ * the image renderer needs a real number to put in a `width` attribute.
334
+ * Approximate by construction -- cell gutters and column padding are not
335
+ * modelled here -- but an approximate cap is the whole difference between
336
+ * a scaled image and a 1200px one.
337
+ */
338
+ const rowPadX = (r.props.pl ?? r.props.px ?? 0) + (r.props.pr ?? r.props.px ?? 0);
339
+ const gapPx = (r.props.gap || 0) * Math.max(0, r.cols.length - 1);
340
+ const innerPx = Math.max(40, (Number(theme.width) || 620) - rowPadX - gapPx);
313
341
  r.cols.forEach((c, ci) => {
342
+ const colPx = Math.max(20, Math.round(innerPx * ((c.span || 100) / 100)));
314
343
  const colLines = [];
315
344
  const items = [];
316
345
  c.blocks.forEach((b, bi) => {
@@ -356,7 +385,7 @@ export function renderDoc(core, live) {
356
385
  // few pixels from the RTE's own controls. The RTE stands in for it for
357
386
  // the duration of the edit; it comes back on blur (still selected).
358
387
  if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
359
- bWrap.appendChild(blockBody(b, theme, live, ctx));
388
+ bWrap.appendChild(blockBody(b, theme, live, ctx, colPx));
360
389
  items.push(bWrap);
361
390
  });
362
391
  let colTracker;
@@ -379,9 +408,9 @@ export function renderDoc(core, live) {
379
408
  // background must stop at the column's visual edge, not bleed across
380
409
  // the gutter. `host` is also what the drag listeners and
381
410
  // `indexFromPoint` must use -- the block slots are its children.
382
- const styled = c.bg || c.border || c.radius || c.padY || c.padX;
411
+ const styled = c.bg || c.bgImage || c.border || c.radius || c.padY || c.padX;
383
412
  const host = styled
384
- ? el('div', { background: c.bg || 'transparent', border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
413
+ ? el('div', { ...colBg(c), border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
385
414
  : colEl;
386
415
  if (live) {
387
416
  host.addEventListener('dragover', (e) => {
@@ -447,6 +447,25 @@ export function renderField(f) {
447
447
  return wrap;
448
448
  }
449
449
 
450
+ /*
451
+ * A note is prose, not a control: no label row, no focus key, nothing to
452
+ * commit. It sits in the flow where the panel put it, tinted by tone --
453
+ * amber for a warning the author should act on before sending, muted for
454
+ * an aside. The left rule rather than a filled panel keeps it from reading
455
+ * as a field the user failed to fill in.
456
+ */
457
+ if (f.isNote) {
458
+ const warn = f.tone !== 'info';
459
+ wrap.appendChild(el('div', {
460
+ fontFamily: 'var(--ed-font)', fontSize: '11px', lineHeight: '1.5',
461
+ color: warn ? 'var(--ed-text)' : 'var(--ed-muted)',
462
+ background: warn ? 'rgba(214,141,32,0.10)' : 'var(--ed-panel-2)',
463
+ borderLeft: '2px solid ' + (warn ? 'rgba(214,141,32,0.85)' : 'var(--ed-line)'),
464
+ padding: '7px 9px', boxSizing: 'border-box',
465
+ }, { text: f.label, class: 'mc-field-note', role: warn ? 'status' : undefined }));
466
+ return wrap;
467
+ }
468
+
450
469
  // No isHead branch: heads never reach renderField -- renderFieldCards
451
470
  // consumes them as card kickers. A field list rendered without the card
452
471
  // grouper would drop its headings, which is the loud failure we want.
@@ -97,11 +97,34 @@ async function inlineExternalImages(root) {
97
97
  ));
98
98
  });
99
99
  root.querySelectorAll('[style]').forEach((el) => {
100
- const m = (el.style.backgroundImage || '').match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
100
+ /*
101
+ * Only the `url(...)` layer is rewritten, never the whole declaration.
102
+ * A tinted section paints `linear-gradient(rgba(20,22,24,α),…),url(…)`
103
+ * (core/layout-style.js), and replacing the property wholesale dropped
104
+ * the gradient -- so every darkened hero came out of the screenshot
105
+ * lighter than the canvas it was captured from. A fetch that fails
106
+ * leaves a transparent pixel in the layer's place rather than `none`,
107
+ * which would be invalid inside a layer list and would take the tint
108
+ * down with it a second way.
109
+ */
110
+ const decl = el.style.backgroundImage || '';
111
+ const m = decl.match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
101
112
  if (!m) return;
102
113
  jobs.push(toDataUri(m[1]).then(
103
- (uri) => { el.style.backgroundImage = 'url("' + uri + '")'; },
104
- () => { el.style.backgroundImage = 'none'; },
114
+ (uri) => { el.style.backgroundImage = decl.replace(m[0], 'url("' + uri + '")'); },
115
+ () => { el.style.backgroundImage = decl.replace(m[0], 'url("' + BLANK_PIXEL + '")'); },
116
+ ));
117
+ });
118
+ // The legacy `background` attribute -- what an imported Outlook-first
119
+ // template, and the exporter's own rows, carry alongside the CSS. Read only
120
+ // where no CSS image was inlined above, so the two never fetch twice.
121
+ root.querySelectorAll('[background]').forEach((el) => {
122
+ const src = el.getAttribute('background') || '';
123
+ if (!src || src.startsWith('data:')) return;
124
+ if (/url\(/.test(el.style.backgroundImage || '')) return;
125
+ jobs.push(toDataUri(src).then(
126
+ (uri) => { el.setAttribute('background', uri); el.style.backgroundImage = 'url("' + uri + '")'; },
127
+ () => { el.removeAttribute('background'); },
105
128
  ));
106
129
  });
107
130
  await Promise.all(jobs);