@glw907/cairn-cms 0.56.1 → 0.57.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 (186) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -4
  3. package/dist/components/AdminLayout.svelte +3 -0
  4. package/dist/components/CairnAdmin.svelte +8 -1
  5. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  6. package/dist/components/CairnMediaLibrary.svelte +929 -0
  7. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  8. package/dist/components/ComponentForm.svelte +175 -46
  9. package/dist/components/ComponentForm.svelte.d.ts +22 -8
  10. package/dist/components/ComponentInsertDialog.svelte +379 -26
  11. package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
  12. package/dist/components/EditPage.svelte +477 -15
  13. package/dist/components/EditPage.svelte.d.ts +2 -0
  14. package/dist/components/MarkdownEditor.svelte +358 -1
  15. package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
  16. package/dist/components/MediaCaptureCard.svelte +135 -0
  17. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  18. package/dist/components/MediaFigureControl.svelte +247 -0
  19. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  20. package/dist/components/MediaHeroField.svelte +569 -0
  21. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  22. package/dist/components/MediaInsertPopover.svelte +449 -0
  23. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  24. package/dist/components/MediaPicker.svelte +257 -0
  25. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  26. package/dist/components/admin-icons.d.ts +12 -0
  27. package/dist/components/admin-icons.js +12 -0
  28. package/dist/components/cairn-admin.css +1045 -28
  29. package/dist/components/client-ingest.d.ts +142 -0
  30. package/dist/components/client-ingest.js +297 -0
  31. package/dist/components/editor-media.d.ts +11 -0
  32. package/dist/components/editor-media.js +206 -0
  33. package/dist/components/editor-placeholder.d.ts +26 -0
  34. package/dist/components/editor-placeholder.js +166 -0
  35. package/dist/components/index.d.ts +1 -0
  36. package/dist/components/index.js +1 -0
  37. package/dist/components/markdown-directives.d.ts +19 -0
  38. package/dist/components/markdown-directives.js +52 -0
  39. package/dist/components/markdown-format.d.ts +89 -0
  40. package/dist/components/markdown-format.js +255 -0
  41. package/dist/components/media-upload-outcome.d.ts +52 -0
  42. package/dist/components/media-upload-outcome.js +48 -0
  43. package/dist/content/compose.js +3 -0
  44. package/dist/content/frontmatter.js +17 -0
  45. package/dist/content/manifest.d.ts +4 -0
  46. package/dist/content/manifest.js +41 -1
  47. package/dist/content/media-refs.d.ts +7 -0
  48. package/dist/content/media-refs.js +52 -0
  49. package/dist/content/schema.d.ts +5 -2
  50. package/dist/content/schema.js +17 -0
  51. package/dist/content/types.d.ts +62 -11
  52. package/dist/content/validate.js +27 -0
  53. package/dist/delivery/public-routes.d.ts +16 -0
  54. package/dist/delivery/public-routes.js +46 -3
  55. package/dist/delivery/seo-fields.js +7 -1
  56. package/dist/delivery/seo.d.ts +2 -0
  57. package/dist/delivery/seo.js +3 -0
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +21 -0
  60. package/dist/doctor/index.d.ts +3 -1
  61. package/dist/doctor/index.js +11 -2
  62. package/dist/doctor/types.d.ts +3 -0
  63. package/dist/doctor/wrangler-config.d.ts +3 -0
  64. package/dist/doctor/wrangler-config.js +20 -0
  65. package/dist/env.d.ts +19 -0
  66. package/dist/env.js +26 -0
  67. package/dist/index.d.ts +1 -1
  68. package/dist/log/events.d.ts +1 -1
  69. package/dist/media/config.d.ts +24 -0
  70. package/dist/media/config.js +69 -0
  71. package/dist/media/delivery-bucket.d.ts +34 -0
  72. package/dist/media/delivery-bucket.js +10 -0
  73. package/dist/media/index.d.ts +6 -0
  74. package/dist/media/index.js +13 -0
  75. package/dist/media/library-entry.d.ts +30 -0
  76. package/dist/media/library-entry.js +17 -0
  77. package/dist/media/manifest.d.ts +44 -0
  78. package/dist/media/manifest.js +105 -0
  79. package/dist/media/naming.d.ts +18 -0
  80. package/dist/media/naming.js +112 -0
  81. package/dist/media/reconcile.d.ts +36 -0
  82. package/dist/media/reconcile.js +45 -0
  83. package/dist/media/reference.d.ts +12 -0
  84. package/dist/media/reference.js +33 -0
  85. package/dist/media/sniff.d.ts +18 -0
  86. package/dist/media/sniff.js +106 -0
  87. package/dist/media/store.d.ts +25 -0
  88. package/dist/media/store.js +16 -0
  89. package/dist/media/transform-url.d.ts +26 -0
  90. package/dist/media/transform-url.js +38 -0
  91. package/dist/media/usage.d.ts +48 -0
  92. package/dist/media/usage.js +90 -0
  93. package/dist/render/component-grammar.d.ts +20 -0
  94. package/dist/render/component-grammar.js +47 -3
  95. package/dist/render/component-validate.js +22 -0
  96. package/dist/render/pipeline.d.ts +2 -0
  97. package/dist/render/pipeline.js +13 -2
  98. package/dist/render/registry.d.ts +28 -0
  99. package/dist/render/registry.js +15 -0
  100. package/dist/render/remark-figure.d.ts +4 -0
  101. package/dist/render/remark-figure.js +103 -0
  102. package/dist/render/resolve-media.d.ts +34 -0
  103. package/dist/render/resolve-media.js +78 -0
  104. package/dist/render/sanitize-schema.d.ts +4 -2
  105. package/dist/render/sanitize-schema.js +5 -3
  106. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  107. package/dist/sveltekit/admin-dispatch.js +5 -0
  108. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  109. package/dist/sveltekit/cairn-admin.js +10 -2
  110. package/dist/sveltekit/content-routes.d.ts +68 -2
  111. package/dist/sveltekit/content-routes.js +461 -10
  112. package/dist/sveltekit/csrf.d.ts +16 -0
  113. package/dist/sveltekit/csrf.js +18 -0
  114. package/dist/sveltekit/guard.js +10 -3
  115. package/dist/sveltekit/index.d.ts +2 -1
  116. package/dist/sveltekit/index.js +1 -0
  117. package/dist/sveltekit/media-route.d.ts +12 -0
  118. package/dist/sveltekit/media-route.js +137 -0
  119. package/dist/vite/index.d.ts +3 -0
  120. package/dist/vite/index.js +7 -2
  121. package/package.json +8 -1
  122. package/src/lib/components/AdminLayout.svelte +3 -0
  123. package/src/lib/components/CairnAdmin.svelte +8 -1
  124. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  125. package/src/lib/components/ComponentForm.svelte +175 -46
  126. package/src/lib/components/ComponentInsertDialog.svelte +379 -26
  127. package/src/lib/components/EditPage.svelte +477 -15
  128. package/src/lib/components/MarkdownEditor.svelte +358 -1
  129. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  130. package/src/lib/components/MediaFigureControl.svelte +247 -0
  131. package/src/lib/components/MediaHeroField.svelte +569 -0
  132. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  133. package/src/lib/components/MediaPicker.svelte +257 -0
  134. package/src/lib/components/admin-icons.ts +12 -0
  135. package/src/lib/components/cairn-admin.css +37 -0
  136. package/src/lib/components/client-ingest.ts +380 -0
  137. package/src/lib/components/editor-media.ts +248 -0
  138. package/src/lib/components/editor-placeholder.ts +213 -0
  139. package/src/lib/components/index.ts +1 -0
  140. package/src/lib/components/markdown-directives.ts +57 -0
  141. package/src/lib/components/markdown-format.ts +307 -1
  142. package/src/lib/components/media-upload-outcome.ts +83 -0
  143. package/src/lib/content/compose.ts +3 -0
  144. package/src/lib/content/frontmatter.ts +16 -1
  145. package/src/lib/content/manifest.ts +44 -1
  146. package/src/lib/content/media-refs.ts +58 -0
  147. package/src/lib/content/schema.ts +31 -7
  148. package/src/lib/content/types.ts +78 -13
  149. package/src/lib/content/validate.ts +26 -1
  150. package/src/lib/delivery/public-routes.ts +52 -3
  151. package/src/lib/delivery/seo-fields.ts +6 -1
  152. package/src/lib/delivery/seo.ts +5 -0
  153. package/src/lib/doctor/checks-local.ts +22 -0
  154. package/src/lib/doctor/index.ts +21 -3
  155. package/src/lib/doctor/types.ts +3 -0
  156. package/src/lib/doctor/wrangler-config.ts +23 -0
  157. package/src/lib/env.ts +28 -0
  158. package/src/lib/index.ts +2 -0
  159. package/src/lib/log/events.ts +8 -1
  160. package/src/lib/media/config.ts +103 -0
  161. package/src/lib/media/delivery-bucket.ts +41 -0
  162. package/src/lib/media/index.ts +22 -0
  163. package/src/lib/media/library-entry.ts +58 -0
  164. package/src/lib/media/manifest.ts +122 -0
  165. package/src/lib/media/naming.ts +130 -0
  166. package/src/lib/media/reconcile.ts +79 -0
  167. package/src/lib/media/reference.ts +40 -0
  168. package/src/lib/media/sniff.ts +114 -0
  169. package/src/lib/media/store.ts +57 -0
  170. package/src/lib/media/transform-url.ts +58 -0
  171. package/src/lib/media/usage.ts +152 -0
  172. package/src/lib/render/component-grammar.ts +59 -3
  173. package/src/lib/render/component-validate.ts +22 -1
  174. package/src/lib/render/pipeline.ts +17 -3
  175. package/src/lib/render/registry.ts +38 -0
  176. package/src/lib/render/remark-figure.ts +132 -0
  177. package/src/lib/render/resolve-media.ts +96 -0
  178. package/src/lib/render/sanitize-schema.ts +5 -3
  179. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  180. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  181. package/src/lib/sveltekit/content-routes.ts +573 -12
  182. package/src/lib/sveltekit/csrf.ts +18 -0
  183. package/src/lib/sveltekit/guard.ts +12 -3
  184. package/src/lib/sveltekit/index.ts +6 -0
  185. package/src/lib/sveltekit/media-route.ts +158 -0
  186. package/src/lib/vite/index.ts +9 -2
