@openpresentation/opf-editor 0.27.0 → 0.29.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 (80) hide show
  1. package/README.md +23 -9
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +136 -125
  10. package/dist/design-options.js +1 -1
  11. package/dist/exact-source.d.ts +30 -0
  12. package/dist/font-gate.js +20 -1
  13. package/dist/json-editor.d.ts +33 -0
  14. package/dist/json-editor.js +91 -4
  15. package/dist/json-options.d.ts +2 -1
  16. package/dist/json-options.js +7 -6
  17. package/dist/layout-adapter.js +96 -0
  18. package/dist/layout-canvas.js +122 -232
  19. package/dist/layout-editor-ui.js +46 -185
  20. package/dist/layout-inspector.js +36 -99
  21. package/dist/layout-layers.js +75 -92
  22. package/dist/layout-model.js +62 -103
  23. package/dist/layout-templates.d.ts +8 -3
  24. package/dist/layout-templates.js +30 -16
  25. package/dist/numbering-panel.js +2 -3
  26. package/dist/react.d.ts +16 -4
  27. package/dist/react.js +47 -6
  28. package/dist/slide-adapter.d.ts +49 -0
  29. package/dist/slide-adapter.js +289 -0
  30. package/dist/slide-edits.d.ts +46 -0
  31. package/dist/slide-edits.js +629 -0
  32. package/dist/slide-manager.js +2 -1
  33. package/dist/slide-scene.d.ts +96 -0
  34. package/dist/slide-scene.js +413 -0
  35. package/dist/source-checker.js +45 -6
  36. package/dist/source-color-scheme.js +60 -0
  37. package/dist/source-completions.js +6 -4
  38. package/dist/source-pane.d.ts +103 -6
  39. package/dist/source-pane.js +196 -9
  40. package/dist/source-worker.js +8 -4
  41. package/dist/studio-canvas.d.ts +34 -0
  42. package/dist/studio-canvas.js +586 -0
  43. package/dist/studio-commands.d.ts +15 -0
  44. package/dist/studio-commands.js +155 -0
  45. package/dist/studio-design.d.ts +16 -0
  46. package/dist/studio-design.js +354 -0
  47. package/dist/studio-kit/commands.d.ts +38 -0
  48. package/dist/studio-kit/commands.js +148 -0
  49. package/dist/studio-kit/dom.d.ts +52 -0
  50. package/dist/studio-kit/dom.js +198 -0
  51. package/dist/studio-kit/drag.d.ts +26 -0
  52. package/dist/studio-kit/drag.js +127 -0
  53. package/dist/studio-kit/gesture.d.ts +29 -0
  54. package/dist/studio-kit/gesture.js +87 -0
  55. package/dist/studio-kit/index.d.ts +29 -0
  56. package/dist/studio-kit/index.js +12 -0
  57. package/dist/studio-kit/inspector.d.ts +22 -0
  58. package/dist/studio-kit/inspector.js +119 -0
  59. package/dist/studio-kit/overlay.d.ts +29 -0
  60. package/dist/studio-kit/overlay.js +182 -0
  61. package/dist/studio-kit/selection.d.ts +30 -0
  62. package/dist/studio-kit/selection.js +106 -0
  63. package/dist/studio-kit/tree.d.ts +9 -0
  64. package/dist/studio-kit/tree.js +82 -0
  65. package/dist/studio-kit/viewport.d.ts +29 -0
  66. package/dist/studio-kit/viewport.js +153 -0
  67. package/dist/studio-layers.d.ts +12 -0
  68. package/dist/studio-layers.js +218 -0
  69. package/dist/studio.d.ts +31 -0
  70. package/dist/studio.js +1608 -0
  71. package/dist/styles.css +1573 -123
  72. package/dist/thumbnail-cache.d.ts +2 -0
  73. package/dist/thumbnail-cache.js +10 -1
  74. package/dist/workspace-measure.js +6 -4
  75. package/dist/workspace-parts.js +20 -0
  76. package/dist/workspace-shell.js +25 -15
  77. package/dist/workspace-theme.js +3 -0
  78. package/dist/workspace.d.ts +114 -10
  79. package/dist/workspace.js +223 -49
  80. package/package.json +30 -19
@@ -43,7 +43,7 @@ import {
43
43
  } from "./design-options.js";
44
44
  import { ACCENT_SLOTS } from "./color-slots.js";
45
45
  import { readDesignVariant, setDesignVariant, variantSwatches } from "./design-variants.js";
46
- import { createContentControls } from "./content-controls.js";
46
+ import { createContentControls, iconNode, shortReason } from "./content-controls.js";
47
47
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
48
48
  import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
49
49
  import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
