@openpresentation/opf-editor 0.28.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 (53) hide show
  1. package/README.md +20 -6
  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 +134 -124
  10. package/dist/exact-source.d.ts +30 -0
  11. package/dist/font-gate.js +20 -1
  12. package/dist/json-editor.js +8 -2
  13. package/dist/json-options.d.ts +2 -1
  14. package/dist/json-options.js +7 -6
  15. package/dist/layout-templates.d.ts +8 -3
  16. package/dist/layout-templates.js +30 -16
  17. package/dist/numbering-panel.js +2 -3
  18. package/dist/react.d.ts +12 -4
  19. package/dist/react.js +39 -5
  20. package/dist/slide-adapter.d.ts +49 -0
  21. package/dist/slide-adapter.js +289 -0
  22. package/dist/slide-edits.d.ts +46 -0
  23. package/dist/slide-edits.js +629 -0
  24. package/dist/slide-manager.js +2 -1
  25. package/dist/slide-scene.d.ts +96 -0
  26. package/dist/slide-scene.js +413 -0
  27. package/dist/source-checker.js +45 -6
  28. package/dist/source-completions.js +6 -4
  29. package/dist/source-pane.d.ts +18 -6
  30. package/dist/source-pane.js +45 -0
  31. package/dist/source-worker.js +8 -4
  32. package/dist/studio-canvas.d.ts +34 -0
  33. package/dist/studio-canvas.js +586 -0
  34. package/dist/studio-commands.d.ts +15 -0
  35. package/dist/studio-commands.js +155 -0
  36. package/dist/studio-design.d.ts +16 -0
  37. package/dist/studio-design.js +354 -0
  38. package/dist/studio-kit/dom.d.ts +16 -0
  39. package/dist/studio-kit/dom.js +42 -6
  40. package/dist/studio-layers.d.ts +12 -0
  41. package/dist/studio-layers.js +218 -0
  42. package/dist/studio.d.ts +31 -0
  43. package/dist/studio.js +1608 -0
  44. package/dist/styles.css +1546 -117
  45. package/dist/thumbnail-cache.d.ts +2 -0
  46. package/dist/thumbnail-cache.js +10 -1
  47. package/dist/workspace-measure.js +6 -4
  48. package/dist/workspace-parts.js +20 -0
  49. package/dist/workspace-shell.js +25 -15
  50. package/dist/workspace-theme.js +3 -0
  51. package/dist/workspace.d.ts +88 -9
  52. package/dist/workspace.js +210 -49
  53. package/package.json +29 -18