@@ -21,30 +21,49 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
21
21
  import { beforeNavigate } from '$app/navigation';
22
22
  import { page } from '$app/state';
23
23
  import BlocksIcon from '@lucide/svelte/icons/blocks';
24
+ import SquarePenIcon from '@lucide/svelte/icons/square-pen';
24
25
  import LinkIcon from '@lucide/svelte/icons/link';
25
26
  import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
26
27
  import PanelRightIcon from '@lucide/svelte/icons/panel-right';
28
+ import ImageIcon from '@lucide/svelte/icons/image';
27
29
  import { useTopbar } from './topbar-context.js';
28
30
  import CsrfField from './CsrfField.svelte';
29
31
  import MarkdownEditor from './MarkdownEditor.svelte';
30
32
  import EditorToolbar from './EditorToolbar.svelte';
31
- import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
33
+ import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
32
34
  import LinkPicker from './LinkPicker.svelte';
33
35
  import WebLinkDialog from './WebLinkDialog.svelte';
36
+ import MediaInsertPopover from './MediaInsertPopover.svelte';
37
+ import MediaHeroField from './MediaHeroField.svelte';
38
+ import MediaFigureControl from './MediaFigureControl.svelte';
34
39
  import DeleteDialog from './DeleteDialog.svelte';
35
40
  import RenameDialog from './RenameDialog.svelte';