@@ -94,6 +94,14 @@ const BLOCK_REPLACEMENTS = ["text", "list", "chart", "table", "metric", "quote",
94
94
  let instances = 0;
95
95
 
96
96
  const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
97
+ /** Whether `path` (an OPF path such as slides.0.blocks.1) is an explicit block that is a group of blocks. */
98
+ function isGroupBlock(presentation, path) {
99
+ const parts = String(path).split(".");
100
+ if (parts.at(-2) !== "blocks" || !/^\d+$/.test(parts.at(-1) ?? "")) return false;
101
+ let value = presentation;
102
+ for (const part of parts) value = value?.[/^\d+$/.test(part) ? Number(part) : part];
103
+ return Boolean(value) && typeof value === "object" && Array.isArray(value.blocks);
104
+ }
97
105
  const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
98
106
 
99
107
  /**
@@ -105,7 +113,12 @@ const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
105
113
  * registered ones), `vocabularies` (display metadata for languages, chart types and social platforms: `catalogDisplay`
106
114
  * from `@openpresentation/gallery`), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
107
115
  * after a content conversion or replacement with the path to keep selected (the block, or the inline
108
- * payload field), because the host's old selection path may no longer exist.
116
+ * payload field), because the host's old selection path may no longer exist. `showPath` (default true) shows the
117
+ * selected block's path as a copyable chip under the selection section's Advanced; `blockActions` (`{ before, after }`)
118
+ * adds host actions to the selected block's arrange toolbar.
119
+ *
120
+ * Every option is a visible control with a short label. A hint is the control's tooltip and accessible description, never a
121
+ * paragraph; an option that does not apply is shown disabled, with its reason as its tooltip.
109
122
  */
110
123
  export function createDesignControls(container, options = {}) {
111
124
  const editor = options.editor;
@@ -173,7 +186,7 @@ export function createDesignControls(container, options = {}) {
173
186
  // An edit of a catalog's record forked it into the deck's own records: say so (FA-23).
174
187
  const notice = change?.notice ? ` ${change.notice}` : "";
175
188
  if (change && change.changed === false) say("Already set. Nothing changed.");
176
- else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings}${notice} Undo restores the previous state.`);
189
+ else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings}${notice}`);
177
190
  options.onChange?.(change);
178
191
  } catch (error) {
179
192
  complain(messageOf(error));
@@ -191,12 +204,12 @@ export function createDesignControls(container, options = {}) {
191
204
  let fieldCount = 0;
192
205
  const nextId = (name) => `${uid}-${name}-${++fieldCount}`;
193
206
 
207
+ // A hint is a tooltip on the field (and the control's accessible description), never a paragraph under it.
194
208
  function wrapField(id, label, control, { help, extra } = {}) {
195
209
  const note = h("span", { class: "opf-dc-note" });
196
210
  const labelEl = h("label", { for: id }, label, note);
197
- const helpEl = help ? h("p", { class: "opf-dc-help", id: `${id}-help` }, help) : undefined;
198
- if (helpEl) control.setAttribute("aria-describedby", helpEl.id);
199
- const wrap = h("div", { class: "opf-dc-field" }, labelEl, control, extra, helpEl);
211
+ if (help) control.title = help;
212
+ const wrap = h("div", { class: "opf-dc-field", title: help }, labelEl, control, extra);
200
213
  return { wrap, note, label: labelEl };
201
214
  }
202
215
 
@@ -275,19 +288,18 @@ export function createDesignControls(container, options = {}) {
275
288
  return api;
276
289
  }
277
290
 
278
- function numberField(name, label, { min, max, step, help, onCommit } = {}) {
279
- const field = textField(name, label, { help, type: "number", onCommit: onCommit ? (value) => onCommit(value === "" ? null : Number(value)) : undefined });
291
+ // A range is the field's placeholder (a unit hint), so the label stays short.
292
+ function numberField(name, label, { min, max, step, help, placeholder, onCommit } = {}) {
293
+ const field = textField(name, label, { help, placeholder: placeholder ?? (min !== undefined && max !== undefined ? `${min}–${max}` : undefined), type: "number", onCommit: onCommit ? (value) => onCommit(value === "" ? null : Number(value)) : undefined });
280
294
  for (const [key, value] of Object.entries({ min, max, step })) if (value !== undefined) field.input.setAttribute(key, String(value));
281
295
  return field;
282
296
  }
283
297
 
284
298
  function checkField(name, label, { help, onChange } = {}) {
285
299
  const id = nextId(name);
286
- const input = h("input", { id, type: "checkbox" });
287
- const helpEl = help ? h("p", { class: "opf-dc-help", id: `${id}-help` }, help) : undefined;
288
- if (helpEl) input.setAttribute("aria-describedby", helpEl.id);
300
+ const input = h("input", { id, type: "checkbox", title: help });
289
301
  const note = h("span", { class: "opf-dc-note" });
290
- const wrap = h("div", { class: "opf-dc-field opf-dc-check" }, input, h("label", { for: id }, label, note), helpEl);
302
+ const wrap = h("div", { class: "opf-dc-field opf-dc-check", title: help }, input, h("label", { for: id }, label, note));
291
303
  if (onChange) input.addEventListener("change", () => onChange(input.checked));
292
304
  return {
293
305
  wrap,
@@ -307,12 +319,12 @@ export function createDesignControls(container, options = {}) {
307
319
  */
308
320
  function overlayFields(name, label, { onCommit } = {}) {
309
321
  const commit = () => onCommit?.();
310
- const color = textField(`${name}-color`, `${label} color`, { list: true, placeholder: "dark1 or #000000", help: "A theme color such as dark1, a hex color, or var:<id>. Clear it to remove the overlay.", onCommit: commit });
322
+ const color = textField(`${name}-color`, `${label} color`, { list: true, placeholder: "dark1 or #000000", help: "Theme color, hex or var:<id>. Clear to remove.", onCommit: commit });
311
323
  color.setList(COLOR_NAMES);
312
- const opacity = numberField(`${name}-opacity`, `${label} opacity (0 to 1)`, { min: 0, max: 1, step: 0.05, onCommit: commit });
313
- const edge = selectField(`${name}-edge`, `${label} band`, { empty: "Whole picture", help: "Cover only a band along one edge, for example a caption strip.", onChange: commit });
324
+ const opacity = numberField(`${name}-opacity`, `${label} opacity`, { min: 0, max: 1, step: 0.05, onCommit: commit });
325
+ const edge = selectField(`${name}-edge`, `${label} band`, { empty: "Whole picture", help: "Cover a band along one edge only", onChange: commit });
314
326
  edge.setOptions(IMAGE_EDGES.map((value) => ({ value, label: `Along the ${value}` })));
315
- const size = numberField(`${name}-size`, `${label} band size (0.05 to 1)`, { min: 0.05, max: 1, step: 0.05, onCommit: commit });
327
+ const size = numberField(`${name}-size`, `${label} band size`, { min: 0.05, max: 1, step: 0.05, onCommit: commit });
316
328
  const wrap = h("fieldset", { class: "opf-dc-fieldset", "data-role": name }, h("legend", {}, label), color.wrap, opacity.wrap, edge.wrap, size.wrap);
317
329
  return {
318
330
  wrap,
@@ -344,25 +356,20 @@ export function createDesignControls(container, options = {}) {
344
356
  const source = textField(`${name}-source`, `${label} source`, {
345
357
  list: true,
346
358
  placeholder: "asset:photo or https://…",
347
- help: help ?? "An asset reference, a web address or a data address. Press Enter to apply; clear the field to remove it.",
359
+ help: help ?? "Asset, URL or data URL. Enter applies; clear to remove.",
348
360
  onCommit: (value) => {
349
361
  const ref = value.trim();
350
362
  run(() => apply(ref === "" ? null : ref), ref === "" ? `${label} removed.` : `${label} changed.`);
351
363
  },
352
364
  });
353
365
  const fileId = nextId(`${name}-file`);
354
- const input = h("input", { id: fileId, type: "file", accept: IMAGE_ACCEPT, "aria-describedby": `${fileId}-help` });
355
- const fileWrap = h(
356
- "div",
357
- { class: "opf-dc-field" },
358
- h("label", { for: fileId }, `Upload ${label.toLowerCase()} file`),
359
- input,
360
- h("p", { class: "opf-dc-help", id: `${fileId}-help` }, `PNG, JPEG, GIF, WebP or SVG, up to ${sizeLabel}. It is added to the presentation's assets (or handed to your app) and used here in one undoable step.`),
361
- );
366
+ const fileHint = `PNG, JPEG, GIF, WebP or SVG, up to ${sizeLabel}`;
367
+ const input = h("input", { id: fileId, type: "file", accept: IMAGE_ACCEPT, title: fileHint });
368
+ const fileWrap = h("div", { class: "opf-dc-field", title: fileHint }, h("label", { for: fileId }, `Upload ${label.toLowerCase()} file`), input);
362
369
  let lastRef = "";
363
370
  let lastKey = "";
364
371
  const alt = textField(`${name}-alt`, `${label} alt text`, {
365
- help: "Describes the image for screen readers. Saved with the image's asset; typed before an upload, it is used for that upload. Press Enter to apply.",
372
+ help: "Read by screen readers. Enter applies.",
366
373
  onCommit: (value) => {
367
374
  const id = assetIdOf(lastRef);
368
375
  if (!id || editor.presentation.assets?.[id] === undefined) return;
@@ -390,7 +397,7 @@ export function createDesignControls(container, options = {}) {
390
397
  try {
391
398
  const change = await applyImageUpload(editor, file, build, { alt: alt.input.value, maxBytes: maxImageBytes, onAddAsset: options.onAddAsset });
392
399
  const warnings = change.prepared?.warnings?.length ? ` ${change.prepared.warnings.map((warning) => warning.message).join(" ")}` : "";
393
- say(`${label} set to ${file.name}${change.assetId ? `, added to assets as ${change.assetId}` : ""}.${warnings} Undo restores the previous state.`);
400
+ say(`${label} set to ${file.name}${change.assetId ? `, added to assets as ${change.assetId}` : ""}.${warnings}`);
394
401
  options.onChange?.(change);
395
402
  } catch (error) {
396
403
  if (destroyed) return;
@@ -438,7 +445,6 @@ export function createDesignControls(container, options = {}) {
438
445
  let scopeField;
439
446
  if (scopeNeeded) {
440
447
  scopeField = selectField("scope", "Applies to", {
441
- help: "Choose the whole presentation, or only the slide you are looking at.",
442
448
  onChange: (value) => {
443
449
  state.scope = value === "slide" ? "slide" : "deck";
444
450
  sync();
@@ -459,7 +465,7 @@ export function createDesignControls(container, options = {}) {
459
465
  const built = {};
460
466
  for (const section of sections) built[section] = group(section);
461
467
  // RR-26: list levels, grouping, regions and images between content and design (content-controls.js).
462
- const contentControls = built.selection || built["slide-content"] ? createContentControls({ editor, h, selectField, run, nextId, getSlide, reselect }) : undefined;
468
+ const contentControls = built.selection || built["slide-content"] ? createContentControls({ editor, doc, h, selectField, run, nextId, getSlide, reselect, blockActions: options.blockActions }) : undefined;
463
469
 
464
470
  // --- look and language --------------------------------------------------------------------------
465
471
 
@@ -478,13 +484,13 @@ export function createDesignControls(container, options = {}) {
478
484
  });
479
485
  return field;
480
486
  };
481
- catalogField("theme", "Theme", "themes", { help: "A theme brings its own color scheme, font scheme and background: choosing one removes those overrides here." });
487
+ catalogField("theme", "Theme", "themes", { help: "Brings its own colors, fonts and background" });
482
488
  catalogField("color-scheme", "Color scheme", "color-schemes");
483
- catalogField("font-scheme", "Font scheme", "font-schemes", { help: "Fonts the preview and export use. The preview shows an open look-alike where a font is not bundled." });
484
- catalogField("language", "Language", "languages", { perSlide: false, help: "Sets the language for the whole presentation, including its script fonts." });
489
+ catalogField("font-scheme", "Font scheme", "font-schemes", { help: "Unbundled fonts preview as an open look-alike" });
490
+ catalogField("language", "Language", "languages", { perSlide: false, help: "Whole presentation, with its script fonts" });
485
491
  // RR-41: one size for the whole presentation; the slides recompose and the export writes the matching slide size.
486
492
  const slideSize = selectField("slide-size", "Slide size", {
487
- help: "The size and shape of every slide. The preview recomposes at the new size.",
493
+ help: "Every slide",
488
494
  onChange: (value) => run(() => switchDimension(editor, "slide-sizes", value), `Slide size set to ${slideSize.select.selectedOptions[0]?.textContent ?? value}.`),
489
495
  });
490
496
  body.append(slideSize.wrap);
@@ -495,7 +501,6 @@ export function createDesignControls(container, options = {}) {
495
501
  });
496
502
 
497
503
  const layout = selectField("layout", "Layout of this slide", {
498
- help: "Arranges this slide's content in the chosen layout's regions.",
499
504
  onChange: (value) => run(() => switchDimension(editor, "layouts", value, { slideIndex: getSlide(), ...catalogOptions() }), `Layout changed to ${layout.select.selectedOptions[0]?.textContent ?? value}.`),
500
505
  });
501
506
  body.append(layout.wrap);
@@ -578,7 +583,7 @@ export function createDesignControls(container, options = {}) {
578
583
 
579
584
  if (built.background) {
580
585
  const { body } = built.background;
581
- const typeSelect = selectField("bg-type", "Background type", { help: "Choose a type and its settings, then Apply. Nothing changes until you apply." });
586
+ const typeSelect = selectField("bg-type", "Background type", { help: "Applies with Apply background" });
582
587
  typeSelect.setOptions([
583
588
  { value: "theme", label: "Theme color" },
584
589
  { value: "solid", label: "Solid color" },
@@ -588,24 +593,24 @@ export function createDesignControls(container, options = {}) {
588
593
  ]);
589
594
  const panel = (type, title) => h("fieldset", { class: "opf-dc-fieldset", "data-bg-type": type }, h("legend", {}, title));
590
595
  const themePanel = panel("theme", "Theme color");
591
- const slot = selectField("bg-slot", "Theme slot", { help: "Follows the color scheme, so it changes with it." });
596
+ const slot = selectField("bg-slot", "Theme slot", { help: "Follows the color scheme" });
592
597
  slot.setOptions(THEME_BACKGROUND_SLOTS.map((value) => ({ value, label: value.replace(/(\D+)(\d)/, (_, name, n) => `${titleCase(name)} ${n}`) })));
593
598
  themePanel.append(slot.wrap);
594
599
 
595
600
  const colorList = [...COLOR_NAMES];
596
601
  const solidPanel = panel("solid", "Solid color");
597
- const solidColor = textField("bg-color", "Color", { list: true, placeholder: "#1F2937 or accent1", help: "A hex color, a scheme color such as accent1 or surface, or var:<id>." });
602
+ const solidColor = textField("bg-color", "Color", { list: true, placeholder: "#1F2937 or accent1", help: "Hex, scheme color (accent1, surface) or var:<id>" });
598
603
  solidColor.setList(colorList);
599
604
  solidPanel.append(solidColor.wrap);
600
605
 
601
606
  const gradientPanel = panel("gradient", "Linear gradient");
602
- const angle = numberField("bg-angle", "Angle in degrees", { step: 1, help: "0 runs left to right, 90 top to bottom." });
607
+ const angle = numberField("bg-angle", "Angle", { step: 1, placeholder: "degrees", help: "0° left to right, 90° top to bottom" });
603
608
  const stopsBox = h("div", { class: "opf-dc-stops", role: "group", "aria-label": "Gradient stops" });
604
609
  const stopRows = [];
605
610
  const renderStops = () => {
606
611
  stopsBox.replaceChildren(
607
612
  ...stopRows.map((row, index) => {
608
- for (const [field, text] of [[row.color, `Stop ${index + 1} color`], [row.position, `Stop ${index + 1} position (0 to 1)`]]) field.wrap.querySelector("label").firstChild.textContent = text;
613
+ for (const [field, text] of [[row.color, `Stop ${index + 1} color`], [row.position, `Stop ${index + 1} position`]]) field.wrap.querySelector("label").firstChild.textContent = text;
609
614
  const remove = h("button", { type: "button", class: "quiet", disabled: stopRows.length <= 2 || undefined, "aria-label": `Remove stop ${index + 1}`, onclick: () => { stopRows.splice(index, 1); backgroundDirty = true; renderStops(); } }, "Remove");
610
615
  return h("div", { class: "opf-dc-stop" }, row.color.wrap, row.position.wrap, remove);
611
616
  }),
@@ -626,15 +631,15 @@ export function createDesignControls(container, options = {}) {
626
631
  gradientPanel.append(angle.wrap, stopsBox, addStopButton);
627
632
 
628
633
  const imagePanel = panel("image", "Image");
629
- const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide around the focus point, contain shows the whole image, stretch fills it exactly, tile repeats it." });
634
+ const fit = selectField("bg-fit", "Fit", { help: "Cover crops to fill; contain shows it all; tile repeats it" });
630
635
  fit.setOptions(IMAGE_BACKGROUND_FITS.map((value) => ({ value, label: titleCase(value) })));
631
636
  const bgAlt = textField("bg-alt", "Background alt text", {
632
- help: "Describe the photo when it carries meaning: PowerPoint then gets a picture at the back that screen readers read. Leave it empty for a decorative background.",
637
+ help: "Leave empty for a decorative background",
633
638
  });
634
- const focusX = numberField("bg-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05 });
635
- const focusY = numberField("bg-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05 });
639
+ const focusX = numberField("bg-focus-x", "Focus X", { min: 0, max: 1, step: 0.05, help: "0 left to 1 right" });
640
+ const focusY = numberField("bg-focus-y", "Focus Y", { min: 0, max: 1, step: 0.05, help: "0 top to 1 bottom" });
636
641
  const overlay = overlayFields("bg-overlay", "Overlay");
637
- const bgRecolor = selectField("bg-recolor", "Recolor", { empty: "None", help: "Grayscale, or a duotone from a dark to a light color. Changes the picture's pixels only, not the overlay." });
642
+ const bgRecolor = selectField("bg-recolor", "Recolor", { empty: "None", help: "Changes the picture, not the overlay" });
638
643
  bgRecolor.setOptions([
639
644
  { value: "grayscale", label: "Grayscale" },
640
645
  { value: "duotone", label: "Duotone" },
@@ -665,12 +670,12 @@ export function createDesignControls(container, options = {}) {
665
670
  const spec = imageSpec(undefined), target = scoped();
666
671
  return (ref, doc) => prepareBackground(doc, { ...spec, src: ref }, target);
667
672
  },
668
- help: "An asset reference, a web address or a data address. Press Enter or choose a file to apply it with the settings here.",
673
+ help: "Asset, URL or data URL. Enter applies.",
669
674
  });
670
675
  imagePanel.append(bgImage.wrap, fit.wrap, bgAlt.wrap, focusX.wrap, focusY.wrap, bgRecolor.wrap, bgDuoDark.wrap, bgDuoLight.wrap, overlay.wrap);
671
676
 
672
677
  const patternPanel = panel("pattern", "Pattern");
673
- const preset = selectField("bg-preset", "Pattern", { help: "The 54 PowerPoint presets. PPTX export writes them as native pattern fills." });
678
+ const preset = selectField("bg-preset", "Pattern", { help: "PowerPoint presets, exported as native fills" });
674
679
  preset.setOptions(Object.entries(PATTERN_GROUPS).flatMap(([name, ids]) => ids.map((id) => ({ value: id, label: id, group: name }))));
675
680
  const patternFg = textField("bg-pattern-fg", "Pattern color", { list: true, placeholder: "#000000 or text" });
676
681
  patternFg.setList(colorList);
@@ -678,7 +683,7 @@ export function createDesignControls(container, options = {}) {
678
683
  patternBg.setList(colorList);
679
684
  patternPanel.append(preset.wrap, patternFg.wrap, patternBg.wrap);
680
685
 
681
- const opacity = numberField("bg-opacity", "Opacity (0 to 1, optional)", { min: 0, max: 1, step: 0.05 });
686
+ const opacity = numberField("bg-opacity", "Opacity", { min: 0, max: 1, step: 0.05 });
682
687
  const opacityValue = () => (opacity.input.value === "" ? undefined : Number(opacity.input.value));
683
688
  const panels = { theme: themePanel, solid: solidPanel, gradient: gradientPanel, image: imagePanel, pattern: patternPanel };
684
689
 
@@ -762,13 +767,13 @@ export function createDesignControls(container, options = {}) {
762
767
  const pick = { which: "footer", zone: "center" };
763
768
  const which = () => pick.which;
764
769
  const zone = () => pick.zone;
765
- const furniture = selectField("hf-which", "Edit", { help: "The header runs along the top of every slide, the footer along the bottom." });
770
+ const furniture = selectField("hf-which", "Edit");
766
771
  furniture.setOptions([
767
772
  { value: "header", label: "Header" },
768
773
  { value: "footer", label: "Footer" },
769
774
  ]);
770
775
  furniture.set(pick.which);
771
- const zoneSelect = selectField("hf-zone", "Zone", { help: "A zone lays its parts out side by side, from the edge it sits on: image (or logo), text, social profiles, date." });
776
+ const zoneSelect = selectField("hf-zone", "Zone", { help: "Parts sit side by side from the edge: image, text, socials, date" });
772
777
  zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
773
778
  zoneSelect.set(pick.zone);
774
779
  furniture.select.addEventListener("change", () => {
@@ -781,7 +786,7 @@ export function createDesignControls(container, options = {}) {
781
786
  });
782
787
  // Hiding is for a slide that would otherwise show the deck's furniture; at the deck, clear the zones instead.
783
788
  const hide = checkField("hf-hide", "Hide it", {
784
- help: "On a slide: shows nothing instead of the presentation's. Uncheck to inherit it again.",
789
+ help: "Hide the presentation's on this slide",
785
790
  onChange: (checked) => run(() => switchDimension(editor, "headers-footers", { [which()]: checked ? false : null }, scoped()), checked ? `${titleCase(which())} hidden on this slide.` : `${titleCase(which())} shown again.`),
786
791
  });
787
792
  const edit = (fields, done) => run(() => setHeaderFooterZone(editor, which(), zone(), fields, scoped()), done);
@@ -792,7 +797,7 @@ export function createDesignControls(container, options = {}) {
792
797
  };
793
798
  const text = textField("hf-text", "Text", {
794
799
  multiline: true,
795
- help: "Literal text, with values added by Insert value ({{slide.number}}, {{deck.slideCount}}, {{slide.section}}, {{organization.name}}, {{speaker.name}}, {{deck.name}}). Shift+Enter starts a new line. Clear it to remove.",
800
+ help: "Shift+Enter for a new line. Clear to remove.",
796
801
  onCommit: (value) => {
797
802
  if (value === currentText()) return;
798
803
  edit({ text: value }, `${where()} text changed.`);
@@ -801,7 +806,7 @@ export function createDesignControls(container, options = {}) {
801
806
  // The menu inserts a variable token at the caret (or over the selection) as one edit, together with any text typed but not yet committed.
802
807
  const insertValue = selectField("hf-insert-value", "Insert value", {
803
808
  empty: "Choose a value…",
804
- help: "Adds a value to the text at the cursor. It is a {{ }} variable: slide number, slide count and section change on every slide.",
809
+ help: "Adds a {{ }} variable at the cursor",
805
810
  });
806
811
  insertValue.setOptions(ZONE_VALUES.map((value) => ({ value: value.name, label: value.label, title: value.token })));
807
812
  insertValue.select.addEventListener("change", () => {
@@ -827,37 +832,36 @@ export function createDesignControls(container, options = {}) {
827
832
  const kind = which(), place = zone(), target = scoped();
828
833
  return (ref, doc) => prepareHeaderFooterZone(doc, kind, place, { image: ref }, target);
829
834
  },
830
- help: "A picture in this zone, such as a partner mark or badge: an asset reference, a web address or a data address, or an organization logo (Insert logo, below). Press Enter to apply; clear the field to remove it.",
835
+ help: "Asset, URL or data URL, or Insert logo. Enter applies.",
831
836
  });
832
837
  // RR-71: a logo is the zone's image, written as a reference to the organization's logo (var:organization.logo.icon). Which artwork is drawn (light or dark) follows each slide's background.
833
- const logoShape = selectField("hf-logo-shape", "Logo shape", { help: "Icon is the mark alone and fits a zone best." });
838
+ const logoShape = selectField("hf-logo-shape", "Logo shape", { help: "Icon fits a zone best" });
834
839
  logoShape.setOptions(LOGO_SHAPES.map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
835
840
  logoShape.set("icon");
836
- const logoOrganization = selectField("hf-logo-organization", "Logo of", { empty: "Primary organization", help: "Choose another organization when the deck has several." });
841
+ const logoOrganization = selectField("hf-logo-organization", "Logo of", { empty: "Primary organization" });
837
842
  const insertLogoButton = button("Insert logo", () => {
838
843
  const shape = logoShape.select.value;
839
844
  const organization = logoOrganization.select.value || undefined;
840
845
  run(() => insertZoneLogo(editor, which(), zone(), { ...scoped(), shape, ...(organization ? { organization } : {}) }), `${where()} now shows the ${shape} logo (${logoReference({ ...(organization ? { organization } : {}), ...(shape !== "full" ? { shape } : {}) })}), replacing any image.`);
841
846
  });
842
847
  insertLogoButton.dataset.action = "insert-logo";
843
- const logoHelp = h("p", { class: "opf-dc-help", id: `${uid}-hf-logo-help` }, "Puts the organization's logo in this zone as its image. The logo comes from the organization's logo settings; add it under Logo, watermark and bullets.");
844
- insertLogoButton.setAttribute("aria-describedby", logoHelp.id);
845
- const logo = { wrap: h("fieldset", { class: "opf-dc-fieldset", "data-role": "hf-logo" }, h("legend", {}, "Logo"), logoShape.wrap, logoOrganization.wrap, h("div", { class: "opf-dc-actions" }, insertLogoButton), logoHelp) };
846
- const dateNow = checkField("hf-date-now", "Show the current date", {
847
- help: "Needs the host to supply today's date when it renders or exports; otherwise the part is reported as unresolved.",
848
+ insertLogoButton.title = "Uses the organization's logo as this zone's image";
849
+ const logo = { wrap: h("fieldset", { class: "opf-dc-fieldset", "data-role": "hf-logo" }, h("legend", {}, "Logo"), logoShape.wrap, logoOrganization.wrap, h("div", { class: "opf-dc-actions" }, insertLogoButton)) };
850
+ const dateNow = checkField("hf-date-now", "Today's date", {
851
+ help: "The host supplies the date when it renders or exports",
848
852
  onChange: (checked) => edit({ date: checked }, `${where()} date ${checked ? "shown" : "removed"}.`),
849
853
  });
850
854
  const dateFixed = textField("hf-date-fixed", "Fixed date", {
851
855
  placeholder: "2026-10-01",
852
- help: "A fixed date written YYYY-MM-DD (formatted by the date format), or literal text when there is no date format. Replaces the current date.",
856
+ help: "YYYY-MM-DD, or text",
853
857
  onCommit: (value) => edit({ date: value.trim() }, `${where()} date changed.`),
854
858
  });
855
859
  const dateFormat = textField("hf-date-format", "Date format", {
856
860
  placeholder: "MMMM d, yyyy",
857
- help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. Text in single quotes is literal. Clear it for the default (M/d/yyyy).`,
861
+ help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. 'Quoted' text is literal.`,
858
862
  onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
859
863
  });
860
- const socials = checkField("hf-socials", "Show the organization's social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
864
+ const socials = checkField("hf-socials", "Social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
861
865
  let shownContext;
862
866
  const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
863
867
  const warnings = h("ul", { class: "opf-dc-warnings", "aria-label": "Header and footer warnings" });
@@ -916,17 +920,16 @@ export function createDesignControls(container, options = {}) {
916
920
  // RR-71: logos live on the organization. One image serves every shape, or each of the four shapes (full, stacked, icon,
917
921
  // wordmark) has its own, optionally split by the background it is drawn on (light or dark).
918
922
  const logoOrganizationPick = selectField("org-logo-organization", "Organization to edit", {
919
- help: "Whose logo to edit. The primary organization's full logo is drawn on cover and section slides.",
920
923
  onChange: () => sync(),
921
924
  });
922
925
  const logoShapePick = selectField("org-logo-shape", "Shape to edit", {
923
- help: "All shapes sets one image for every place the logo is drawn. Set a shape to give it its own image; a missing shape falls back to the full logo.",
926
+ help: "A missing shape falls back to the full logo",
924
927
  onChange: () => sync(),
925
928
  });
926
929
  logoShapePick.setOptions(["all", ...LOGO_SHAPES].map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
927
930
  logoShapePick.set("all");
928
931
  const logoBackgroundPick = selectField("org-logo-background", "Background to edit", {
929
- help: "Use a different image on light and on dark backgrounds. Each slide draws the one that suits its background; a missing one uses the other.",
932
+ help: "Each slide draws the one that suits its background",
930
933
  onChange: () => sync(),
931
934
  });
932
935
  logoBackgroundPick.setOptions(LOGO_BACKGROUNDS.map((value) => ({ value, label: LOGO_BACKGROUND_LABELS[value] })));
@@ -941,7 +944,7 @@ export function createDesignControls(container, options = {}) {
941
944
  const { shape, ...rest } = logoTarget();
942
945
  return (ref, doc) => prepareOrganizationLogo(doc, shape, ref, rest);
943
946
  },
944
- help: "An asset reference, a web address or a data address for the chosen shape and background. Press Enter to apply; clear the field to remove it.",
947
+ help: "Asset, URL or data URL. Enter applies; clear to remove.",
945
948
  });
946
949
  const logoSummary = h("ul", { class: "opf-dc-summary", "aria-label": "Logo shapes set" });
947
950
  const organizationLogoGroup = h("fieldset", { class: "opf-dc-fieldset", "data-role": "organization-logo" }, h("legend", {}, "Organization logo"), logoOrganizationPick.wrap, logoShapePick.wrap, logoBackgroundPick.wrap, orgLogo.wrap, logoSummary);
@@ -980,12 +983,11 @@ export function createDesignControls(container, options = {}) {
980
983
  // The logo that covers, sections and picture bullets draw: the primary organization's by default, another organization's or shape, or none.
981
984
  const choiceOrganizationPick = selectField("design-logo-organization", "Logo for covers, sections and bullets", {
982
985
  empty: "Primary organization",
983
- help: "Which organization's logo covers and sections draw, and picture bullets use. Choose None to draw no logo.",
984
986
  onChange: () => applyLogoChoice(),
985
987
  });
986
988
  const choiceShapePick = selectField("design-logo-shape", "Shape to draw", {
987
989
  empty: "Default (full; icon for bullets)",
988
- help: "Choose a shape to draw it instead of the default for that place. A missing shape falls back to the full logo.",
990
+ help: "A missing shape falls back to the full logo",
989
991
  onChange: () => applyLogoChoice(),
990
992
  });
991
993
  choiceShapePick.setOptions(LOGO_SHAPES.map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
@@ -1018,16 +1020,17 @@ export function createDesignControls(container, options = {}) {
1018
1020
  };
1019
1021
  const bullet = selectField("list-bullet", "List bullets", {
1020
1022
  empty: "Default (character)",
1021
- help: "Picture bullets draw the logo as the marker and need a logo.",
1022
- onChange: (value) => run(() => setDesignOption(editor, "listBullet", value === "" ? null : value, scoped()), value === "image" ? "Picture bullets on." : "Character bullets."),
1023
+ help: "Picture bullets need a logo",
1024
+ onChange: (value) => run(() => setDesignOption(editor, "listBullet", value === "" ? null : value, scoped()), value === "image" ? "Picture bullets on." : value === "none" ? "No bullets." : "Character bullets."),
1023
1025
  });
1024
1026
  bullet.setOptions([
1025
1027
  { value: "character", label: "Character" },
1026
1028
  { value: "image", label: "Picture (the logo)" },
1029
+ { value: "none", label: "None (no bullets)" },
1027
1030
  ]);
1028
1031
  const accent = textField("accent-font", "Accent font", {
1029
1032
  placeholder: "Font family name",
1030
- help: "The font for accent text such as tags. Press Enter to apply; clear the field to remove it.",
1033
+ help: "For accent text such as tags. Enter applies.",
1031
1034
  onCommit: (value) => run(() => setDesignOption(editor, "accentFont", value.trim() === "" ? null : value.trim(), scoped()), value.trim() === "" ? "Accent font removed." : "Accent font changed."),
1032
1035
  });
1033
1036
  const watermarkSource = imageCluster("watermark", "Watermark", {
@@ -1040,7 +1043,7 @@ export function createDesignControls(container, options = {}) {
1040
1043
  // FA-13: a watermark is an image or a text stamp; the text field and the image cluster replace each other.
1041
1044
  const watermarkText = textField("watermark-text", "Watermark text", {
1042
1045
  placeholder: "DRAFT",
1043
- help: "A text stamp centered on the slide, drawn diagonally in the theme text color at the opacity below. Press Enter to apply; clear the field to remove it. It replaces a watermark image.",
1046
+ help: "A diagonal text stamp; replaces a watermark image",
1044
1047
  onCommit: (value) => run(() => {
1045
1048
  const text = value.trim();
1046
1049
  const current = getDesignOption(editor.presentation, "watermark", scoped()).value;
@@ -1049,13 +1052,13 @@ export function createDesignControls(container, options = {}) {
1049
1052
  return setDesignOption(editor, "watermark", { text, opacity }, scoped());
1050
1053
  }, value.trim() === "" ? "Watermark removed." : "Watermark text set."),
1051
1054
  });
1052
- const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity (0 to 1)", {
1055
+ const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity", {
1053
1056
  min: 0,
1054
1057
  max: 1,
1055
1058
  step: 0.05,
1056
1059
  onCommit: (value) => run(() => setDesignOption(editor, "watermark", { opacity: value === null ? 0.1 : value }, scoped()), "Watermark opacity changed."),
1057
1060
  });
1058
- const watermarkOff = checkField("watermark-off", "Hide the inherited watermark", {
1061
+ const watermarkOff = checkField("watermark-off", "Hide inherited watermark", {
1059
1062
  onChange: (checked) => run(() => setDesignOption(editor, "watermark", checked ? false : null, scoped()), checked ? "Watermark hidden." : "Watermark restored."),
1060
1063
  });
1061
1064
  body.append(organizationLogoGroup, logoChoiceGroup, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkText.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
@@ -1101,9 +1104,9 @@ export function createDesignControls(container, options = {}) {
1101
1104
  };
1102
1105
  enumField("titleAlignment", "Title alignment", ["left", "center", "right"], {});
1103
1106
  enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
1104
- enumField("contentDirection", "Content direction", ["horizontal", "vertical"], { horizontal: "Horizontal (row)", vertical: "Vertical (column)" }, "The axis parallel content is arranged along when a slide sets no arrangement of its own.");
1105
- enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits next to supporting content.");
1106
- enumField("imageFit", "Image fit", IMAGE_FITS, { cover: "Cover (crop to fill)", contain: "Contain (whole image)", stretch: "Stretch to the frame" }, "How pictures fill their frames when an image block does not choose its own fit.");
1107
+ enumField("contentDirection", "Content direction", ["horizontal", "vertical"], { horizontal: "Horizontal (row)", vertical: "Vertical (column)" }, "When a slide sets no arrangement");
1108
+ enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits");
1109
+ enumField("imageFit", "Image fit", IMAGE_FITS, { cover: "Cover (crop to fill)", contain: "Contain (whole image)", stretch: "Stretch to the frame" }, "When an image block sets no fit");
1107
1110
  const box = selectField("opt-contentBox", "Content box", {
1108
1111
  empty: "Default",
1109
1112
  onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
@@ -1131,13 +1134,13 @@ export function createDesignControls(container, options = {}) {
1131
1134
  field.set(currentSwitchValue(editor.presentation, dimension).value);
1132
1135
  });
1133
1136
  };
1134
- single("narrative", "Narrative", "narratives", "The narrative plan the deck points at (a catalog reference; a custom one is a record embedded in the document's catalogs). The slides do not change, and keep their beat links.");
1137
+ single("narrative", "Narrative", "narratives", "Authoring metadata; slides do not change");
1135
1138
  single("tone", "Tone", "tones");
1136
- single("purpose", "Purpose", "purposes", "What the deck is for. Authoring metadata: the slides do not change. A goal written in the JSON source shows as custom.");
1139
+ single("purpose", "Purpose", "purposes", "Authoring metadata; slides do not change");
1137
1140
  const audience = selectField("audience", "Audience", {
1138
1141
  multiple: true,
1139
1142
  size: 6,
1140
- help: "Hold Ctrl or Shift to choose more than one.",
1143
+ help: "Ctrl or Shift selects several",
1141
1144
  onChange: (values) => {
1142
1145
  if (!values.length) return complain("Choose at least one audience.");
1143
1146
  run(() => switchDimension(editor, "audiences", values, catalogOptions()), "Audience changed.");
@@ -1154,7 +1157,7 @@ export function createDesignControls(container, options = {}) {
1154
1157
 
1155
1158
  const owner = selectField("social-owner", "Socials belong to");
1156
1159
  const platform = selectField("social-platform", "Platform");
1157
- const handle = textField("social-handle", "Handle or address", { placeholder: "@handle", help: "Press Enter to apply." });
1160
+ const handle = textField("social-handle", "Handle or address", { placeholder: "@handle" });
1158
1161
  const applySocial = () => {
1159
1162
  const value = handle.input.value.trim();
1160
1163
  if (!value) return complain("Enter a handle for the platform.");
@@ -1183,20 +1186,17 @@ export function createDesignControls(container, options = {}) {
1183
1186
  const dynamic = {};
1184
1187
  if (built.selection) {
1185
1188
  const { body, details } = built.selection;
1186
- const summary = h("p", { class: "opf-dc-help", "data-role": "selection-summary" });
1187
1189
  const convert = selectField("convert", "Content type", {
1188
- help: "Converting keeps your text and never adds content. What a type cannot carry is named below.",
1189
1190
  onChange: (value) => {
1190
1191
  if (!value) return;
1191
1192
  const path = dynamic.blockPath;
1192
- run(() => switchDimension(editor, "blocks", value, { path, convert: true }), (change) => `Converted to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}.${change.loss?.length ? ` Not carried over: ${change.loss.join(", ")}.` : " Nothing was lost."}`);
1193
+ run(() => switchDimension(editor, "blocks", value, { path, convert: true }), (change) => `Converted to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}.${change.loss?.length ? ` Not carried over: ${change.loss.join(", ")}.` : ""}`);
1193
1194
  reselect(path);
1194
1195
  },
1195
1196
  });
1196
- const unavailable = h("ul", { class: "opf-dc-warnings", "aria-label": "Conversions not available" });
1197
- const replace = selectField("replace", "Replace with new content", {
1198
- empty: "Choose a type…",
1199
- help: "Starts from sample content. The old content is discarded; Undo brings it back.",
1197
+ const replace = selectField("replace", "Replace with", {
1198
+ empty: "New content…",
1199
+ help: "Replaces this content with a sample",
1200
1200
  onChange: (value) => {
1201
1201
  if (!value) return;
1202
1202
  const path = dynamic.blockPath;
@@ -1206,11 +1206,20 @@ export function createDesignControls(container, options = {}) {
1206
1206
  });
1207
1207
  replace.setOptions(BLOCK_REPLACEMENTS.map((value) => ({ value, label: BLOCK_KIND_LABELS[value] })));
1208
1208
  const chartType = selectField("chart-type", "Chart type", {
1209
- help: "Only types the chart's data can use as it is.",
1209
+ help: "Types this data supports",
1210
1210
  onChange: (value) => run(() => switchDimension(editor, "charts", value, { slideIndex: getSlide(), path: dynamic.blockPath, ...catalogOptions() }), `Chart type set to ${chartType.select.selectedOptions[0]?.textContent ?? value}.`),
1211
1211
  });
1212
- body.append(summary, convert.wrap, unavailable, contentControls.nodes.metrics, contentControls.nodes.list, chartType.wrap, contentControls.nodes.arrange, replace.wrap);
1213
- dynamic.selection = { details, summary, convert, unavailable, replace, chartType };
1212
+ // The block's path is internal: it is a copyable chip under Advanced (a host with its own Advanced, such as the Studio, turns it off).
1213
+ const pathChip = h("button", { type: "button", class: "opf-dc-path", "data-role": "selection-path", title: "Copy path" });
1214
+ pathChip.addEventListener("click", () => {
1215
+ const path = pathChip.dataset.path;
1216
+ const clipboard = doc.defaultView?.navigator?.clipboard;
1217
+ if (!path || !clipboard?.writeText) return complain("Copying is not available here.");
1218
+ clipboard.writeText(path).then(() => say(`Copied ${path}.`), () => complain("Could not copy the path."));
1219
+ });
1220
+ const advanced = h("details", { class: "opf-dc-advanced", "data-role": "selection-advanced", hidden: options.showPath === false || undefined }, h("summary", {}, "Advanced"), pathChip);
1221
+ body.append(contentControls.nodes.arrange, contentControls.nodes.list, convert.wrap, contentControls.nodes.metrics, chartType.wrap, replace.wrap, contentControls.nodes.image, advanced);
1222
+ dynamic.selection = { details, convert, replace, chartType, pathChip, advanced };
1214
1223
  }
1215
1224
 
1216
1225
  if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
@@ -1219,11 +1228,10 @@ export function createDesignControls(container, options = {}) {
1219
1228
 
1220
1229
  if (built.image) {
1221
1230
  const { body, details } = built.image;
1222
- const summary = h("p", { class: "opf-dc-help", "data-role": "image-summary" });
1223
1231
  const treat = (fields, done) => run(() => setImageTreatment(editor, dynamic.imagePath, fields), done);
1224
1232
  const fit = selectField("img-fit", "Fit", {
1225
1233
  empty: "Presentation default",
1226
- help: "Cover fills the frame around the focus point, contain shows the whole picture, stretch fills the frame exactly.",
1234
+ help: "Cover crops to fill; contain shows it all; stretch distorts",
1227
1235
  onChange: (value) => treat({ fit: value === "" ? null : value }, "Image fit changed."),
1228
1236
  });
1229
1237
  fit.setOptions(IMAGE_FITS.map((value) => ({ value, label: titleCase(value) })));
@@ -1231,20 +1239,20 @@ export function createDesignControls(container, options = {}) {
1231
1239
  const x = focusX.input.value, y = focusY.input.value;
1232
1240
  treat({ focus: x === "" && y === "" ? null : { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } }, "Focus point changed.");
1233
1241
  };
1234
- const focusX = numberField("img-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05, help: "The point a cover fit keeps in view. Clear both to center it.", onCommit: focusCommit });
1235
- const focusY = numberField("img-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05, onCommit: focusCommit });
1242
+ const focusX = numberField("img-focus-x", "Focus X", { min: 0, max: 1, step: 0.05, help: "0 left to 1 right; the point a cover fit keeps in view", onCommit: focusCommit });
1243
+ const focusY = numberField("img-focus-y", "Focus Y", { min: 0, max: 1, step: 0.05, help: "0 top to 1 bottom", onCommit: focusCommit });
1236
1244
  const shape = selectField("img-shape", "Shape", { empty: "Rectangle", onChange: (value) => treat({ shape: value === "" ? null : value }, "Image shape changed.") });
1237
1245
  shape.setOptions(IMAGE_SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
1238
- const radius = numberField("img-radius", "Corner radius (0 to 0.5 of the shorter side)", { min: 0, max: 0.5, step: 0.02, onCommit: (value) => treat({ cornerRadius: value }, "Corner radius changed.") });
1239
- const aspect = numberField("img-aspect", "Frame aspect ratio (width / height)", { min: 0.1, max: 10, step: 0.01, help: "For example 1.78 for 16:9, 1 for a square. Clear it to use the whole region.", onCommit: (value) => treat({ aspectRatio: value }, "Frame aspect ratio changed.") });
1240
- const opacity = numberField("img-opacity", "Opacity (0 to 1)", { min: 0, max: 1, step: 0.05, onCommit: (value) => treat({ opacity: value }, "Image opacity changed.") });
1246
+ const radius = numberField("img-radius", "Corner radius", { min: 0, max: 0.5, step: 0.02, help: "Share of the shorter side", onCommit: (value) => treat({ cornerRadius: value }, "Corner radius changed.") });
1247
+ const aspect = numberField("img-aspect", "Aspect ratio", { min: 0.1, max: 10, step: 0.01, placeholder: "width / height", help: "1.78 for 16:9, 1 for a square. Clear to fill the region.", onCommit: (value) => treat({ aspectRatio: value }, "Frame aspect ratio changed.") });
1248
+ const opacity = numberField("img-opacity", "Opacity", { min: 0, max: 1, step: 0.05, onCommit: (value) => treat({ opacity: value }, "Image opacity changed.") });
1241
1249
  const borderCommit = () => {
1242
1250
  const ref = borderColor.input.value.trim();
1243
1251
  treat({ border: ref ? { color: ref, width: borderWidth.input.value === "" ? 2 : Number(borderWidth.input.value) } : null }, ref ? "Border changed." : "Border removed.");
1244
1252
  };
1245
- const borderColor = textField("img-border-color", "Border color", { list: true, placeholder: "dark1 or #1F2937", help: "Clear it to remove the border.", onCommit: borderCommit });
1253
+ const borderColor = textField("img-border-color", "Border color", { list: true, placeholder: "dark1 or #1F2937", help: "Clear to remove", onCommit: borderCommit });
1246
1254
  borderColor.setList(COLOR_NAMES);
1247
- const borderWidth = numberField("img-border-width", "Border width (0 to 64)", { min: 0, max: 64, step: 1, onCommit: borderCommit });
1255
+ const borderWidth = numberField("img-border-width", "Border width", { min: 0, max: 64, step: 1, onCommit: borderCommit });
1248
1256
  const recolorCommit = () => {
1249
1257
  const kind = recolor.select.value;
1250
1258
  if (kind === "duotone" && (!duoDark.input.value.trim() || !duoLight.input.value.trim())) return say("Enter the dark and light duotone colors to apply it.");
@@ -1268,19 +1276,18 @@ export function createDesignControls(container, options = {}) {
1268
1276
  );
1269
1277
  };
1270
1278
  const placeEdge = selectField("img-place-edge", "Bleed to an edge", {
1271
- empty: "No: flows with the content",
1272
- help: "The image takes a band along that slide edge, edge to edge, and the title and content use the rest of the slide.",
1279
+ empty: "None",
1280
+ help: "Takes a band along that slide edge",
1273
1281
  onChange: placementCommit,
1274
1282
  });
1275
- const placeSize = numberField("img-place-size", "Share of the slide (0.1 to 0.9)", { min: 0.1, max: 0.9, step: 0.05, onCommit: placementCommit });
1276
- const placeInset = checkField("img-place-inset", "Inset inside the slide padding", { onChange: placementCommit });
1283
+ const placeSize = numberField("img-place-size", "Band size", { min: 0.1, max: 0.9, step: 0.05, onCommit: placementCommit });
1284
+ const placeInset = checkField("img-place-inset", "Inset in padding", { onChange: placementCommit });
1277
1285
  const placement = h("fieldset", { class: "opf-dc-fieldset", "data-role": "img-placement" }, h("legend", {}, "Placement"), placeEdge.wrap, placeSize.wrap, placeInset.wrap);
1278
- body.append(summary, fit.wrap, focusX.wrap, focusY.wrap, shape.wrap, radius.wrap, aspect.wrap, opacity.wrap, borderColor.wrap, borderWidth.wrap, recolor.wrap, duoDark.wrap, duoLight.wrap, overlay.wrap, placement);
1286
+ body.append(fit.wrap, focusX.wrap, focusY.wrap, shape.wrap, radius.wrap, aspect.wrap, opacity.wrap, borderColor.wrap, borderWidth.wrap, recolor.wrap, duoDark.wrap, duoLight.wrap, overlay.wrap, placement);
1279
1287
  dynamic.image = {
1280
1288
  details,
1281
1289
  load(path) {
1282
1290
  const current = readImageTreatments(editor.presentation, path);
1283
- summary.textContent = `Image block (${current.path})`;
1284
1291
  fit.set(current.fit ?? "");
1285
1292
  focusX.set(current.focus ? String(current.focus.x) : "");
1286
1293
  focusY.set(current.focus ? String(current.focus.y) : "");
@@ -1298,7 +1305,7 @@ export function createDesignControls(container, options = {}) {
1298
1305
  for (const field of [duoDark, duoLight]) field.wrap.hidden = !(duotone || recolor.select.value === "duotone");
1299
1306
  overlay.set(current.overlay);
1300
1307
  placement.hidden = !current.placeable;
1301
- placeEdge.setOptions(IMAGE_EDGES.map((edge) => ({ value: edge, label: titleCase(edge), disabled: current.usedEdges.includes(edge), title: current.usedEdges.includes(edge) ? "Another image already bleeds to this edge." : undefined })));
1308
+ placeEdge.setOptions(IMAGE_EDGES.map((edge) => ({ value: edge, label: titleCase(edge), disabled: current.usedEdges.includes(edge), title: current.usedEdges.includes(edge) ? "Another image bleeds to this edge" : undefined })));
1302
1309
  placeEdge.set(current.placement?.edge ?? "");
1303
1310
  placeSize.set(current.placement?.size === undefined ? "" : String(current.placement.size));
1304
1311
  placeInset.set(current.placement?.inset === true);
@@ -1312,7 +1319,6 @@ export function createDesignControls(container, options = {}) {
1312
1319
  if (built.catalog) {
1313
1320
  const { body } = built.catalog;
1314
1321
  const findings = h("ul", { class: "opf-dc-warnings", "aria-label": "Catalog reference problems", "data-role": "reference-findings" });
1315
- const intro = h("p", { class: "opf-dc-help" }, "The document embeds every catalog record it uses when it is saved. Updating replaces an embedded record with the catalog's current one, only after you approve it.");
1316
1322
  const updates = h("ul", { class: "opf-dc-summary", "aria-label": "Catalog updates", "data-role": "catalog-updates" });
1317
1323
  const review = { changes: [] };
1318
1324
  const check = button("Check for catalog updates", () => {
@@ -1346,7 +1352,8 @@ export function createDesignControls(container, options = {}) {
1346
1352
  applyButton.hidden = review.changes.length === 0;
1347
1353
  };
1348
1354
  renderUpdates();
1349
- body.append(intro, findings, h("div", { class: "opf-dc-actions" }, check, applyButton), updates);
1355
+ check.title = "Compares embedded records with the catalog; nothing changes until you apply";
1356
+ body.append(findings, h("div", { class: "opf-dc-actions" }, check, applyButton), updates);
1350
1357
  syncs.push(() => {
1351
1358
  const list = referenceFindings(editor.validation);
1352
1359
  findings.replaceChildren(
@@ -1372,10 +1379,9 @@ export function createDesignControls(container, options = {}) {
1372
1379
 
1373
1380
  if (built.table) {
1374
1381
  const { body, details } = built.table;
1375
- const summary = h("p", { class: "opf-dc-help", "data-role": "table-summary" });
1376
1382
  const applyStyle = (change, done) => run(() => setTableStyle(editor, dynamic.tablePath, change), done);
1377
1383
  const headerStyle = selectField("table-header", "Header row", {
1378
- help: "The header fill. Theme uses the primary color; Plain looks like the body.",
1384
+ help: "Theme uses the primary color",
1379
1385
  onChange: (value) => applyStyle({ ...dynamic.tableStyle, header: value }, "Table header changed."),
1380
1386
  });
1381
1387
  headerStyle.setOptions([
@@ -1384,7 +1390,6 @@ export function createDesignControls(container, options = {}) {
1384
1390
  { value: "accent", label: "Accent color" },
1385
1391
  ]);
1386
1392
  const banding = checkField("table-banding", "Banded rows", {
1387
- help: "Alternates the fill of body rows. Text color adjusts automatically.",
1388
1393
  onChange: (checked) => applyStyle({ ...dynamic.tableStyle, banding: checked }, checked ? "Banded rows on." : "Banded rows off."),
1389
1394
  });
1390
1395
  const borders = selectField("table-borders", "Borders", {
@@ -1397,10 +1402,10 @@ export function createDesignControls(container, options = {}) {
1397
1402
  { value: "none", label: "None" },
1398
1403
  ]);
1399
1404
  const resetStyle = button("Reset table style", () => applyStyle("theme", "Table style reset to the theme."), { quiet: true });
1400
- const cellLabel = h("p", { class: "opf-dc-help", "data-role": "cell-summary" });
1405
+ const cellLabel = h("p", { class: "opf-dc-cell-label", "data-role": "cell-summary" });
1401
1406
  const columns = numberField("span-columns", "Columns to merge", { min: 1, step: 1 });
1402
1407
  const rows = numberField("span-rows", "Rows to merge", { min: 1, step: 1 });
1403
- const join = checkField("merge-join", "Keep text from the merged cells", { help: "Without this, merging cells that hold text is refused so nothing is hidden." });
1408
+ const join = checkField("merge-join", "Keep merged text", { help: "Otherwise merging cells that hold text is refused" });
1404
1409
  const merge = button("Merge cells", () => {
1405
1410
  const cell = dynamic.cell;
1406
1411
  if (!cell) return;
@@ -1418,7 +1423,7 @@ export function createDesignControls(container, options = {}) {
1418
1423
  const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap, bold.wrap);
1419
1424
  // FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
1420
1425
  const alt = textField("table-alt", "Alt text", {
1421
- help: "What the table shows: its point and the key numbers, not \"a table\". Optional, because a screen reader reads a table cell by cell. Press Enter to apply.",
1426
+ help: "Its point and key numbers. Enter applies.",
1422
1427
  placeholder: "What the table shows",
1423
1428
  onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
1424
1429
  });
@@ -1427,8 +1432,8 @@ export function createDesignControls(container, options = {}) {
1427
1432
  onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
1428
1433
  });
1429
1434
  const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1430
- body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1431
- dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, bold, join };
1435
+ body.append(alt.wrap, decorative.wrap, ...styleControls);
1436
+ dynamic.table = { details, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, bold, join };
1432
1437
  }
1433
1438
 
1434
1439
  // --- sync -----------------------------------------------------------------------------------------
@@ -1439,24 +1444,31 @@ export function createDesignControls(container, options = {}) {
1439
1444
  const blockPath = selected ? blockPathForSelection(document_, selected) : undefined;
1440
1445
  const content = blockPath ? readBlockContent(document_, blockPath) : undefined;
1441
1446
  dynamic.blockPath = blockPath;
1447
+ // A selected group has no content of its own, but it can be arranged (grouped with the next block, ungrouped).
1448
+ const groupPath = !blockPath && selected && isGroupBlock(document_, selected) ? selected : undefined;
1442
1449
  const ui = dynamic.selection;
1443
1450
  if (ui) {
1444
- ui.details.hidden = !content;
1451
+ ui.details.hidden = !content && !groupPath;
1452
+ const path = blockPath ?? groupPath;
1453
+ ui.pathChip.dataset.path = path ?? "";
1454
+ ui.pathChip.replaceChildren(h("code", {}, path ?? ""), iconNode(doc, "Copy", 14));
1455
+ ui.pathChip.setAttribute("aria-label", `Copy path ${path ?? ""}`);
1456
+ for (const field of [ui.convert, ui.replace]) field.wrap.hidden = !content;
1457
+ ui.chartType.wrap.hidden = true;
1445
1458
  if (content) {
1446
1459
  const targets = blockConversionTargets(document_, blockPath);
1447
- ui.summary.textContent = `${BLOCK_KIND_LABELS[content.kind] ?? content.kind} block (${blockPath})`;
1460
+ // Every type is listed; one this content cannot become is disabled, with the reason as its tooltip and accessible description.
1448
1461
  ui.convert.setOptions([
1449
1462
  { value: "", label: `${BLOCK_KIND_LABELS[content.kind] ?? content.kind} (current)` },
1450
1463
  ...targets.map((target) => ({
1451
1464
  value: target.kind,
1452
- label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : `${target.label} (unavailable)`,
1465
+ label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : target.label,
1453
1466
  disabled: !target.available,
1454
- title: target.reason,
1467
+ title: target.available ? undefined : `${target.label}: ${shortReason(target.reason)}`,
1455
1468
  })),
1456
1469
  ]);
1457
1470
  ui.convert.set("");
1458
1471
  ui.convert.wrap.hidden = targets.length === 0;
1459
- ui.unavailable.replaceChildren(...targets.filter((target) => !target.available).map((target) => h("li", {}, `${target.label}: ${target.reason}`)));
1460
1472
  ui.replace.set("");
1461
1473
  const charts = content.kind === "chart" ? compatibleChartTypes(document_, { slideIndex: getSlide(), path: blockPath, ...catalogOptions() }) : [];
1462
1474
  ui.chartType.wrap.hidden = charts.length === 0;
@@ -1485,7 +1497,6 @@ export function createDesignControls(container, options = {}) {
1485
1497
  const inline = Array.isArray(tableValue?.rows);
1486
1498
  tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
1487
1499
  if (tablePath && !tableUi.details.hidden) {
1488
- tableUi.summary.textContent = `Table (${tablePath})`;
1489
1500
  const textAlternative = readTableAlt(tableValue);
1490
1501
  tableUi.alt.set(textAlternative.alt);
1491
1502
  tableUi.alt.input.disabled = textAlternative.decorative;
@@ -1525,7 +1536,7 @@ export function createDesignControls(container, options = {}) {
1525
1536
  }
1526
1537
  }
1527
1538
  if (contentControls) {
1528
- contentControls.sync({ blockPath, content, selectedPath: selected, slideIndex: getSlide() });
1539
+ contentControls.sync({ blockPath, groupPath, content, selectedPath: selected, slideIndex: getSlide() });
1529
1540
  const slideContent = built["slide-content"];
1530
1541
  if (slideContent) slideContent.details.hidden = [...contentControls.slideNode.children].every((node) => node.hidden);
1531
1542
  }