@seliseblocks/mailcraft 0.2.7 → 0.2.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -49,6 +49,7 @@
49
49
  "examples",
50
50
  "README.md",
51
51
  "DOCS.md",
52
+ "CHANGELOG.md",
52
53
  "LICENSE"
53
54
  ],
54
55
  "engines": {
@@ -60,7 +60,14 @@ export const mkRow = (spans, blocks) => ({
60
60
  border: 0, borderStyle: 'solid', lineColor: '#e2e2e5', bTop: true, bRight: true, bBottom: true, bLeft: true, radius: 0, shadow: '', maxW: 100,
61
61
  mt: 0, mr: 0, mb: 0, ml: 0,
62
62
  layout: 'columns', flexDir: 'row', justify: 'flex-start', alignItems: 'stretch', wrap: true, gridCols: 2,
63
- py: 20, px: 24, padSplit: false, gap: 20, valign: 'top', stackMobile: true,
63
+ py: 20, px: 24, padSplit: false, gap: 20, valign: 'top',
64
+ // What this row does on a narrow screen. `mobileCols` is the number of
65
+ // columns it keeps there -- 1 stacks (the old `stackMobile: true`), 2 makes
66
+ // a two-up grid, 'keep' leaves the desktop layout alone (the old
67
+ // `stackMobile: false`). `mobileOrder: 'reverse'` flips the visual order,
68
+ // which is how an alternating image/text strip keeps the image on top of
69
+ // every band once stacked. Saved documents are mapped over in migrateDoc.
70
+ mobileCols: 1, mobileOrder: 'normal',
64
71
  },
65
72
  cols: spans.map((s, i) => ({ id: uid(), span: s, blocks: i === 0 && blocks ? blocks : [] })),
66
73
  });
@@ -210,6 +217,13 @@ export function migrateDoc(doc) {
210
217
  // 'square' choice rather than silently becoming an unrecognized value.
211
218
  c.blocks.forEach((b) => { if (b.type === 'social' && b.props.shape === 'solid') b.props.shape = 'square'; });
212
219
  });
220
+ // Mobile behaviour used to be one boolean, `stackMobile`. Mapped before
221
+ // the defaults are applied, or every document saved before this build
222
+ // would take `mobileCols: 1` from the defaults and a row that had
223
+ // deliberately opted out of stacking would start stacking.
224
+ if (r.props.mobileCols === undefined && r.props.stackMobile !== undefined) {
225
+ r.props.mobileCols = r.props.stackMobile === false ? 'keep' : 1;
226
+ }
213
227
  Object.keys(defaults).forEach((k) => { if (r.props[k] === undefined) r.props[k] = defaults[k]; });
214
228
  if (hadBlocks && !r.cols.some((c) => c.blocks.length)) emptied.push(r.id);
215
229
  });