36
41
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
37
42
  import ShortcutsDialog from './ShortcutsDialog.svelte';
38
43
  import { cairnLinkCompletionSource } from './link-completion.js';
39
- import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
44
+ import {
45
+ findMediaImagesNeedingAlt,
46
+ unwrapCairnLink,
47
+ unwrapFigure,
48
+ updateFigure,
49
+ wrapImageInFigure,
50
+ type FigureAtImage,
51
+ type FigureRole,
52
+ type FormatKind,
53
+ } from './markdown-format.js';
40
54
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
41
55
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
42
- import type { ComponentRegistry } from '../render/registry.js';
56
+ import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
57
+ import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
43
58
  import type { IconSet } from '../render/glyph.js';
44
59
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
45
- import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
60
+ import type { TextareaField, TagsField, FreeTagsField, ImageValue } from '../content/types.js';
46
61
  import type { LinkResolve } from '../content/links.js';
47
62
  import { manifestLinkResolver } from '../content/manifest.js';
63
+ import type { MediaResolve } from '../render/resolve-media.js';
64
+ import { manifestMediaResolver } from '../render/resolve-media.js';
65
+ import type { MediaEntry } from '../media/manifest.js';
66
+ import { mediaLibraryEntry } from '../media/library-entry.js';
48
67
 
