@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
@@ -0,0 +1,135 @@
1
+ <!--
2
+ @component
3
+ The one-step capture card for a media insert. Shown when an author already has bytes in hand (a
4
+ paste, a drop, or a chosen file), it captures three things and emits them to its host: the file, an
5
+ editable display name, and the alt text. It is a presentational form card, not a dialog of its own;
6
+ Task 6's insert popover hosts it.
7
+
8
+ The display name pre-fills from proposedNameFor(file.name): a real specific stem (blue-shoes.png)
9
+ arrives as a Suggested value, while a generic camera stem (IMG_4821.jpg) leaves the field empty and
10
+ required with no tag, so the author never accepts machine noise.
11
+
12
+ Alt is a real role="radiogroup" of two radios: write a description, or mark decorative. The
13
+ requirement is surfaced through aria-describedby, never by disabling the submit. Insert is never
14
+ disabled (the locked no-skipped-disabled-reason rule); an author may proceed with alt unset and the
15
+ emitted record carries an empty alt, which the host treats as needs-alt debt. A decorative choice
16
+ also resolves alt to the empty string. The committed reference keys off an empty alt as the
17
+ needs-alt signal, so the emitted record uses an empty alt string for both the decorative and the
18
+ left-blank cases, and a separate decorative flag distinguishes them for the host.
19
+ -->
20
+ <script lang="ts">
21
+ import { untrack } from 'svelte';
22
+ import { proposedNameFor } from './client-ingest.js';
23
+
24
+ /** The record the card emits to its host on insert. */
25
+ interface CaptureRecord {
26
+ /** The image bytes the author is placing. */
27
+ file: File;
28
+ /** The editable display name, the proposed stem or the author's edit. */
29
+ displayName: string;
30
+ /** The alt text. Empty for a decorative image or for an author who proceeded without alt; the
31
+ * host commits an empty alt as the needs-alt signal in the `![](media:...)` reference. */
32
+ alt: string;
33
+ /** True when the author marked the image decorative, distinguishing a deliberate empty alt from
34
+ * a left-blank one. The committed alt is empty either way. */
35
+ decorative: boolean;
36
+ }
37
+
38
+ interface Props {
39
+ /** The image to capture; the card previews it from a local object URL. */
40
+ file: File;
41
+ /** Emit the captured record to the host on insert. */
42
+ oncapture: (record: CaptureRecord) => void;
43
+ }
44
+
45
+ let { file, oncapture }: Props = $props();
46
+
47
+ // The proposed display name for this file, computed once. A real stem pre-fills the field and shows
48
+ // the Suggested tag; a generic stem yields null, leaving the field empty, required, and untagged.
49
+ const proposed = $derived(proposedNameFor(file.name));
50
+ // Seeded once from the file the card opened with; untrack marks it a deliberate one-time read, not
51
+ // a reactive miss. The card lives for one file, so a later file swap is out of scope.
52
+ let displayName = $state(untrack(() => proposedNameFor(file.name) ?? ''));
53
+
54
+ // The alt mode: unset until the author picks one, then 'describe' or 'decorative'. Unset emits an
55
+ // empty alt (needs-alt debt); insert never blocks on it.
56
+ let altMode = $state<'describe' | 'decorative' | null>(null);
57
+ let altText = $state('');
58
+
59
+ // A local object URL for the preview. Allocation and revoke live in ONE $effect keyed on the file,
60
+ // never in a $derived: a derivation that calls createObjectURL allocates a resource as a side
61
+ // effect, which can desync from the revoke (a re-derive leaks the prior URL). The matched effect
62
+ // revokes on teardown and on any file change, so the blob never leaks.
63
+ let previewUrl = $state('');
64
+ $effect(() => {
65
+ const url = URL.createObjectURL(file);
66
+ previewUrl = url;
67
+ return () => URL.revokeObjectURL(url);
68
+ });
69
+
70
+ function submit(e: SubmitEvent) {
71
+ e.preventDefault();
72
+ // Decorative and write-but-blank both commit an empty alt; the decorative flag distinguishes
73
+ // them for the host. A described image carries the trimmed alt text.
74
+ const alt = altMode === 'describe' ? altText.trim() : '';
75
+ oncapture({ file, displayName: displayName.trim(), alt, decorative: altMode === 'decorative' });
76
+ }
77
+ </script>
78
+
79
+ <form class="flex flex-col gap-4" onsubmit={submit}>
80
+ <div class="flex items-start gap-3">
81
+ <img
82
+ src={previewUrl}
83
+ alt=""
84
+ class="h-16 w-16 flex-none rounded-box border border-[var(--cairn-card-border)] object-cover"
85
+ />
86
+ <label class="flex flex-1 flex-col gap-1">
87
+ <span class="flex items-center gap-2 text-sm font-medium">
88
+ Name
89
+ {#if proposed !== null}
90
+ <span class="badge badge-ghost badge-sm">Suggested</span>
91
+ {/if}
92
+ </span>
93
+ <input
94
+ class="input w-full"
95
+ aria-required={proposed === null ? 'true' : undefined}
96
+ placeholder="What is this image?"
97
+ bind:value={displayName}
98
+ />
99
+ </label>
100
+ </div>
101
+
102
+ <fieldset
103
+ class="flex flex-col gap-2"
104
+ role="radiogroup"
105
+ aria-label="Alt text"
106
+ aria-required="true"
107
+ aria-describedby="cairn-capture-alt-note"
108
+ >
109
+ <legend class="text-sm font-medium">Alt text</legend>
110
+ <p id="cairn-capture-alt-note" class="text-xs text-[var(--color-muted)]">
111
+ Describe the image for screen readers, or mark it decorative. You can insert without alt text
112
+ and add it later.
113
+ </p>
114
+ <label class="flex cursor-pointer items-center gap-2">
115
+ <input type="radio" class="radio radio-sm" name="cairn-capture-alt" value="describe" bind:group={altMode} />
116
+ <span class="text-sm">Write a description</span>
117
+ </label>
118
+ {#if altMode === 'describe'}
119
+ <input
120
+ class="input input-sm ml-6 w-[calc(100%-1.5rem)]"
121
+ aria-label="Alt text description"
122
+ placeholder="A short description"
123
+ bind:value={altText}
124
+ />
125
+ {/if}
126
+ <label class="flex cursor-pointer items-center gap-2">
127
+ <input type="radio" class="radio radio-sm" name="cairn-capture-alt" value="decorative" bind:group={altMode} />
128
+ <span class="text-sm">Mark as decorative</span>
129
+ </label>
130
+ </fieldset>
131
+
132
+ <div class="flex justify-end">
133
+ <button type="submit" class="btn btn-sm btn-primary">Insert image</button>
134
+ </div>
135
+ </form>
@@ -0,0 +1,40 @@
1
+ /** The record the card emits to its host on insert. */
2
+ interface CaptureRecord {
3
+ /** The image bytes the author is placing. */
4
+ file: File;
5
+ /** The editable display name, the proposed stem or the author's edit. */
6
+ displayName: string;
7
+ /** The alt text. Empty for a decorative image or for an author who proceeded without alt; the
8
+ * host commits an empty alt as the needs-alt signal in the `![](media:...)` reference. */
9
+ alt: string;
10
+ /** True when the author marked the image decorative, distinguishing a deliberate empty alt from
11
+ * a left-blank one. The committed alt is empty either way. */
12
+ decorative: boolean;
13
+ }
14
+ interface Props {
15
+ /** The image to capture; the card previews it from a local object URL. */
16
+ file: File;
17
+ /** Emit the captured record to the host on insert. */
18
+ oncapture: (record: CaptureRecord) => void;
19
+ }
20
+ /**
21
+ * The one-step capture card for a media insert. Shown when an author already has bytes in hand (a
22
+ * paste, a drop, or a chosen file), it captures three things and emits them to its host: the file, an
23
+ * editable display name, and the alt text. It is a presentational form card, not a dialog of its own;
24
+ * Task 6's insert popover hosts it.
25
+ *
26
+ * The display name pre-fills from proposedNameFor(file.name): a real specific stem (blue-shoes.png)
27
+ * arrives as a Suggested value, while a generic camera stem (IMG_4821.jpg) leaves the field empty and
28
+ * required with no tag, so the author never accepts machine noise.
29
+ *
30
+ * Alt is a real role="radiogroup" of two radios: write a description, or mark decorative. The
31
+ * requirement is surfaced through aria-describedby, never by disabling the submit. Insert is never
32
+ * disabled (the locked no-skipped-disabled-reason rule); an author may proceed with alt unset and the
33
+ * emitted record carries an empty alt, which the host treats as needs-alt debt. A decorative choice
34
+ * also resolves alt to the empty string. The committed reference keys off an empty alt as the
35
+ * needs-alt signal, so the emitted record uses an empty alt string for both the decorative and the
36
+ * left-blank cases, and a separate decorative flag distinguishes them for the host.
37
+ */
38
+ declare const MediaCaptureCard: import("svelte").Component<Props, {}, "">;
39
+ type MediaCaptureCard = ReturnType<typeof MediaCaptureCard>;
40
+ export default MediaCaptureCard;
@@ -0,0 +1,247 @@
1
+ <!--
2
+ @component
3
+ The figure control form: the caption and placement an author gives an inline media image. The host
4
+ (EditPage) opens it over the media image at the caret, in `wrap` mode for a bare image or `edit` mode
5
+ for an existing `:::figure`. It is the form CONTENT only; the host mounts it inside the Edit-block
6
+ dialog. On submit it emits the chosen caption and role through `onapply`; in edit mode it also offers
7
+ `onunwrap` to strip the figure back to the bare image.
8
+
9
+ The caption is the visible line under the image, distinct from the alt text. The control surfaces the
10
+ image's alt state (Described or Needs alt) that the host derives and passes; the deep needs-alt wiring
11
+ is the host's. When the image is decorative AND the author gives it a caption, the control warns: a
12
+ decorative image is hidden from screen readers, so a visible caption on it is a contradiction.
13
+
14
+ The placement is a roving-tabindex radiogroup (Measure, Center, Wide, Full): one bordered group, the
15
+ active segment tinted with a check glyph (the non-color state cue, WCAG 1.4.1), arrow keys move and
16
+ select. Measure maps to the null role (the measure default, no role brace); the others map to their
17
+ own name.
18
+ -->
19
+ <script lang="ts">
20
+ import { untrack } from 'svelte';
21
+ import type { FigureRole } from './markdown-format.js';
22
+
23
+ /** The roles the segmented control offers, with Measure standing for the null (measure-default)
24
+ * role. Declared as a const tuple so the segment loop and the keyboard handler share one source. */
25
+ const ROLE_OPTIONS: { value: FigureRole | null; label: string }[] = [
26
+ { value: null, label: 'Measure' },
27
+ { value: 'center', label: 'Center' },
28
+ { value: 'wide', label: 'Wide' },
29
+ { value: 'full', label: 'Full' },
30
+ ];
31
+
32
+ interface Props {
33
+ /** The initial caption; the field seeds from it and the author edits a local copy. */
34
+ caption?: string;
35
+ /** The initial placement role, or null for the measure default. */
36
+ role?: FigureRole | null;
37
+ /** `wrap` for a bare image (the primary action wraps it), `edit` for an existing figure (the
38
+ * primary action updates it and a ghost action unwraps it). */
39
+ mode?: 'wrap' | 'edit';
40
+ /** Whether the image's alt is empty or marked decorative; the host derives it. Drives the
41
+ * alt-status row and the decorative-plus-caption warning. */
42
+ decorative?: boolean;
43
+ /** Emit the chosen caption and role: the host wraps (wrap mode) or updates (edit mode). */
44
+ onapply: (choice: { caption: string; role: FigureRole | null }) => void;
45
+ /** Emit the unwrap action (edit mode only); the host replaces the figure with its bare image. */
46
+ onunwrap?: () => void;
47
+ }
48
+
49
+ let { caption = '', role = null, mode = 'wrap', decorative = false, onapply, onunwrap }: Props =
50
+ $props();
51
+
52
+ // The author's working copies, seeded once from the props the control opened with. untrack marks
53
+ // the read a deliberate one-time seed (the control mounts fresh per image), not a reactive miss.
54
+ let captionValue = $state(untrack(() => caption));
55
+ let roleValue = $state<FigureRole | null>(untrack(() => role));
56
+
57
+ // The index of the active role in ROLE_OPTIONS, the roving-tabindex focus target.
58
+ const activeIndex = $derived(ROLE_OPTIONS.findIndex((o) => o.value === roleValue));
59
+
60
+ // The decorative-plus-caption contradiction: a decorative image is hidden from screen readers, so a
61
+ // visible caption on it is a state to flag (never blocked, surfaced for the author to resolve).
62
+ const decorativeWithCaption = $derived(decorative && captionValue.trim() !== '');
63
+
64
+ // The segment refs, so arrow-key navigation can move focus to the newly selected segment.
65
+ let segmentEls = $state<HTMLButtonElement[]>([]);
66
+
67
+ function pickRole(value: FigureRole | null) {
68
+ roleValue = value;
69
+ }
70
+
71
+ // Arrow keys move and select within the radiogroup (the roving-tabindex pattern); Home/End jump to
72
+ // the ends. Selection follows focus, the standard radiogroup behavior.
73
+ function onSegmentKeydown(e: KeyboardEvent, index: number) {
74
+ let next = index;
75
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % ROLE_OPTIONS.length;
76
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
77
+ next = (index - 1 + ROLE_OPTIONS.length) % ROLE_OPTIONS.length;
78
+ else if (e.key === 'Home') next = 0;
79
+ else if (e.key === 'End') next = ROLE_OPTIONS.length - 1;
80
+ else return;
81
+ e.preventDefault();
82
+ pickRole(ROLE_OPTIONS[next].value);
83
+ segmentEls[next]?.focus();
84
+ }
85
+
86
+ function submit(e: SubmitEvent) {
87
+ e.preventDefault();
88
+ onapply({ caption: captionValue.trim(), role: roleValue });
89
+ }
90
+ </script>
91
+
92
+ <form class="flex flex-col gap-4" onsubmit={submit}>
93
+ <div class="flex flex-col gap-1">
94
+ <label for="cairn-figure-caption" class="text-sm font-medium">Caption</label>
95
+ <input
96
+ id="cairn-figure-caption"
97
+ class="input w-full"
98
+ type="text"
99
+ placeholder="Describe what the image adds to the post"
100
+ aria-describedby="cairn-figure-caption-hint"
101
+ bind:value={captionValue}
102
+ />
103
+ <p id="cairn-figure-caption-hint" class="text-xs text-[var(--color-muted)]">
104
+ Shown under the image, for everyone. This is not the alt text.
105
+ </p>
106
+ </div>
107
+
108
+ <!-- The alt-status row: the image's alt state the host derives. Described or Needs alt, the latter
109
+ in the warning ink with a glyph so the state never rides hue alone (WCAG 1.4.1). -->
110
+ <div class="flex items-center gap-2 text-sm">
111
+ <span class="font-medium" aria-hidden="true">Alt text</span>
112
+ {#if decorative}
113
+ <span
114
+ class="inline-flex items-center gap-1 font-medium text-[var(--cairn-warning-ink)]"
115
+ data-cairn-alt-status="needs"
116
+ aria-label="Alt text: needs a description"
117
+ >
118
+ <svg
119
+ width="13"
120
+ height="13"
121
+ viewBox="0 0 24 24"
122
+ fill="none"
123
+ stroke="currentColor"
124
+ stroke-width="2.4"
125
+ stroke-linecap="round"
126
+ stroke-linejoin="round"
127
+ aria-hidden="true"
128
+ >
129
+ <path d="M10.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" />
130
+ <line x1="12" y1="9" x2="12" y2="13" />
131
+ <line x1="12" y1="17" x2="12.01" y2="17" />
132
+ </svg>
133
+ Needs alt
134
+ </span>
135
+ {:else}
136
+ <span
137
+ class="inline-flex items-center gap-1 text-[var(--color-muted)]"
138
+ data-cairn-alt-status="described"
139
+ aria-label="Alt text: described"
140
+ >
141
+ <svg
142
+ width="13"
143
+ height="13"
144
+ viewBox="0 0 24 24"
145
+ fill="none"
146
+ stroke="currentColor"
147
+ stroke-width="3"
148
+ stroke-linecap="round"
149
+ stroke-linejoin="round"
150
+ aria-hidden="true"
151
+ >
152
+ <path d="M20 6 9 17l-5-5" />
153
+ </svg>
154
+ Described
155
+ </span>
156
+ {/if}
157
+ </div>
158
+
159
+ <div class="flex flex-col gap-1">
160
+ <span id="cairn-figure-placement-label" class="text-sm font-medium">Placement</span>
161
+ <!-- The segmented control: one bordered group, borderless segments, the active one tinted with a
162
+ check glyph. A roving-tabindex radiogroup, so arrow keys move and select and one Tab stop
163
+ reaches the group. -->
164
+ <div
165
+ role="radiogroup"
166
+ aria-labelledby="cairn-figure-placement-label"
167
+ class="bg-base-100 inline-flex items-center self-start overflow-hidden rounded-lg border border-[var(--cairn-card-border)]"
168
+ >
169
+ {#each ROLE_OPTIONS as option, index (option.label)}
170
+ {@const pressed = roleValue === option.value}
171
+ <button
172
+ bind:this={segmentEls[index]}
173
+ type="button"
174
+ role="radio"
175
+ aria-checked={pressed}
176
+ tabindex={index === activeIndex ? 0 : -1}
177
+ class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-normal {index > 0
178
+ ? 'border-l border-[var(--cairn-card-border)]'
179
+ : ''} {pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}"
180
+ onclick={() => pickRole(option.value)}
181
+ onkeydown={(e) => onSegmentKeydown(e, index)}
182
+ >
183
+ {#if pressed}
184
+ <svg
185
+ class="h-3 w-3"
186
+ viewBox="0 0 24 24"
187
+ fill="none"
188
+ stroke="currentColor"
189
+ stroke-width="2.5"
190
+ stroke-linecap="round"
191
+ stroke-linejoin="round"
192
+ aria-hidden="true"
193
+ >
194
+ <path d="M20 6 9 17l-5-5" />
195
+ </svg>
196
+ {/if}
197
+ {option.label}
198
+ </button>
199
+ {/each}
200
+ </div>
201
+ <p class="text-xs text-[var(--color-muted)]">
202
+ Center suits an image narrower than the text column. Measure keeps it at the column width.
203
+ </p>
204
+ </div>
205
+
206
+ <!-- An always-present polite live region, so the contradiction announces when it appears in
207
+ response to the author's own typing (not only on the dialog's open read). Empty and invisible
208
+ until the warning fills it. -->
209
+ <div role="status" aria-live="polite">
210
+ {#if decorativeWithCaption}
211
+ <div
212
+ class="flex items-start gap-2 rounded-[0.55rem] p-2.5 text-[var(--cairn-warning-ink)]"
213
+ style="background: color-mix(in oklab, var(--cairn-warning-ink) 8%, transparent);"
214
+ >
215
+ <svg
216
+ width="14"
217
+ height="14"
218
+ viewBox="0 0 24 24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ stroke-width="2.2"
222
+ stroke-linecap="round"
223
+ stroke-linejoin="round"
224
+ class="mt-0.5 flex-none"
225
+ aria-hidden="true"
226
+ >
227
+ <path d="M10.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" />
228
+ <line x1="12" y1="9" x2="12" y2="13" />
229
+ <line x1="12" y1="17" x2="12.01" y2="17" />
230
+ </svg>
231
+ <p class="m-0 text-xs leading-relaxed">
232
+ A decorative image is hidden from screen readers, but this one has a caption. Describe it, or
233
+ remove the caption.
234
+ </p>
235
+ </div>
236
+ {/if}
237
+ </div>
238
+
239
+ <div class="flex justify-end gap-2">
240
+ {#if mode === 'edit'}
241
+ <button type="button" class="btn btn-sm btn-ghost" onclick={() => onunwrap?.()}>Unwrap</button>
242
+ <button type="submit" class="btn btn-sm btn-primary">Update figure</button>
243
+ {:else}
244
+ <button type="submit" class="btn btn-sm btn-primary">Wrap in figure</button>
245
+ {/if}
246
+ </div>
247
+ </form>
@@ -0,0 +1,40 @@
1
+ import type { FigureRole } from './markdown-format.js';
2
+ interface Props {
3
+ /** The initial caption; the field seeds from it and the author edits a local copy. */
4
+ caption?: string;
5
+ /** The initial placement role, or null for the measure default. */
6
+ role?: FigureRole | null;
7
+ /** `wrap` for a bare image (the primary action wraps it), `edit` for an existing figure (the
8
+ * primary action updates it and a ghost action unwraps it). */
9
+ mode?: 'wrap' | 'edit';
10
+ /** Whether the image's alt is empty or marked decorative; the host derives it. Drives the
11
+ * alt-status row and the decorative-plus-caption warning. */
12
+ decorative?: boolean;
13
+ /** Emit the chosen caption and role: the host wraps (wrap mode) or updates (edit mode). */
14
+ onapply: (choice: {
15
+ caption: string;
16
+ role: FigureRole | null;
17
+ }) => void;
18
+ /** Emit the unwrap action (edit mode only); the host replaces the figure with its bare image. */
19
+ onunwrap?: () => void;
20
+ }
21
+ /**
22
+ * The figure control form: the caption and placement an author gives an inline media image. The host
23
+ * (EditPage) opens it over the media image at the caret, in `wrap` mode for a bare image or `edit` mode
24
+ * for an existing `:::figure`. It is the form CONTENT only; the host mounts it inside the Edit-block
25
+ * dialog. On submit it emits the chosen caption and role through `onapply`; in edit mode it also offers
26
+ * `onunwrap` to strip the figure back to the bare image.
27
+ *
28
+ * The caption is the visible line under the image, distinct from the alt text. The control surfaces the
29
+ * image's alt state (Described or Needs alt) that the host derives and passes; the deep needs-alt wiring
30
+ * is the host's. When the image is decorative AND the author gives it a caption, the control warns: a
31
+ * decorative image is hidden from screen readers, so a visible caption on it is a contradiction.
32
+ *
33
+ * The placement is a roving-tabindex radiogroup (Measure, Center, Wide, Full): one bordered group, the
34
+ * active segment tinted with a check glyph (the non-color state cue, WCAG 1.4.1), arrow keys move and
35
+ * select. Measure maps to the null role (the measure default, no role brace); the others map to their
36
+ * own name.
37
+ */
38
+ declare const MediaFigureControl: import("svelte").Component<Props, {}, "">;
39
+ type MediaFigureControl = ReturnType<typeof MediaFigureControl>;
40
+ export default MediaFigureControl;