@@ -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,7 +1020,7 @@ 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. No bullets draws no marker and no indent, for label-and-description lists.",
1023
+ help: "Picture bullets need a logo",
1022
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([
@@ -1028,7 +1030,7 @@ export function createDesignControls(container, options = {}) {
1028
1030
  ]);
1029
1031
  const accent = textField("accent-font", "Accent font", {
1030
1032
  placeholder: "Font family name",
1031
- 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.",
1032
1034
  onCommit: (value) => run(() => setDesignOption(editor, "accentFont", value.trim() === "" ? null : value.trim(), scoped()), value.trim() === "" ? "Accent font removed." : "Accent font changed."),
1033
1035
  });
1034
1036
  const watermarkSource = imageCluster("watermark", "Watermark", {
@@ -1041,7 +1043,7 @@ export function createDesignControls(container, options = {}) {
1041
1043
  // FA-13: a watermark is an image or a text stamp; the text field and the image cluster replace each other.
1042
1044
  const watermarkText = textField("watermark-text", "Watermark text", {
1043
1045
  placeholder: "DRAFT",
1044
- 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",
1045
1047
  onCommit: (value) => run(() => {
1046
1048
  const text = value.trim();
1047
1049
  const current = getDesignOption(editor.presentation, "watermark", scoped()).value;
@@ -1050,13 +1052,13 @@ export function createDesignControls(container, options = {}) {
1050
1052
  return setDesignOption(editor, "watermark", { text, opacity }, scoped());
1051
1053
  }, value.trim() === "" ? "Watermark removed." : "Watermark text set."),
1052
1054
  });
1053
- const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity (0 to 1)", {
1055
+ const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity", {
1054
1056
  min: 0,
1055
1057
  max: 1,
1056
1058
  step: 0.05,
1057
1059
  onCommit: (value) => run(() => setDesignOption(editor, "watermark", { opacity: value === null ? 0.1 : value }, scoped()), "Watermark opacity changed."),
1058
1060
  });
1059
- const watermarkOff = checkField("watermark-off", "Hide the inherited watermark", {
1061
+ const watermarkOff = checkField("watermark-off", "Hide inherited watermark", {
1060
1062
  onChange: (checked) => run(() => setDesignOption(editor, "watermark", checked ? false : null, scoped()), checked ? "Watermark hidden." : "Watermark restored."),
1061
1063
  });
1062
1064
  body.append(organizationLogoGroup, logoChoiceGroup, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkText.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
@@ -1102,9 +1104,9 @@ export function createDesignControls(container, options = {}) {
1102
1104
  };
1103
1105
  enumField("titleAlignment", "Title alignment", ["left", "center", "right"], {});
1104
1106
  enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
1105
- 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.");
1106
- enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits next to supporting content.");
1107
- 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");
1108
1110
  const box = selectField("opt-contentBox", "Content box", {
1109
1111
  empty: "Default",
1110
1112
  onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
@@ -1132,13 +1134,13 @@ export function createDesignControls(container, options = {}) {
1132
1134
  field.set(currentSwitchValue(editor.presentation, dimension).value);
1133
1135
  });
1134
1136
  };
1135
- 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");
1136
1138
  single("tone", "Tone", "tones");
1137
- 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");
1138
1140
  const audience = selectField("audience", "Audience", {
1139
1141
  multiple: true,
1140
1142
  size: 6,
1141
- help: "Hold Ctrl or Shift to choose more than one.",
1143
+ help: "Ctrl or Shift selects several",
1142
1144
  onChange: (values) => {
1143
1145
  if (!values.length) return complain("Choose at least one audience.");
1144
1146
  run(() => switchDimension(editor, "audiences", values, catalogOptions()), "Audience changed.");
@@ -1155,7 +1157,7 @@ export function createDesignControls(container, options = {}) {
1155
1157
 
1156
1158
  const owner = selectField("social-owner", "Socials belong to");
1157
1159
  const platform = selectField("social-platform", "Platform");
1158
- 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" });
1159
1161
  const applySocial = () => {
1160
1162
  const value = handle.input.value.trim();
1161
1163
  if (!value) return complain("Enter a handle for the platform.");
@@ -1184,20 +1186,17 @@ export function createDesignControls(container, options = {}) {
1184
1186
  const dynamic = {};
1185
1187
  if (built.selection) {
1186
1188
  const { body, details } = built.selection;
1187
- const summary = h("p", { class: "opf-dc-help", "data-role": "selection-summary" });
1188
1189
  const convert = selectField("convert", "Content type", {
1189
- help: "Converting keeps your text and never adds content. What a type cannot carry is named below.",
1190
1190
  onChange: (value) => {
1191
1191
  if (!value) return;
1192
1192
  const path = dynamic.blockPath;
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(", ")}.` : " 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(", ")}.` : ""}`);
1194
1194
  reselect(path);
1195
1195
  },
1196
1196
  });
1197
- const unavailable = h("ul", { class: "opf-dc-warnings", "aria-label": "Conversions not available" });
1198
- const replace = selectField("replace", "Replace with new content", {
1199
- empty: "Choose a type…",
1200
- 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",
1201
1200
  onChange: (value) => {
1202
1201
  if (!value) return;
1203
1202
  const path = dynamic.blockPath;
@@ -1207,11 +1206,20 @@ export function createDesignControls(container, options = {}) {
1207
1206
  });
1208
1207
  replace.setOptions(BLOCK_REPLACEMENTS.map((value) => ({ value, label: BLOCK_KIND_LABELS[value] })));
1209
1208
  const chartType = selectField("chart-type", "Chart type", {
1210
- help: "Only types the chart's data can use as it is.",
1209
+ help: "Types this data supports",
1211
1210
  onChange: (value) => run(() => switchDimension(editor, "charts", value, { slideIndex: getSlide(), path: dynamic.blockPath, ...catalogOptions() }), `Chart type set to ${chartType.select.selectedOptions[0]?.textContent ?? value}.`),
1212
1211
  });
1213
- body.append(summary, convert.wrap, unavailable, contentControls.nodes.metrics, contentControls.nodes.list, chartType.wrap, contentControls.nodes.arrange, replace.wrap);
1214
- 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 };
1215
1223
  }
1216
1224
 
1217
1225
  if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
@@ -1220,11 +1228,10 @@ export function createDesignControls(container, options = {}) {
1220
1228
 
1221
1229
  if (built.image) {
1222
1230
  const { body, details } = built.image;
1223
- const summary = h("p", { class: "opf-dc-help", "data-role": "image-summary" });
1224
1231
  const treat = (fields, done) => run(() => setImageTreatment(editor, dynamic.imagePath, fields), done);
1225
1232
  const fit = selectField("img-fit", "Fit", {
1226
1233
  empty: "Presentation default",
1227
- 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",
1228
1235
  onChange: (value) => treat({ fit: value === "" ? null : value }, "Image fit changed."),
1229
1236
  });
1230
1237
  fit.setOptions(IMAGE_FITS.map((value) => ({ value, label: titleCase(value) })));
@@ -1232,20 +1239,20 @@ export function createDesignControls(container, options = {}) {
1232
1239
  const x = focusX.input.value, y = focusY.input.value;
1233
1240
  treat({ focus: x === "" && y === "" ? null : { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } }, "Focus point changed.");
1234
1241
  };
1235
- 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 });
1236
- 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 });
1237
1244
  const shape = selectField("img-shape", "Shape", { empty: "Rectangle", onChange: (value) => treat({ shape: value === "" ? null : value }, "Image shape changed.") });
1238
1245
  shape.setOptions(IMAGE_SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
1239
- 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.") });
1240
- 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.") });
1241
- 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.") });
1242
1249
  const borderCommit = () => {
1243
1250
  const ref = borderColor.input.value.trim();
1244
1251
  treat({ border: ref ? { color: ref, width: borderWidth.input.value === "" ? 2 : Number(borderWidth.input.value) } : null }, ref ? "Border changed." : "Border removed.");
1245
1252
  };
1246
- 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 });
1247
1254
  borderColor.setList(COLOR_NAMES);
1248
- 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 });
1249
1256
  const recolorCommit = () => {
1250
1257
  const kind = recolor.select.value;
1251
1258
  if (kind === "duotone" && (!duoDark.input.value.trim() || !duoLight.input.value.trim())) return say("Enter the dark and light duotone colors to apply it.");
@@ -1269,19 +1276,18 @@ export function createDesignControls(container, options = {}) {
1269
1276
  );
1270
1277
  };
1271
1278
  const placeEdge = selectField("img-place-edge", "Bleed to an edge", {
1272
- empty: "No: flows with the content",
1273
- 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",
1274
1281
  onChange: placementCommit,
1275
1282
  });
1276
- 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 });
1277
- 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 });
1278
1285
  const placement = h("fieldset", { class: "opf-dc-fieldset", "data-role": "img-placement" }, h("legend", {}, "Placement"), placeEdge.wrap, placeSize.wrap, placeInset.wrap);
1279
- 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);
1280
1287
  dynamic.image = {
1281
1288
  details,
1282
1289
  load(path) {
1283
1290
  const current = readImageTreatments(editor.presentation, path);
1284
- summary.textContent = `Image block (${current.path})`;
1285
1291
  fit.set(current.fit ?? "");
1286
1292
  focusX.set(current.focus ? String(current.focus.x) : "");
1287
1293
  focusY.set(current.focus ? String(current.focus.y) : "");
@@ -1299,7 +1305,7 @@ export function createDesignControls(container, options = {}) {
1299
1305
  for (const field of [duoDark, duoLight]) field.wrap.hidden = !(duotone || recolor.select.value === "duotone");
1300
1306
  overlay.set(current.overlay);
1301
1307
  placement.hidden = !current.placeable;
1302
- 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 })));
1303
1309
  placeEdge.set(current.placement?.edge ?? "");
1304
1310
  placeSize.set(current.placement?.size === undefined ? "" : String(current.placement.size));
1305
1311
  placeInset.set(current.placement?.inset === true);
@@ -1313,7 +1319,6 @@ export function createDesignControls(container, options = {}) {
1313
1319
  if (built.catalog) {
1314
1320
  const { body } = built.catalog;
1315
1321
  const findings = h("ul", { class: "opf-dc-warnings", "aria-label": "Catalog reference problems", "data-role": "reference-findings" });
1316
- 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.");
1317
1322
  const updates = h("ul", { class: "opf-dc-summary", "aria-label": "Catalog updates", "data-role": "catalog-updates" });
1318
1323
  const review = { changes: [] };
1319
1324
  const check = button("Check for catalog updates", () => {
@@ -1347,7 +1352,8 @@ export function createDesignControls(container, options = {}) {
1347
1352
  applyButton.hidden = review.changes.length === 0;
1348
1353
  };
1349
1354
  renderUpdates();
1350
- 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);
1351
1357
  syncs.push(() => {
1352
1358
  const list = referenceFindings(editor.validation);
1353
1359
  findings.replaceChildren(
@@ -1373,10 +1379,9 @@ export function createDesignControls(container, options = {}) {
1373
1379
 
1374
1380
  if (built.table) {
1375
1381
  const { body, details } = built.table;
1376
- const summary = h("p", { class: "opf-dc-help", "data-role": "table-summary" });
1377
1382
  const applyStyle = (change, done) => run(() => setTableStyle(editor, dynamic.tablePath, change), done);
1378
1383
  const headerStyle = selectField("table-header", "Header row", {
1379
- help: "The header fill. Theme uses the primary color; Plain looks like the body.",
1384
+ help: "Theme uses the primary color",
1380
1385
  onChange: (value) => applyStyle({ ...dynamic.tableStyle, header: value }, "Table header changed."),
1381
1386
  });
1382
1387
  headerStyle.setOptions([
@@ -1385,7 +1390,6 @@ export function createDesignControls(container, options = {}) {
1385
1390
  { value: "accent", label: "Accent color" },
1386
1391
  ]);
1387
1392
  const banding = checkField("table-banding", "Banded rows", {
1388
- help: "Alternates the fill of body rows. Text color adjusts automatically.",
1389
1393
  onChange: (checked) => applyStyle({ ...dynamic.tableStyle, banding: checked }, checked ? "Banded rows on." : "Banded rows off."),
1390
1394
  });
1391
1395
  const borders = selectField("table-borders", "Borders", {
@@ -1398,10 +1402,10 @@ export function createDesignControls(container, options = {}) {
1398
1402
  { value: "none", label: "None" },
1399
1403
  ]);
1400
1404
  const resetStyle = button("Reset table style", () => applyStyle("theme", "Table style reset to the theme."), { quiet: true });
1401
- 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" });
1402
1406
  const columns = numberField("span-columns", "Columns to merge", { min: 1, step: 1 });
1403
1407
  const rows = numberField("span-rows", "Rows to merge", { min: 1, step: 1 });
1404
- 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" });
1405
1409
  const merge = button("Merge cells", () => {
1406
1410
  const cell = dynamic.cell;
1407
1411
  if (!cell) return;
@@ -1419,7 +1423,7 @@ export function createDesignControls(container, options = {}) {
1419
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);
1420
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.
1421
1425
  const alt = textField("table-alt", "Alt text", {
1422
- 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.",
1423
1427
  placeholder: "What the table shows",
1424
1428
  onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
1425
1429
  });
@@ -1428,8 +1432,8 @@ export function createDesignControls(container, options = {}) {
1428
1432
  onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
1429
1433
  });
1430
1434
  const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1431
- body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1432
- 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 };
1433
1437
  }
1434
1438
 
1435
1439
  // --- sync -----------------------------------------------------------------------------------------
@@ -1440,24 +1444,31 @@ export function createDesignControls(container, options = {}) {
1440
1444
  const blockPath = selected ? blockPathForSelection(document_, selected) : undefined;
1441
1445
  const content = blockPath ? readBlockContent(document_, blockPath) : undefined;
1442
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;
1443
1449
  const ui = dynamic.selection;
1444
1450
  if (ui) {
1445
- 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;
1446
1458
  if (content) {
1447
1459
  const targets = blockConversionTargets(document_, blockPath);
1448
- 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.
1449
1461
  ui.convert.setOptions([
1450
1462
  { value: "", label: `${BLOCK_KIND_LABELS[content.kind] ?? content.kind} (current)` },
1451
1463
  ...targets.map((target) => ({
1452
1464
  value: target.kind,
1453
- 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,
1454
1466
  disabled: !target.available,
1455
- title: target.reason,
1467
+ title: target.available ? undefined : `${target.label}: ${shortReason(target.reason)}`,
1456
1468
  })),
1457
1469
  ]);
1458
1470
  ui.convert.set("");
1459
1471
  ui.convert.wrap.hidden = targets.length === 0;
1460
- ui.unavailable.replaceChildren(...targets.filter((target) => !target.available).map((target) => h("li", {}, `${target.label}: ${target.reason}`)));
1461
1472
  ui.replace.set("");
1462
1473
  const charts = content.kind === "chart" ? compatibleChartTypes(document_, { slideIndex: getSlide(), path: blockPath, ...catalogOptions() }) : [];
1463
1474
  ui.chartType.wrap.hidden = charts.length === 0;
@@ -1486,7 +1497,6 @@ export function createDesignControls(container, options = {}) {
1486
1497
  const inline = Array.isArray(tableValue?.rows);
1487
1498
  tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
1488
1499
  if (tablePath && !tableUi.details.hidden) {
1489
- tableUi.summary.textContent = `Table (${tablePath})`;
1490
1500
  const textAlternative = readTableAlt(tableValue);
1491
1501
  tableUi.alt.set(textAlternative.alt);
1492
1502
  tableUi.alt.input.disabled = textAlternative.decorative;
@@ -1526,7 +1536,7 @@ export function createDesignControls(container, options = {}) {
1526
1536
  }
1527
1537
  }
1528
1538
  if (contentControls) {
1529
- contentControls.sync({ blockPath, content, selectedPath: selected, slideIndex: getSlide() });
1539
+ contentControls.sync({ blockPath, groupPath, content, selectedPath: selected, slideIndex: getSlide() });
1530
1540
  const slideContent = built["slide-content"];
1531
1541
  if (slideContent) slideContent.details.hidden = [...contentControls.slideNode.children].every((node) => node.hidden);
1532
1542
  }