@@ -1,117 +1,117 @@
1
- /**
2
- * Import-time CSS cascade: folds a parsed document's `<style>` rules into the
3
- * elements' inline styles, so the importer's classifiers (which read inline
4
- * styles only) see class-styled templates -- Mailchimp exports, hand-written
5
- * emails, framework output that was never inlined -- the same way a mail
6
- * client would.
7
- *
8
- * Deliberately a subset of a real cascade, matched to what email CSS uses:
9
- * - `@media` blocks (and every other at-rule) are dropped whole: responsive
10
- * overrides can't be represented in the imported model, desktop values win.
11
- * A side benefit: base-rule `.desktop_hide { display:none }` still applies,
12
- * so mobile-only duplicate content is correctly dropped by the importer's
13
- * hidden-element skip.
14
- * - Selectors containing `:` are skipped -- pseudo-classes/-elements are
15
- * interactive or generated state with no place in a static import.
16
- * - Specificity is the classic ids/classes/tags count; equal specificity
17
- * resolves by source order; `!important` wins over everything including
18
- * inline styles, which otherwise always win (matching browser behavior for
19
- * the combinations that matter here).
20
- */
21
-
22
- function stripComments(css) {
23
- return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
24
- }
25
-
26
- /** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
27
- function stripAtRules(css) {
28
- let out = '';
29
- let i = 0;
30
- while (i < css.length) {
31
- if (css[i] === '@') {
32
- const semi = css.indexOf(';', i);
33
- const brace = css.indexOf('{', i);
34
- if (brace === -1 || (semi !== -1 && semi < brace)) {
35
- i = semi === -1 ? css.length : semi + 1;
36
- continue;
37
- }
38
- let depth = 0;
39
- let j = brace;
40
- for (; j < css.length; j++) {
41
- if (css[j] === '{') depth++;
42
- else if (css[j] === '}') { depth--; if (!depth) break; }
43
- }
44
- i = j + 1;
45
- continue;
46
- }
47
- out += css[i];
48
- i++;
49
- }
50
- return out;
51
- }
52
-
53
- function specificity(sel) {
54
- const ids = (sel.match(/#[\w-]+/g) || []).length;
55
- const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
56
- const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
57
- return ids * 100 + classes * 10 + tags;
58
- }
59
-
60
- export function inlineStylesheets(doc) {
61
- const sheets = Array.from(doc.querySelectorAll('style'));
62
- if (!sheets.length) return;
63
- const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
-
65
- const rules = [];
66
- const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
67
- let m;
68
- let order = 0;
69
- while ((m = ruleRe.exec(css))) {
70
- const decls = [];
71
- m[2].split(';').forEach((d) => {
72
- const at = d.indexOf(':');
73
- if (at < 0) return;
74
- const prop = d.slice(0, at).trim().toLowerCase();
75
- let value = d.slice(at + 1).trim();
76
- if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
77
- const important = /!important\s*$/i.test(value);
78
- if (important) value = value.replace(/!important\s*$/i, '').trim();
79
- if (value) decls.push({ prop, value, important });
80
- });
81
- if (!decls.length) continue;
82
- m[1].split(',').forEach((raw) => {
83
- const sel = raw.trim();
84
- if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
85
- rules.push({ sel, decls, spec: specificity(sel), order: order++ });
86
- });
87
- }
88
- if (!rules.length) return;
89
-
90
- // Ascending: later (more specific / later-in-source) rules overwrite
91
- // earlier winners per property below.
92
- rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
93
-
94
- const winners = new Map(); // element -> Map(prop -> {value, important})
95
- rules.forEach((rule) => {
96
- let matched;
97
- try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
98
- matched.forEach((el) => {
99
- if (el !== doc.body && !doc.body.contains(el)) return;
100
- let bucket = winners.get(el);
101
- if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
102
- rule.decls.forEach((d) => {
103
- const cur = bucket.get(d.prop);
104
- if (cur && cur.important && !d.important) return;
105
- bucket.set(d.prop, d);
106
- });
107
- });
108
- });
109
-
110
- winners.forEach((bucket, el) => {
111
- if (!el.style) return;
112
- bucket.forEach((d, prop) => {
113
- if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
114
- try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
115
- });
116
- });
117
- }
1
+ /**
2
+ * Import-time CSS cascade: folds a parsed document's `<style>` rules into the
3
+ * elements' inline styles, so the importer's classifiers (which read inline
4
+ * styles only) see class-styled templates -- Mailchimp exports, hand-written
5
+ * emails, framework output that was never inlined -- the same way a mail
6
+ * client would.
7
+ *
8
+ * Deliberately a subset of a real cascade, matched to what email CSS uses:
9
+ * - `@media` blocks (and every other at-rule) are dropped whole: responsive
10
+ * overrides can't be represented in the imported model, desktop values win.
11
+ * A side benefit: base-rule `.desktop_hide { display:none }` still applies,
12
+ * so mobile-only duplicate content is correctly dropped by the importer's
13
+ * hidden-element skip.
14
+ * - Selectors containing `:` are skipped -- pseudo-classes/-elements are
15
+ * interactive or generated state with no place in a static import.
16
+ * - Specificity is the classic ids/classes/tags count; equal specificity
17
+ * resolves by source order; `!important` wins over everything including
18
+ * inline styles, which otherwise always win (matching browser behavior for
19
+ * the combinations that matter here).
20
+ */
21
+
22
+ function stripComments(css) {
23
+ return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
24
+ }
25
+
26
+ /** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
27
+ function stripAtRules(css) {
28
+ let out = '';
29
+ let i = 0;
30
+ while (i < css.length) {
31
+ if (css[i] === '@') {
32
+ const semi = css.indexOf(';', i);
33
+ const brace = css.indexOf('{', i);
34
+ if (brace === -1 || (semi !== -1 && semi < brace)) {
35
+ i = semi === -1 ? css.length : semi + 1;
36
+ continue;
37
+ }
38
+ let depth = 0;
39
+ let j = brace;
40
+ for (; j < css.length; j++) {
41
+ if (css[j] === '{') depth++;
42
+ else if (css[j] === '}') { depth--; if (!depth) break; }
43
+ }
44
+ i = j + 1;
45
+ continue;
46
+ }
47
+ out += css[i];
48
+ i++;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function specificity(sel) {
54
+ const ids = (sel.match(/#[\w-]+/g) || []).length;
55
+ const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
56
+ const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
57
+ return ids * 100 + classes * 10 + tags;
58
+ }
59
+
60
+ export function inlineStylesheets(doc) {
61
+ const sheets = Array.from(doc.querySelectorAll('style'));
62
+ if (!sheets.length) return;
63
+ const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
+
65
+ const rules = [];
66
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
67
+ let m;
68
+ let order = 0;
69
+ while ((m = ruleRe.exec(css))) {
70
+ const decls = [];
71
+ m[2].split(';').forEach((d) => {
72
+ const at = d.indexOf(':');
73
+ if (at < 0) return;
74
+ const prop = d.slice(0, at).trim().toLowerCase();
75
+ let value = d.slice(at + 1).trim();
76
+ if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
77
+ const important = /!important\s*$/i.test(value);
78
+ if (important) value = value.replace(/!important\s*$/i, '').trim();
79
+ if (value) decls.push({ prop, value, important });
80
+ });
81
+ if (!decls.length) continue;
82
+ m[1].split(',').forEach((raw) => {
83
+ const sel = raw.trim();
84
+ if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
85
+ rules.push({ sel, decls, spec: specificity(sel), order: order++ });
86
+ });
87
+ }
88
+ if (!rules.length) return;
89
+
90
+ // Ascending: later (more specific / later-in-source) rules overwrite
91
+ // earlier winners per property below.
92
+ rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
93
+
94
+ const winners = new Map(); // element -> Map(prop -> {value, important})
95
+ rules.forEach((rule) => {
96
+ let matched;
97
+ try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
98
+ matched.forEach((el) => {
99
+ if (el !== doc.body && !doc.body.contains(el)) return;
100
+ let bucket = winners.get(el);
101
+ if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
102
+ rule.decls.forEach((d) => {
103
+ const cur = bucket.get(d.prop);
104
+ if (cur && cur.important && !d.important) return;
105
+ bucket.set(d.prop, d);
106
+ });
107
+ });
108
+ });
109
+
110
+ winners.forEach((bucket, el) => {
111
+ if (!el.style) return;
112
+ bucket.forEach((d, prop) => {
113
+ if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
114
+ try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
115
+ });
116
+ });
117
+ }
@@ -3,7 +3,7 @@ import { mk, blk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } f
3
3
  import { binder, decorate, group } from './binder.js';
4
4
  import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
5
5
  import { validateFiles, limitsProblem } from './storage-limits.js';
6
- import { migrateTokens, cleanHtml, escHtml, linkHref } from './sanitize.js';
6
+ import { migrateTokens, cleanHtml, escHtml, linkHref, scaleInlineSizes, stripInlineStyle } from './sanitize.js';
7
7
  import { buildHtml as buildHtmlFn } from './export.js';
8
8
  import { htmlToDoc } from './import-html.js';
9
9
  import { boxCss } from './layout-style.js';
@@ -35,6 +35,48 @@ const UPLOAD_CONCURRENCY = 3;
35
35
  */
