@glw907/cairn-cms 0.56.2 → 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 (173) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +929 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +347 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +569 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +17 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +62 -11
  47. package/dist/content/validate.js +27 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +68 -2
  102. package/dist/sveltekit/content-routes.js +461 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  116. package/src/lib/components/EditPage.svelte +347 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +569 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +16 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +78 -13
  138. package/src/lib/content/validate.ts +26 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +573 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,449 @@
1
+ <!--
2
+ @component
3
+ The at-caret media insert popover: the single entry point for placing an image. It composes the
4
+ capture card (MediaCaptureCard) and the combobox picker (MediaPicker), routes by the opening signal,
5
+ and drives the optimistic upload loop.
6
+
7
+ Routing (locked decision 4): open('capture', file) goes straight to the capture card with the bytes
8
+ in hand (the paste and drag path); open('chooser') leads with the upload drop zone and choose-file as
9
+ the persistent primary, with the picker below under "or reuse an image" (the toolbar-button path).
10
+
11
+ The optimistic loop: on a capture record the popover lands a placeholder at the caret (a local object
12
+ URL, so the author sees the image at once), runs ingestFile then buildUploadRequest then sendUpload,
13
+ and on the success envelope swaps the placeholder for the committed ![alt](media:slug.hash) text. A
14
+ dedup result still inserts but notes "Reused an existing image"; a typed failure cancels the
15
+ placeholder and shows the card with a Retry; an opaque or status-0 response is a session-expired
16
+ signal. The placeholder is a widget, never doc text, so a failed or expired upload leaves the source
17
+ exactly as it was (open risk 2).
18
+
19
+ The popover is headless by default (trigger=false): the host opens it through the exported open(). It
20
+ moves focus in on open, traps Tab, and restores focus to the editor on close or Escape through
21
+ editor.focusEditor() (the selection is intact, since opening only blurred the editor). Below the
22
+ narrow breakpoint it falls back to a full-height bottom sheet (the admin design system's modal-sizing
23
+ rule). The CSRF token is read from the admin context.
24
+ -->
25
+ <script lang="ts">
26
+ import { getContext, tick } from 'svelte';
27
+ import { CSRF_CONTEXT_KEY } from './csrf-context.js';
28
+ import MediaCaptureCard from './MediaCaptureCard.svelte';
29
+ import MediaPicker, { type MediaLibraryEntry, type MediaSelection } from './MediaPicker.svelte';
30
+ import {
31
+ ingestFile,
32
+ buildUploadRequest,
33
+ sendUpload,
34
+ ingestFailureKind,
35
+ failureCard,
36
+ type IngestFailureCard,
37
+ } from './client-ingest.js';
38
+ import { deserialize } from '$app/forms';
39
+ import { uploadOutcome, type UploadEnvelope, type UploadFailureKind } from './media-upload-outcome.js';
40
+ import type { MediaEntry } from '../media/manifest.js';
41
+
42
+ // The placeholder api type is referenced inline (import('...').Type), never a static
43
+ // `import type ... from`, so no static edge to the dynamically-imported editor-placeholder module
44
+ // sits in this client component (the editor-boundary test bars that edge by a textual `from` scan).
45
+ type ImagePlaceholderApi = import('./editor-placeholder.js').ImagePlaceholderApi;
46
+
47
+ /** The record the capture card emits, the same shape MediaCaptureCard.oncapture carries. */
48
+ interface CaptureRecord {
49
+ file: File;
50
+ displayName: string;
51
+ alt: string;
52
+ decorative: boolean;
53
+ }
54
+
55
+ interface Props {
56
+ /** The concept the entry belongs to (the upload action's route param). */
57
+ conceptId: string;
58
+ /** The entry id (the upload action's route param). */
59
+ id: string;
60
+ /** The merged committed-plus-uploaded media library, keyed by content hash, for the picker. */
61
+ library: Record<string, MediaLibraryEntry>;
62
+ /** The editor seams the popover drives: caret anchoring, focus restore, the placeholder api, and
63
+ * the direct-insert path for a picked image (no upload). */
64
+ editor: {
65
+ caretCoords: () => { left: number; right: number; top: number; bottom: number } | null;
66
+ focusEditor: () => void;
67
+ placeholders: ImagePlaceholderApi;
68
+ insertImage: (alt: string, ref: string) => void;
69
+ };
70
+ /** Called with the server-owned record on a successful upload; the host appends it to its records
71
+ * state and merges it into the library so the source decoration resolves the new reference. */
72
+ onuploaded: (record: MediaEntry) => void;
73
+ /** Render the built-in trigger button. False (the default) mounts headless; the host opens the
74
+ * popover through the exported open(). */
75
+ trigger?: boolean;
76
+ }
77
+
78
+ let { conceptId, id, library, editor, onuploaded, trigger = false }: Props = $props();
79
+
80
+ // The CSRF token getter from the admin context (AdminLayout provides it). Undefined outside the
81
+ // shell, where the empty token fails the guard's check, the intended fail-closed signal.
82
+ const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
83
+
84
+ // The view the popover shows. 'capture' is the one-step card with bytes in hand; 'chooser' leads
85
+ // with upload and the picker; null is closed.
86
+ type View = 'capture' | 'chooser';
87
+ let view = $state<View | null>(null);
88
+ let captureFile = $state<File | null>(null);
89
+
90
+ // The card a failed loop surfaces: an ingest-taxonomy card (its own message) or the envelope-only
91
+ // generic card. The two share the failed-card shape, so one alias names both.
92
+ type FailureCard = IngestFailureCard | { status: 'failed'; kind: UploadFailureKind; message: string };
93
+
94
+ // The transient status of the in-flight or failed loop, surfaced under the active view. 'reused'
95
+ // briefly notes a dedup collapse; the failure and expired states carry a message and a retry.
96
+ type Status =
97
+ | { kind: 'idle' }
98
+ | { kind: 'reused' }
99
+ | { kind: 'failed'; card: FailureCard; retry: () => void }
100
+ | { kind: 'expired' };
101
+ let status = $state<Status>({ kind: 'idle' });
102
+
103
+ // The anchor coordinates captured on open, so the popover positions at the caret even after focus
104
+ // leaves the editor. Null falls back to a centered position.
105
+ let anchor = $state<{ left: number; right: number; top: number; bottom: number } | null>(null);
106
+ let panel = $state<HTMLDivElement | null>(null);
107
+ let fileInput = $state<HTMLInputElement | null>(null);
108
+ // The primary control of each terminal status block, bound so focus lands on the action when the
109
+ // block renders (the focused capture-card submit unmounts when the loop starts, so focus would
110
+ // otherwise drop to <body>).
111
+ let retryButton = $state<HTMLButtonElement | null>(null);
112
+ let expiredCloseButton = $state<HTMLButtonElement | null>(null);
113
+ let reusedDoneButton = $state<HTMLButtonElement | null>(null);
114
+
115
+ // When the loop settles into a terminal state, move focus to that state's primary control (Retry,
116
+ // Close, or Done) so the keyboard/screen-reader user lands on the action and the Tab trap plus
117
+ // Escape stay in play. The message blocks carry role="alert"/role="status" so the transition is
118
+ // also announced (WCAG 4.1.3, 2.4.3). Keyed on status.kind, after the block renders.
119
+ $effect(() => {
120
+ const kind = status.kind;
121
+ if (kind !== 'failed' && kind !== 'expired' && kind !== 'reused') return;
122
+ void tick().then(() => {
123
+ if (kind === 'failed') retryButton?.focus();
124
+ else if (kind === 'expired') expiredCloseButton?.focus();
125
+ else reusedDoneButton?.focus();
126
+ });
127
+ });
128
+
129
+ /**
130
+ * Open the popover. 'capture' with a file goes straight to the capture card (paste/drag); 'chooser'
131
+ * leads with the upload zone and the picker (the toolbar button). Anchors to the caret and moves
132
+ * focus in.
133
+ */
134
+ export function open(signal: 'chooser' | 'capture', file?: File): void {
135
+ anchor = editor.caretCoords();
136
+ status = { kind: 'idle' };
137
+ if (signal === 'capture' && file) {
138
+ captureFile = file;
139
+ view = 'capture';
140
+ } else {
141
+ captureFile = null;
142
+ view = 'chooser';
143
+ }
144
+ // Move focus into the panel once it renders.
145
+ void tick().then(() => panel?.focus());
146
+ }
147
+
148
+ function close() {
149
+ view = null;
150
+ captureFile = null;
151
+ status = { kind: 'idle' };
152
+ editor.focusEditor();
153
+ }
154
+
155
+ // Trap Tab within the panel and close on Escape, restoring focus to the editor.
156
+ function onKeydown(e: KeyboardEvent) {
157
+ if (e.key === 'Escape') {
158
+ e.preventDefault();
159
+ close();
160
+ return;
161
+ }
162
+ if (e.key !== 'Tab' || !panel) return;
163
+ const focusable = panel.querySelectorAll<HTMLElement>(
164
+ 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
165
+ );
166
+ if (focusable.length === 0) return;
167
+ const first = focusable[0];
168
+ const last = focusable[focusable.length - 1];
169
+ const activeEl = document.activeElement;
170
+ if (e.shiftKey && (activeEl === first || activeEl === panel)) {
171
+ e.preventDefault();
172
+ last.focus();
173
+ } else if (!e.shiftKey && activeEl === last) {
174
+ e.preventDefault();
175
+ first.focus();
176
+ }
177
+ }
178
+
179
+ // The picker path: a picked asset inserts its reference directly at the caret with no upload, then
180
+ // the popover closes. This is the reuse-an-existing path.
181
+ function onPick(sel: MediaSelection) {
182
+ editor.insertImage(sel.alt, sel.ref);
183
+ close();
184
+ }
185
+
186
+ // A chosen file (the choose-file fallback in the chooser) routes to the capture card, the same one
187
+ // a paste or drag opens, so every byte path runs the one capture-then-upload flow.
188
+ function onChosenFile(e: Event) {
189
+ const input = e.currentTarget as HTMLInputElement;
190
+ const file = input.files?.[0];
191
+ if (file) {
192
+ captureFile = file;
193
+ view = 'capture';
194
+ status = { kind: 'idle' };
195
+ }
196
+ }
197
+
198
+ // The optimistic upload loop, on a capture-card record. It lands a placeholder, runs the ingest and
199
+ // upload, and resolves the placeholder to the committed reference or cancels it on any failure, so
200
+ // the source is never left with a half-written token.
201
+ async function runUpload(record: CaptureRecord) {
202
+ const objectUrl = URL.createObjectURL(record.file);
203
+ const pid = editor.placeholders.begin(objectUrl);
204
+ // Close the byte-capture view now; the placeholder carries the progress in the source.
205
+ view = null;
206
+ captureFile = null;
207
+
208
+ // Drop the in-flight placeholder, leaving the source exactly as it was, and free the object URL.
209
+ // Both unsuccessful paths (a typed failure, an expired session) end here before setting status.
210
+ const discardPlaceholder = () => {
211
+ editor.placeholders.cancel(pid);
212
+ URL.revokeObjectURL(objectUrl);
213
+ };
214
+ const fail = (card: FailureCard) => {
215
+ discardPlaceholder();
216
+ status = { kind: 'failed', card, retry: () => void runUpload(record) };
217
+ };
218
+ const expire = () => {
219
+ discardPlaceholder();
220
+ status = { kind: 'expired' };
221
+ };
222
+ // The author-facing card for an envelope-only generic refusal (a deserialize throw or an
223
+ // operational reason with no author-actionable specifics).
224
+ const genericCard = () => fail({ status: 'failed', kind: 'generic', message: GENERIC_FAILURE_MESSAGE });
225
+
226
+ // Stage progress, not real byte counts: fetch cannot report upload bytes, so the bar reads the
227
+ // ingest/upload LIFECYCLE (begin ~0.1 set by the field, ingesting ~0.4, uploading ~0.85, resolve
228
+ // clears it). Honest stage progress, never a fabricated timer.
229
+ editor.placeholders.progress(pid, 0.4);
230
+ let ingested: Awaited<ReturnType<typeof ingestFile>>;
231
+ try {
232
+ ingested = await ingestFile(record.file);
233
+ } catch (err) {
234
+ fail(failureCard(ingestFailureKind(err)));
235
+ return;
236
+ }
237
+
238
+ editor.placeholders.progress(pid, 0.85);
239
+ const { url, init } = buildUploadRequest({
240
+ conceptId,
241
+ id,
242
+ bytes: ingested.blob,
243
+ contentType: ingested.contentType,
244
+ csrf: csrf?.() ?? '',
245
+ filename: record.file.name,
246
+ alt: record.alt,
247
+ displayName: record.displayName,
248
+ width: ingested.width,
249
+ height: ingested.height,
250
+ });
251
+
252
+ let res: Response;
253
+ try {
254
+ res = await sendUpload(url, init);
255
+ } catch (err) {
256
+ fail(failureCard(ingestFailureKind(err)));
257
+ return;
258
+ }
259
+
260
+ // The guard's expired-session 303 under redirect: 'manual' surfaces as an opaque, status-0
261
+ // response; treat it as session-expired before parsing a body that is not there.
262
+ if (res.type === 'opaqueredirect' || res.status === 0) {
263
+ expire();
264
+ return;
265
+ }
266
+
267
+ // deserialize returns the generic ActionResult; the upload action's success data is an
268
+ // UploadResult and its failure data carries an error string, so the result matches UploadEnvelope.
269
+ // The cast names that known shape for the pure mapper (the redirect/status-0 case is handled above).
270
+ // An unexpected server response (a 500/502/504 from a worker crash, OOM, or an edge timeout) is an
271
+ // HTML error page, not a devalue-encoded result, so deserialize throws. Catch it and route the
272
+ // throw through fail() with the generic card, so the placeholder cancels and a Retry is offered.
273
+ let outcome: ReturnType<typeof uploadOutcome>;
274
+ try {
275
+ outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
276
+ } catch {
277
+ genericCard();
278
+ return;
279
+ }
280
+ if (outcome.kind === 'session-expired') {
281
+ expire();
282
+ return;
283
+ }
284
+ if (outcome.kind === 'failed') {
285
+ // An ingest-taxonomy kind reuses failureCard's own message; the envelope-only `generic` kind
286
+ // carries its own plain message. Either way the card shows the message with a Retry.
287
+ if (outcome.failure === 'generic') genericCard();
288
+ else fail(failureCard(outcome.failure));
289
+ return;
290
+ }
291
+
292
+ // Success: swap the placeholder for the committed reference in one transaction, hand the record
293
+ // up, and close. A dedup reuse still inserts the existing reference (the decision) and briefly
294
+ // notes it.
295
+ editor.placeholders.resolveTo(pid, record.alt, outcome.reference);
296
+ onuploaded(outcome.record);
297
+ URL.revokeObjectURL(objectUrl);
298
+ if (outcome.reused) {
299
+ status = { kind: 'reused' };
300
+ } else {
301
+ close();
302
+ }
303
+ }
304
+
305
+ // The author-facing message for an envelope-only generic refusal (a binding-missing, a csrf, a
306
+ // length-required: operational refusals with no author-actionable specifics). The ingest-taxonomy
307
+ // kinds carry their own messages through failureCard.
308
+ const GENERIC_FAILURE_MESSAGE = 'The upload could not be completed. Please try again.';
309
+
310
+ // The popover's anchored position: just below the caret line, clamped into the viewport. A null
311
+ // anchor centers it. The full-height sheet at the narrow breakpoint is the CSS fallback.
312
+ const positionStyle = $derived(
313
+ anchor
314
+ ? `left: ${Math.max(8, Math.min(anchor.left, (typeof window !== 'undefined' ? window.innerWidth : 1024) - 360))}px; top: ${anchor.bottom + 6}px;`
315
+ : 'left: 50%; top: 4rem; transform: translateX(-50%);',
316
+ );
317
+ </script>
318
+
319
+ {#if trigger}
320
+ <button
321
+ type="button"
322
+ class="btn btn-sm btn-ghost"
323
+ aria-haspopup="dialog"
324
+ aria-label="Insert image"
325
+ onclick={() => open('chooser')}
326
+ >
327
+ Insert image
328
+ </button>
329
+ {/if}
330
+
331
+ {#if view !== null || status.kind === 'failed' || status.kind === 'expired' || status.kind === 'reused'}
332
+ <!-- The light-dismiss backdrop: a click outside closes a non-destructive popover. A real button so
333
+ it carries a role and a keyboard activation; tabindex -1 keeps it out of the focus trap, and
334
+ Escape on the panel is the keyboard dismiss. -->
335
+ <button
336
+ type="button"
337
+ class="cairn-media-popover-backdrop"
338
+ tabindex="-1"
339
+ aria-label="Close"
340
+ onclick={close}
341
+ ></button>
342
+ <div
343
+ bind:this={panel}
344
+ class="cairn-media-popover"
345
+ style={positionStyle}
346
+ role="dialog"
347
+ aria-modal="true"
348
+ aria-label="Insert image"
349
+ tabindex="-1"
350
+ onkeydown={onKeydown}
351
+ >
352
+ <div class="mb-2 flex items-center justify-between gap-2">
353
+ <h2 class="text-sm font-semibold">Insert image</h2>
354
+ <button type="button" class="btn btn-ghost btn-xs" aria-label="Close" onclick={close}>✕</button>
355
+ </div>
356
+
357
+ {#if status.kind === 'expired'}
358
+ <!-- role="alert" (assertive): the upload failed mid-flight after the capture card unmounted, so
359
+ the state transition must announce. Focus moves to Close (the $effect above). -->
360
+ <div class="flex flex-col gap-2" data-testid="cairn-media-expired" role="alert">
361
+ <p class="text-sm">Your session has expired. Please sign in again to add an image.</p>
362
+ <div class="flex justify-end">
363
+ <button bind:this={expiredCloseButton} type="button" class="btn btn-sm" onclick={close}>Close</button>
364
+ </div>
365
+ </div>
366
+ {:else if status.kind === 'failed'}
367
+ <!-- role="alert" (assertive): a failure must interrupt. Focus moves to Retry (the $effect). -->
368
+ <div class="flex flex-col gap-2" data-testid="cairn-media-failed" role="alert">
369
+ <p class="text-sm">{status.card.message}</p>
370
+ <div class="flex justify-end gap-2">
371
+ <button type="button" class="btn btn-ghost btn-sm" onclick={close}>Cancel</button>
372
+ <button bind:this={retryButton} type="button" class="btn btn-primary btn-sm" onclick={status.retry}>Retry</button>
373
+ </div>
374
+ </div>
375
+ {:else if status.kind === 'reused'}
376
+ <!-- role="status" (polite): a reuse is a success note, not an interruption. Focus moves to
377
+ Done so the keyboard user lands on the one action. -->
378
+ <div class="flex flex-col gap-2" data-testid="cairn-media-reused" role="status">
379
+ <p class="text-sm">Reused an existing image.</p>
380
+ <div class="flex justify-end">
381
+ <button bind:this={reusedDoneButton} type="button" class="btn btn-primary btn-sm" onclick={close}>Done</button>
382
+ </div>
383
+ </div>
384
+ {:else if view === 'capture' && captureFile}
385
+ <MediaCaptureCard file={captureFile} oncapture={runUpload} />
386
+ {:else if view === 'chooser'}
387
+ <div class="flex flex-col gap-3">
388
+ <!-- Upload-first: the persistent primary path. -->
389
+ <div class="flex flex-col gap-1">
390
+ <button
391
+ type="button"
392
+ class="btn btn-primary btn-sm w-full"
393
+ onclick={() => fileInput?.click()}
394
+ >
395
+ Upload an image
396
+ </button>
397
+ <input
398
+ bind:this={fileInput}
399
+ type="file"
400
+ accept="image/*"
401
+ class="sr-only"
402
+ aria-label="Choose an image to upload"
403
+ onchange={onChosenFile}
404
+ />
405
+ </div>
406
+ <p class="text-center text-xs text-[var(--color-muted)]">or reuse an image</p>
407
+ <MediaPicker {library} onselect={onPick} />
408
+ </div>
409
+ {/if}
410
+ </div>
411
+ {/if}
412
+
413
+ <style>
414
+ .cairn-media-popover-backdrop {
415
+ position: fixed;
416
+ inset: 0;
417
+ z-index: 40;
418
+ }
419
+ .cairn-media-popover {
420
+ position: fixed;
421
+ z-index: 41;
422
+ width: 22rem;
423
+ max-width: calc(100vw - 1rem);
424
+ max-height: min(28rem, 80vh);
425
+ overflow: auto;
426
+ border-radius: var(--radius-box, 0.75rem);
427
+ border: 1px solid var(--cairn-card-border, oklch(90% 0.01 75));
428
+ background: var(--color-base-100, white);
429
+ padding: 0.875rem;
430
+ /* The theme-adaptive elevation var, not a fixed shadow: in light the soft shadow carries the
431
+ lift, in dark the hairline border defines the panel where a shadow barely shows. */
432
+ box-shadow: var(--cairn-shadow, 0 12px 32px -8px oklch(0% 0 0 / 0.25));
433
+ }
434
+ /* Below the narrow breakpoint the popover becomes a full-height bottom sheet (the design system's
435
+ modal-sizing rule: filling the height is correct only on a small viewport). */
436
+ @media (max-width: 640px) {
437
+ .cairn-media-popover {
438
+ left: 0 !important;
439
+ right: 0;
440
+ top: auto !important;
441
+ bottom: 0;
442
+ transform: none !important;
443
+ width: 100%;
444
+ max-width: 100%;
445
+ max-height: 90vh;
446
+ border-radius: var(--radius-box, 0.75rem) var(--radius-box, 0.75rem) 0 0;
447
+ }
448
+ }
449
+ </style>