49
68
  interface Props {
50
69
  /** The edit load's data, plus the site name for the heading. */
@@ -52,7 +71,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
52
71
  /** The site's component registry, for the insert palette. */
53
72
  registry?: ComponentRegistry;
54
73
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
55
- render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
74
+ render?: (
75
+ md: string,
76
+ opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
77
+ ) => string | Promise<string>;
56
78
  /** The site's icon set, for the guided form's icon fields. */
57
79
  icons?: IconSet;
58
80
  /** The last content action's failure: the save guard's broken links, the delete guard's
@@ -124,6 +146,12 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
124
146
  if (target?.closest('dialog, #cairn-pane-write')) return;
125
147
  fieldsDirty = true;
126
148
  }
149
+ // Mark the details fields dirty without a form input event. The hero field writes its value to
150
+ // hidden inputs, whose programmatic value changes do not fire the form's oninput, so it signals
151
+ // dirty through this helper instead.
152
+ function markFieldsDirty() {
153
+ fieldsDirty = true;
154
+ }
127
155
 
128
156
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
129
157
  let editForm = $state<HTMLFormElement | null>(null);
@@ -263,6 +291,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
263
291
  // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
264
292
  // landing tab.
265
293
  let mode = $state<'write' | 'preview'>('write');
294
+ // Preview is read-only, so the insert controls the page renders into the toolbar disable with the
295
+ // strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
296
+ // is in scope before its first use.
297
+ const insertDisabled = $derived(mode === 'preview');
266
298
  let previewHtml = $state('');
267
299
  // True after a render call threw, so the preview pane can say so instead of going blank.
268
300
  let previewFailed = $state(false);
@@ -353,12 +385,65 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
353
385
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
354
386
  const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
355
387
  let insert = $state.raw<(text: string) => void>(() => {});
388
+ // The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
389
+ // routes through it to overwrite an edited block's source span. A no-op until then.
390
+ let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
391
+ // The editor's select-range seam, registered by MarkdownEditor on mount; the needs-alt notice's
392
+ // jump control routes through it to land the author on an image that lacks alt. A no-op until then.
393
+ let selectRange = $state.raw<(from: number, to: number) => void>(() => {});
356
394
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
357
395
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
358
396
  // reads it for the Text field's default.
359
397
  let getSelection = $state.raw<() => string>(() => '');
360
398
  // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
361
399
  let format = $state.raw<(kind: FormatKind) => void>(() => {});
400
+
401
+ // The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
402
+ // above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
403
+ // restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
404
+ // api type is referenced inline (import('...').Type), never a static `import type ... from`, so
405
+ // no static edge to the dynamically-imported editor-placeholder module sits in this component
406
+ // (the editor-boundary test bars that edge by a textual `from` scan).
407
+ let caretCoords = $state.raw<() => { left: number; right: number; top: number; bottom: number } | null>(
408
+ () => null,
409
+ );
410
+ let focusEditor = $state.raw<() => void>(() => {});
411
+ let placeholders = $state.raw<import('./editor-placeholder.js').ImagePlaceholderApi | null>(null);
412
+ let insertImageFn = $state.raw<(alt: string, ref: string) => void>(() => {});
413
+
414
+ // A no-op placeholder api so the editor object handed to the popover is never null before the
415
+ // editor registers its real one on mount.
416
+ const noopPlaceholders: import('./editor-placeholder.js').ImagePlaceholderApi = {
417
+ begin: () => 0,
418
+ progress: () => {},
419
+ resolveTo: () => {},
420
+ cancel: () => {},
421
+ };
422
+
423
+ // The editor object the popover drives, delegating through the holders so the latest registered
424
+ // function is always used (the holders start as no-ops and are replaced on mount).
425
+ const editorApi = $derived({
426
+ caretCoords: () => caretCoords(),
427
+ focusEditor: () => focusEditor(),
428
+ placeholders: placeholders ?? noopPlaceholders,
429
+ insertImage: (alt: string, ref: string) => insertImageFn(alt, ref),
430
+ });
431
+
432
+ // The headless media insert popover, opened from the toolbar control, paste, or drop.
433
+ let mediaPopover = $state<MediaInsertPopover | null>(null);
434
+
435
+ // The rendered hero fields' refs (for the needs-alt notice's "Add alt text" action, which focuses
436
+ // the field's own alt input) and their reported needs-alt signals, keyed by field name. A hero is
437
+ // a frontmatter value with no body offset, so its needs-alt signal comes from the field, not the
438
+ // body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
439
+ // source range (selectRange). The records are keyed by field name; `data.fields` is static for the
440
+ // page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
441
+ let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
442
+ let heroNeedsAlt = $state<Record<string, boolean>>({});
443
+
444
+ // The server-owned records from each successful upload this session. They ride the save form as
445
+ // the hidden `media` field, so the save action merges them into media.json.
446
+ let uploadedRecords = $state<MediaEntry[]>([]);
362
447
  // A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
363
448
  type DialogHandle = { open: () => void };
364
449
  // The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
@@ -366,7 +451,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
366
451
  // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
367
452
  let webLinkDialog = $state<DialogHandle | null>(null);
368
453
  let linkPicker = $state<DialogHandle | null>(null);
369
- let insertDialog = $state<DialogHandle | null>(null);
454
+ // The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
455
+ // drives editComponent(def, values, range) on it, beyond the shared open().
456
+ let insertDialog = $state<ComponentInsertDialog | null>(null);
370
457
  // The lifecycle dialogs, opened from the header's overflow menu.
371
458
  let deleteDialog = $state<DialogHandle | null>(null);
372
459
  let renameDialog = $state<DialogHandle | null>(null);
@@ -379,6 +466,183 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
379
466
  // by, so the toolbar trigger and the dialog appear and disappear together.
380
467
  const hasComponents = $derived(insertableDefs(registry).length > 0);
381
468
 
469
+ // The directive container at the editor caret, reported by MarkdownEditor whenever it changes
470
+ // (null outside any container). The Edit-block control resolves it against the registry and the
471
+ // round-trip safety gate below; its identity is the key the async gate guards against a stale
472
+ // result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
473
+ // matching interface is not exported.
474
+ type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
475
+ let caretComponent = $state<CaretComponent | null>(null);
476
+
477
+ // The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
478
+ // media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
479
+ // writes source through the replaceRange seam. The figure dialog is mounted headless below.
480
+ let mediaAtCaret = $state<FigureAtImage | null>(null);
481
+ // The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
482
+ // (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
483
+ let figureDialog = $state<HTMLDialogElement | null>(null);
484
+ // Whether the Figure control is available: a media image sits at the caret and Preview is not
485
+ // showing (the insert controls disable together with the Write surface). The control is always
486
+ // rendered (it never mounts on caret move); only its enabled state changes.
487
+ const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
488
+ const figureLabel = $derived(
489
+ figureAvailable
490
+ ? mediaAtCaret?.figure
491
+ ? 'Edit the figure at the cursor'
492
+ : 'Wrap the image at the cursor in a figure'
493
+ : 'Place the cursor on an image to add a figure',
494
+ );
495
+ // Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
496
+ // a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
497
+ // empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
498
+ // warning. Derived from the reported token so it tracks the caret.
499
+ const figureDecorative = $derived.by(() => {
500
+ if (!mediaAtCaret) return false;
501
+ const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
502
+ const match = /^!\[([\s\S]*?)\]\(/.exec(token);
503
+ return (match?.[1] ?? '').trim() === '';
504
+ });
505
+ // A def is actionable for guided edit when it has a schema (the same notion the insert catalog
506
+ // lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
507
+ // hasSchema so the two surfaces can never drift on what counts as editable.
508
+ function editableDef(name: string | null): ComponentDef | undefined {
509
+ if (!name) return undefined;
510
+ const def = registry?.get(name);
511
+ if (!def) return undefined;
512
+ return hasSchema(def) ? def : undefined;
513
+ }
514
+ // The resolved editability: the def, the source range, and the validated markdown when the caret
515
+ // sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
516
+ // caret; null otherwise. The def, range, and markdown are captured from one caretComponent
517
+ // snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
518
+ // control enables only when this is set.
519
+ let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
520
+ // Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
521
+ // disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
522
+ // one (no guided form either way); 'unsafe' is a known component the safety gate refused.
523
+ let editReason = $state<'none' | 'unsafe'>('none');
524
+ // Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
525
+ // async, so a slow check could resolve after a newer caret move; guard latest-wins on the
526
+ // caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
527
+ // the caret has not moved since the check started. A block whose check did not pass for the
528
+ // current caret never enables edit.
529
+ $effect(() => {
530
+ const current = caretComponent;
531
+ const def = editableDef(current?.name ?? null);
532
+ if (!current || !def) {
533
+ editable = null;
534
+ editReason = 'none';
535
+ return;
536
+ }
537
+ let stale = false;
538
+ void componentRoundTripSafety(current.markdown, def)
539
+ .then((result) => {
540
+ if (stale || caretComponent !== current) return;
541
+ if (result.safe) {
542
+ editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
543
+ editReason = 'none';
544
+ } else {
545
+ editable = null;
546
+ editReason = 'unsafe';
547
+ }
548
+ })
549
+ .catch(() => {
550
+ // A parse throw during the safety check must never leave a stale block enabled. Guarded by
551
+ // the same latest-wins identity, fall back to the safe default of no editable block.
552
+ if (stale || caretComponent !== current) return;
553
+ editable = null;
554
+ editReason = 'none';
555
+ });
556
+ return () => {
557
+ stale = true;
558
+ };
559
+ });
560
+ // The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
561
+ // names the action; the two disabled reasons are honest about why.
562
+ const editBlockLabel = $derived(
563
+ editable
564
+ ? 'Edit the component at the cursor'
565
+ : editReason === 'unsafe'
566
+ ? "This block can't be edited in the form. Edit it as markdown."
567
+ : 'Place the cursor in a component to edit it',
568
+ );
569
+ // Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
570
+ // caret is not on a safe, schema-bearing component. The control stays focusable and announced in
571
+ // this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
572
+ // technology; the dead click is made inert in editBlock().
573
+ const editBlockUnavailable = $derived(insertDisabled || !editable);
574
+ // Activate edit: parse the block into form values, then open the dialog in edit mode over the
575
+ // stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
576
+ // unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
577
+ // from the one editable snapshot, so a newer caret markdown is never paired with an older range.
578
+ async function editBlock() {
579
+ if (insertDisabled || !editable) return;
580
+ const values = await parseComponent(editable.markdown, editable.def);
581
+ insertDialog?.editComponent(editable.def, values, editable.range);
582
+ }
583
+
584
+ // The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
585
+ // caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
586
+ // figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
587
+ // measure default. decorative rides the snapshot too. Null while the dialog is closed.
588
+ let figurePrefill = $state<{
589
+ mode: 'wrap' | 'edit';
590
+ caption: string;
591
+ role: FigureRole | null;
592
+ decorative: boolean;
593
+ image: { from: number; to: number };
594
+ figureRange: { from: number; to: number } | null;
595
+ } | null>(null);
596
+
597
+ // Open the figure control over the media image at the caret. Inert unless a media image sits there
598
+ // and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
599
+ // of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
600
+ function openFigure() {
601
+ if (!figureAvailable || !mediaAtCaret) return;
602
+ const at = mediaAtCaret;
603
+ figurePrefill = {
604
+ mode: at.figure ? 'edit' : 'wrap',
605
+ caption: at.figure?.caption ?? '',
606
+ role: at.figure?.role ?? null,
607
+ decorative: figureDecorative,
608
+ image: { from: at.imageFrom, to: at.imageTo },
609
+ figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
610
+ };
611
+ figureDialog?.showModal();
612
+ }
613
+
614
+ // Apply the control's choice through the replaceRange seam, then close. Wrap a bare image or update
615
+ // an existing figure, off the snapshot the dialog opened on. The pure transform owns the source
616
+ // shape and keeps the media token byte-intact; the preview stays read-only.
617
+ function applyFigure(choice: { caption: string; role: FigureRole | null }) {
618
+ const pre = figurePrefill;
619
+ if (!pre) return;
620
+ const result =
621
+ pre.mode === 'edit' && pre.figureRange
622
+ ? updateFigure(body, pre.figureRange, choice.caption, choice.role)
623
+ : wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
624
+ writeFigureResult(result);
625
+ }
626
+
627
+ // Unwrap the figure back to its bare image, then close. Edit mode only (the snapshot carries the
628
+ // figure range). The bare image token is restored verbatim by the pure transform.
629
+ function unwrapFigureAction() {
630
+ const pre = figurePrefill;
631
+ if (!pre || !pre.figureRange) return;
632
+ writeFigureResult(unwrapFigure(body, pre.figureRange));
633
+ }
634
+
635
+ // Write a figure transform's result back to the editor: overwrite the whole doc through the
636
+ // replaceRange seam, then place the selection the transform chose (the seam alone drops the caret
637
+ // at the end). replaceRange dispatches the doc change and focuses the surface; selectRange then
638
+ // dispatches a selection-only transaction, which CodeMirror's history does not record as its own
639
+ // undoable event, so one undo reverts the whole figure write. Close the dialog last.
640
+ function writeFigureResult(result: { doc: string; from: number; to: number }) {
641
+ replaceRange(0, body.length, result.doc);
642
+ selectRange(result.from, result.to);
643
+ figureDialog?.close();
644
+ }
645
+
382
646
  // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
383
647
  // when it has never been published); otherwise the live site matches and it reads Published.
384
648
  const status = $derived.by(() => {
@@ -444,6 +708,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
444
708
  }
445
709
  }
446
710
 
711
+ // The media images in the live body that carry no alt text, recomputed as the author types. Alt is
712
+ // accessibility debt, never a render or publish failure, so this drives a non-blocking warning the
713
+ // author can act on or leave; the count drops and the notice clears as each alt is filled.
714
+ const needsAlt = $derived(findMediaImagesNeedingAlt(body));
715
+
716
+ // The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
717
+ const imageFields = $derived(
718
+ data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
719
+ );
720
+ // The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
721
+ // row's action focuses the field's alt input (the body scanner and its source-range jump cannot
722
+ // reach a frontmatter value). The headline count sums these with the body scanner's hits.
723
+ const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
724
+ const needsAltCount = $derived(needsAlt.length + heroRows.length);
725
+
447
726
  // The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
448
727
  // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
449
728
  const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
@@ -556,6 +835,28 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
556
835
  // returns undefined for a missing target so the render step marks it cairn-broken-link.
557
836
  const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
558
837
 
838
+ // The media analog: it turns a media: reference into its /media delivery path in the preview, and
839
+ // returns undefined for a missing target so the render step marks it cairn-broken-media. The
840
+ // committed mediaTargets projection is merged with this session's uploaded records (the same
841
+ // override the picker's library does), so a just-uploaded image renders its thumbnail in the live
842
+ // preview before the next save commits it, rather than reading as a broken reference.
843
+ const resolveMediaTargets = $derived({
844
+ ...data.mediaTargets,
845
+ ...Object.fromEntries(
846
+ uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }]),
847
+ ),
848
+ });
849
+ const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
850
+
851
+ // The picker's library, the committed projection merged with this session's uploaded records,
852
+ // keyed by content hash. An uploaded record overrides a committed entry on a hash match (the same
853
+ // hash is the same bytes, so the override is harmless). This is what the editor decorates with, so
854
+ // a just-uploaded image carries its source chip before the next save commits it.
855
+ const mediaLibrary = $derived({
856
+ ...data.mediaLibrary,
857
+ ...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)])),
858
+ });
859
+
559
860
  // The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
560
861
  const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
561
862
 
@@ -563,10 +864,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
563
864
  mode = m;
564
865
  }
565
866
 
566
- // Preview is read-only, so the insert controls the page renders into the toolbar disable with
567
- // the strip's own format buttons.
568
- const insertDisabled = $derived(mode === 'preview');
569
-
570
867
  // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
571
868
  // strip or the surface, without claiming the keys page-wide. The listener attaches
572
869
  // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
@@ -626,10 +923,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
626
923
  if (mode !== 'preview' || !render) return;
627
924
  const md = body;
628
925
  const resolve = resolveLink; // tracked read in the effect body
926
+ const resolveMediaRef = resolveMedia; // tracked read in the effect body
629
927
  const run = ++previewRun;
630
928
  const handle = setTimeout(async () => {
631
929
  try {
632
- const html = await render(md, { resolve });
930
+ const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
633
931
  if (run === previewRun) {
634
932
  previewHtml = html;
635
933
  previewFailed = false;
@@ -828,6 +1126,45 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
828
1126
  </ul>
829
1127
  </div>
830
1128
  {/if}
1129
+ <!-- The publish-time needs-alt notice: a non-blocking warning, never a block. Alt text is
1130
+ accessibility debt, so the author can add it now or save without it; the count drops live as
1131
+ each alt is filled and the notice clears at zero. The leading glyph carries the state alongside
1132
+ the count, so the caution reads without relying on hue. Each row's jump control selects the
1133
+ image in the source through the editor's select-range seam, landing the author on it to type
1134
+ the alt. The role="status" live region renders unconditionally (present and empty at load), so
1135
+ when the first debt appears its count change announces; a region conditionally mounted with its
1136
+ first content may not be observed by assistive tech (WCAG 4.1.3). The visible alert chrome and
1137
+ content gate on the count, so an empty region shows nothing. A plain wrapper (not display:contents)
1138
+ carries the role, since some assistive tech drops a role off a display:contents box. -->
1139
+ <div role="status">
1140
+ {#if needsAltCount}
1141
+ <div class="alert alert-warning mb-4 flex-col items-start text-sm">
1142
+ <p class="flex items-center gap-2 font-medium">
1143
+ <svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
1144
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
1145
+ </svg>
1146
+ <span>{needsAltCount} {needsAltCount === 1 ? 'image needs' : 'images need'} alt text</span>
1147
+ </p>
1148
+ <p>Alt text describes an image for readers who cannot see it. Add it now, or save and come back to it.</p>
1149
+ <ul class="mt-1 w-full">
1150
+ {#each needsAlt as item (item.from)}
1151
+ <li class="flex items-center justify-between gap-2">
1152
+ <code class="text-xs">{item.ref}</code>
1153
+ <button type="button" class="btn btn-xs" onclick={() => selectRange(item.from, item.to)}>Add alt text</button>
1154
+ </li>
1155
+ {/each}
1156
+ <!-- The frontmatter-hero rows: a hero has no body offset, so its action focuses the field's
1157
+ own alt input rather than a source range. -->
1158
+ {#each heroRows as hero (hero.name)}
1159
+ <li class="flex items-center justify-between gap-2">
1160
+ <span class="text-xs font-medium">{hero.label}</span>
1161
+ <button type="button" class="btn btn-xs" onclick={() => heroFieldRefs[hero.name]?.focusAlt()}>Add alt text</button>
1162
+ </li>
1163
+ {/each}
1164
+ </ul>
1165
+ </div>
1166
+ {/if}
1167
+ </div>
831
1168
  {#if draftWarning}
832
1169
  <div class="alert alert-warning mb-4 text-sm">
833
1170
  Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
@@ -903,6 +1240,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
903
1240
  >
904
1241
  <BlocksIcon class="h-4 w-4" aria-hidden="true" />
905
1242
  </button>
1243
+ <!-- Edit block re-opens the component at the caret into the guided form. It is
1244
+ unavailable while Preview shows (like the insert controls) and whenever the caret is
1245
+ not on a safe, schema-bearing component; the tooltip names the reason in each state.
1246
+ The unavailable state uses aria-disabled, not the native disabled attribute, so the
1247
+ control stays focusable and its reason reaches assistive technology; the disabled
1248
+ look rides a class and editBlock() early-returns so the dead click is inert. -->
1249
+ <button
1250
+ type="button"
1251
+ class="btn btn-sm btn-ghost btn-square"
1252
+ class:btn-disabled={editBlockUnavailable}
1253
+ aria-haspopup="dialog"
1254
+ aria-label={editBlockLabel}
1255
+ title={editBlockLabel}
1256
+ aria-disabled={editBlockUnavailable}
1257
+ onclick={editBlock}
1258
+ >
1259
+ <SquarePenIcon class="h-4 w-4" aria-hidden="true" />
1260
+ </button>
906
1261
  {/if}
907
1262
  <button
908
1263
  type="button"
@@ -929,9 +1284,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
929
1284
  <button
930
1285
  type="button"
931
1286
  class="btn btn-ghost btn-sm btn-square"
932
- disabled
933
- aria-label="Image (coming soon)"
934
- title="Image (coming soon)"
1287
+ disabled={insertDisabled}
1288
+ aria-label="Insert image"
1289
+ title="Insert image"
1290
+ onclick={() => mediaPopover?.open('chooser')}
935
1291
  >
936
1292
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
937
1293
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
@@ -939,6 +1295,26 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
939
1295
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
940
1296
  </svg>
941
1297
  </button>
1298
+ <!-- The Figure control: always rendered, enabled only when the caret sits on a media image
1299
+ (and the Write surface is up). It never mounts or unmounts on caret movement; only its
1300
+ enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
1301
+ not the native disabled attribute, so the control stays focusable and its reason reaches
1302
+ assistive technology; openFigure() early-returns so the dead click is inert. The dimming
1303
+ uses opacity and cursor utilities, never .btn-disabled, because that sets
1304
+ pointer-events: none and would suppress the title tooltip a mouse user reads for the why. -->
1305
+ <button
1306
+ type="button"
1307
+ class="btn btn-sm btn-ghost btn-square"
1308
+ class:opacity-50={!figureAvailable}
1309
+ class:cursor-not-allowed={!figureAvailable}
1310
+ aria-haspopup="dialog"
1311
+ aria-label={figureLabel}
1312
+ title={figureLabel}
1313
+ aria-disabled={!figureAvailable}
1314
+ onclick={openFigure}
1315
+ >
1316
+ <ImageIcon class="h-4 w-4" aria-hidden="true" />
1317
+ </button>
942
1318
  {/snippet}
943
1319
  </EditorToolbar>
944
1320
  {/if}
@@ -950,13 +1326,26 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
950
1326
  name="body"
951
1327
  {surface}
952
1328
  registerInsert={(fn) => (insert = fn)}
1329
+ onComponentAtCaret={(info) => (caretComponent = info)}
1330
+ onMediaImageAtCaret={(info) => (mediaAtCaret = info)}
1331
+ registerReplaceRange={(fn) => (replaceRange = fn)}
1332
+ registerSelectRange={(fn) => (selectRange = fn)}
953
1333
  registerInsertLink={(fn) => (insertLink = fn)}
954
1334
  registerGetSelection={(fn) => (getSelection = fn)}
955
1335
  registerFormat={(fn) => (format = fn)}
1336
+ registerCaretCoords={(fn) => (caretCoords = fn)}
1337
+ registerFocusEditor={(fn) => (focusEditor = fn)}
1338
+ registerImagePlaceholders={(api) => (placeholders = api)}
1339
+ registerInsertImage={(fn) => (insertImageFn = fn)}
1340
+ onImageIngest={(file) => mediaPopover?.open('capture', file)}
956
1341
  {completionSources}
1342
+ {mediaLibrary}
957
1343
  {focusMode}
958
1344
  {typewriter}
959
1345
  />
1346
+ <!-- The accumulated uploaded records ride the save form alongside the body. The save action
1347
+ reads `media` and merges these records into media.json (publish submits the same form). -->
1348
+ <input type="hidden" name="media" value={JSON.stringify(uploadedRecords)} />
960
1349
  </div>
961
1350
  {#if mode === 'preview'}
962
1351
  <!-- The preview ground: recessed under the floating frame card so the page reads as a
@@ -1177,6 +1566,19 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1177
1566
  value={tagValue}
1178
1567
  />
1179
1568
  </label>
1569
+ {:else if field.type === 'image'}
1570
+ {@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
1571
+ <MediaHeroField
1572
+ bind:this={heroFieldRefs[field.name]}
1573
+ field={{ name: field.name, label: field.label }}
1574
+ value={heroValue}
1575
+ mediaLibrary={mediaLibrary}
1576
+ conceptId={data.conceptId}
1577
+ id={data.id}
1578
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1579
+ ondirty={markFieldsDirty}
1580
+ onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
1581
+ />
1180
1582
  {:else}
1181
1583
  <label class="flex flex-col gap-1">
1182
1584
  <span class="text-sm font-medium">{field.label}</span>
@@ -1239,10 +1641,70 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1239
1641
  <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
1240
1642
  tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
1241
1643
  through their exported open(). -->
1242
- <ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
1644
+ <ComponentInsertDialog
1645
+ bind:this={insertDialog}
1646
+ trigger={false}
1647
+ {registry}
1648
+ {insert}
1649
+ update={(range, md) => replaceRange(range.from, range.to, md)}
1650
+ {icons}
1651
+ {render}
1652
+ preview={data.preview}
1653
+ />
1243
1654
  <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
1244
1655
  <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
1245
1656
 
1657
+ <!-- The media insert popover, mounted headless: the toolbar control, a paste, or a drop drives it
1658
+ through its exported open(). On a successful upload it hands the server-owned record up; the
1659
+ record joins uploadedRecords (the hidden save field) and the merged library (the source chip). -->
1660
+ <MediaInsertPopover
1661
+ bind:this={mediaPopover}
1662
+ trigger={false}
1663
+ conceptId={data.conceptId}
1664
+ id={data.id}
1665
+ library={mediaLibrary}
1666
+ editor={editorApi}
1667
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1668
+ />
1669
+
1670
+ <!-- The figure control's host dialog, mounted headless outside the edit form (the control holds its
1671
+ own <form>). The toolbar Figure control opens it through openFigure(), pre-filled from the caret
1672
+ snapshot. The control is keyed on figurePrefill so it remounts fresh per open, seeding its fields
1673
+ from the new caption/role. The native <dialog> gives the focus trap and Escape for free, and the
1674
+ close event (the X, the backdrop, Escape, and the apply path all fire it) clears the snapshot so
1675
+ the host state matches the closed dialog. -->
1676
+ <dialog
1677
+ class="modal"
1678
+ aria-labelledby="cairn-figure-dialog-title"
1679
+ bind:this={figureDialog}
1680
+ onclose={() => (figurePrefill = null)}
1681
+ >
1682
+ <div class="modal-box max-w-sm">
1683
+ <div class="mb-3 flex items-center justify-between">
1684
+ <h2 id="cairn-figure-dialog-title" class="flex items-center gap-2 text-base font-semibold">
1685
+ <ImageIcon class="h-4 w-4 text-[var(--color-accent)]" aria-hidden="true" />
1686
+ {figurePrefill?.mode === 'edit' ? 'Edit figure' : 'Wrap in a figure'}
1687
+ </h2>
1688
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => figureDialog?.close()}>✕</button>
1689
+ </div>
1690
+ {#if figurePrefill}
1691
+ {#key figurePrefill}
1692
+ <MediaFigureControl
1693
+ caption={figurePrefill.caption}
1694
+ role={figurePrefill.role}
1695
+ mode={figurePrefill.mode}
1696
+ decorative={figurePrefill.decorative}
1697
+ onapply={applyFigure}
1698
+ onunwrap={unwrapFigureAction}
1699
+ />
1700
+ {/key}
1701
+ {/if}
1702
+ </div>
1703
+ <form method="dialog" class="modal-backdrop">
1704
+ <button tabindex="-1" aria-label="Close">close</button>
1705
+ </form>
1706
+ </dialog>
1707
+
1246
1708
  <!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
1247
1709
  exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
1248
1710
  <RenameDialog