36
36
  const RETIRED_SEED = /^data:image\/svg\+xml;utf8,.*%3Cpattern%20id%3D%22s%22/;
37
37
  const withoutRetiredSeeds = (assets) => (Array.isArray(assets) ? assets.filter((a) => !RETIRED_SEED.test(String((a && a.url) || ''))) : []);
38
+ /**
39
+ * The one span Text size moves in, per block type -- shared by the inspector
40
+ * range and the RTE's ± buttons. They used to disagree: the panel allowed
41
+ * 8–96 (text) and 12–120 (heading) while `size()` clamped to a private
42
+ * 10–64, so a single "Larger text" click on a 96px block *shrank* it to 64.
43
+ */
44
+ const SIZE_SPAN = { text: [8, 96], heading: [12, 120] };
45
+ /** The blocks whose content is rich HTML in props, and the prop it lives in. Only these need the descendant rewrites below. */
46
+ const RICH_HTML_PROP = { text: 'html', list: 'items' };
47
+ /** Inspector key -> the inline CSS property whose descendant copies mask it (see `stripInlineStyle`). */
48
+ const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weight', align: 'text-align' };
49
+
50
+ /**
51
+ * Keeps a rich block's descendants in agreement with the block-level control
52
+ * being moved. Imported HTML deliberately keeps per-element inline typography
53
+ * (sanitize.js `cleanImportHtml`), and a descendant's own declaration beats
54
+ * inheritance from the wrapper -- so without this, Text size / color / Line
55
+ * spacing / weight / Align silently did nothing on any imported (or
56
+ * AI-drafted) block. Size *scales* descendants so a 15/26/15px hierarchy
57
+ * survives as 45/78/45 instead of flattening; the others strip the descendant
58
+ * property, exactly the semantics the Font control already shipped with.
59
+ * Runs inside the same `commit` as the prop write: one undo step, and a
60
+ * document nobody touches is never rewritten.
61
+ */
62
+ function syncRichContent(block, key, val) {
63
+ const prop = RICH_HTML_PROP[block.type];
64
+ if (!prop) return;
65
+ // list items are one fragment per line; the rewrite must not run across the joins.
66
+ const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
67
+ const src = block.props[prop];
68
+ if (src == null || src === '') return;
69
+ if (key === 'size') {
70
+ const cur = Number(block.props.size);
71
+ const next = Number(val);
72
+ // An unknown base (imports that carried no readable size) can't be scaled
73
+ // against -- the first explicit size just establishes the base.
74
+ if (cur > 0 && next > 0 && next !== cur) block.props[prop] = perLine(src, (s) => scaleInlineSizes(s, next / cur));
75
+ } else if (RICH_OWNED_STYLE[key]) {
76
+ block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
77
+ }
78
+ }
79
+
38
80
  const BORDER_STYLES = [
39
81
  { value: 'solid', label: 'Solid' },
40
82
  { value: 'dashed', label: 'Dashed' },
@@ -524,7 +566,8 @@ export class EditorCore {
524
566
  if (b.props[key] !== val) this.setProp(b.id, key, val);
525
567
  }
526
568
  const cur = Number(b.props.size) || 16;
527
- this.setProp(b.id, 'size', Math.max(10, Math.min(64, cur + delta)));
569
+ const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
570
+ this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
528
571
  }
529
572
 
530
573
  hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
@@ -620,7 +663,10 @@ export class EditorCore {
620
663
  this.commit((doc) => {
621
664
  const f = this.find(doc, id);
622
665
  const target = f.block ? f.block.props : (f.row ? f.row.props : null);
623
- if (target) target[key] = val;
666
+ if (!target) return;
667
+ // Before the write: the size rewrite needs the outgoing value as its base.
668
+ if (f.block) syncRichContent(f.block, key, val);
669
+ target[key] = val;
624
670
  });
625
671
  }
626
672
 
@@ -1190,6 +1236,12 @@ export class EditorCore {
1190
1236
  this.flash(this.t('toast.htmlCopied'));
1191
1237
  };
1192
1238
 
1239
+ /** Code view's source pane onto the clipboard, unsaved edits included: `codeSrc` is what the pane shows, which may be ahead of what Apply has pushed to the canvas. */
1240
+ copyCode = () => {
1241
+ if (navigator.clipboard) navigator.clipboard.writeText(this.state.codeSrc).catch(() => {});
1242
+ this.flash(this.t('toast.htmlCopied'));
1243
+ };
1244
+
1193
1245
  downloadExport = () => {
1194
1246
  const blob = new Blob([this.state.exportCode], { type: 'text/html' });
1195
1247
  const a = document.createElement('a');
@@ -1305,12 +1357,23 @@ export class EditorCore {
1305
1357
  // line and a dead divider strip under the title.
1306
1358
  const base = [];
1307
1359
  const padF = [B.head('Spacing'), group(null, [B.range('Above & below', 'py', 0, 160, 2, 'px'), B.range('Left & right', 'px', 0, 120, 2, 'px')])];
1360
+ // Appended to every block type at once rather than repeated across
1361
+ // twenty switch arms. An absent `vis` means "all devices", so the
1362
+ // property only ever appears in a document that asked for it and no
1363
+ // migration is needed. Re-decorating an already-decorated list is safe:
1364
+ // `decorate` derives its flags from `kind`, which survives the pass.
1365
+ const visF = [B.head('Visibility'), B.seg('Show on', 'vis', [
1366
+ { value: 'all', label: 'All' },
1367
+ { value: 'desktop', label: 'Desktop' },
1368
+ { value: 'mobile', label: 'Mobile' },
1369
+ ])];
1370
+ const built = (() => {
1308
1371
  switch (b.type) {
1309
1372
  case 'text': return decorate(base.concat(
1310
1373
  /<a(?:\s|>)/i.test(String(b.props.html || ''))
1311
1374
  ? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
1312
1375
  : [],
1313
- [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 96, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1376
+ [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1314
1377
  case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
1315
1378
  case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
1316
1379
  case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
@@ -1344,7 +1407,7 @@ export class EditorCore {
1344
1407
  case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
1345
1408
  case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.color('Color', 'color')]));
1346
1409
  case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
1347
- case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 12, 120, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1410
+ case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.heading, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1348
1411
  case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
1349
1412
  case 'table': return decorate(base.concat([
1350
1413
  // Custom kind (render/fields.js `renderTableGrid`): a real grid of
@@ -1368,6 +1431,11 @@ export class EditorCore {
1368
1431
  case 'codeblock': return decorate(base.concat([B.area('Code', 'code'), B.color('Background', 'bg'), B.color('Text color', 'color'), B.range('Size', 'size', 8, 32, 0.5, 'px'), B.range('Padding', 'pad', 0, 80, 2, 'px')]));
1369
1432
  default: return [];
1370
1433
  }
1434
+ })();
1435
+ // Logic markers are editor furniture with no rendered body, so there is
1436
+ // nothing for a device to show or hide.
1437
+ if (b.type === 'condition' || b.type === 'loop') return built;
1438
+ return decorate(built.concat(visF));
1371
1439
  }
1372
1440
  if (f.row) {
1373
1441
  const r = f.row;
@@ -1405,7 +1473,21 @@ export class EditorCore {
1405
1473
  },
1406
1474
  ...(r.cols.length > 1 ? [
1407
1475
  B.range('Space between columns', 'gap', 0, 120, 2, 'px'),
1408
- B.tog('Stack columns on mobile', 'stackMobile'),
1476
+ B.head('On mobile'),
1477
+ // Replaces the old "Stack columns on mobile" switch, which could
1478
+ // only say all-or-nothing. Saved documents are mapped onto these
1479
+ // values in migrateDoc, so an existing toggle keeps its meaning.
1480
+ B.seg('Columns', 'mobileCols', [
1481
+ { value: 1, label: 'One' },
1482
+ { value: 2, label: 'Two' },
1483
+ { value: 'keep', label: 'Keep' },
1484
+ ]),
1485
+ // Only offered where it changes something: reversing a row that
1486
+ // keeps its desktop layout would do nothing.
1487
+ ...(p.mobileCols !== 'keep' ? [B.seg('Order', 'mobileOrder', [
1488
+ { value: 'normal', label: 'Normal' },
1489
+ { value: 'reverse', label: 'Reverse' },
1490
+ ])] : []),
1409
1491
  ] : []),
1410
1492
  // Per-column styling, only for multi-column sections (a single
1411
1493
  // column's background is just the section background).