@glw907/cairn-cms 0.60.0 → 0.62.1

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 (281) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/components/AdminLayout.svelte +152 -229
  3. package/dist/components/CairnAdmin.svelte +13 -42
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +194 -261
  7. package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +781 -1205
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/HelpHome.svelte +824 -0
  17. package/dist/components/HelpHome.svelte.d.ts +22 -0
  18. package/dist/components/IconPicker.svelte +23 -53
  19. package/dist/components/LinkPicker.svelte +34 -58
  20. package/dist/components/LoginPage.svelte +14 -27
  21. package/dist/components/ManageEditors.svelte +3 -15
  22. package/dist/components/MarkdownEditor.svelte +689 -957
  23. package/dist/components/MarkdownHelpDialog.svelte +12 -27
  24. package/dist/components/MediaCaptureCard.svelte +18 -57
  25. package/dist/components/MediaFigureControl.svelte +32 -71
  26. package/dist/components/MediaHeroField.svelte +210 -329
  27. package/dist/components/MediaInsertPopover.svelte +156 -283
  28. package/dist/components/MediaPicker.svelte +67 -131
  29. package/dist/components/NavTree.svelte +46 -78
  30. package/dist/components/RenameDialog.svelte +16 -43
  31. package/dist/components/ShortcutsDialog.svelte +9 -13
  32. package/dist/components/ShortcutsGrid.svelte +1 -2
  33. package/dist/components/TidyReview.svelte +140 -248
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +4 -0
  36. package/dist/components/client-ingest.d.ts +16 -8
  37. package/dist/components/client-ingest.js +12 -6
  38. package/dist/components/editor-media.js +16 -8
  39. package/dist/components/editor-placeholder.d.ts +4 -2
  40. package/dist/components/editor-tidy.d.ts +24 -12
  41. package/dist/components/editor-tidy.js +8 -4
  42. package/dist/components/index.d.ts +1 -0
  43. package/dist/components/index.js +1 -0
  44. package/dist/components/link-completion.d.ts +12 -6
  45. package/dist/components/link-completion.js +12 -6
  46. package/dist/components/markdown-directives.d.ts +9 -6
  47. package/dist/components/markdown-directives.js +9 -6
  48. package/dist/components/markdown-format.d.ts +7 -2
  49. package/dist/components/markdown-format.js +59 -28
  50. package/dist/components/markdown-reference.d.ts +8 -0
  51. package/dist/components/markdown-reference.js +22 -0
  52. package/dist/components/media-upload-outcome.d.ts +12 -6
  53. package/dist/components/objective-errors.d.ts +8 -4
  54. package/dist/components/objective-errors.js +8 -4
  55. package/dist/components/preview-doc.d.ts +4 -2
  56. package/dist/components/preview-doc.js +4 -2
  57. package/dist/components/spellcheck.d.ts +57 -29
  58. package/dist/components/spellcheck.js +50 -20
  59. package/dist/components/tidy-categorize.d.ts +20 -10
  60. package/dist/components/tidy-categorize.js +16 -8
  61. package/dist/components/tidy-validate.d.ts +12 -6
  62. package/dist/components/tidy-validate.js +20 -10
  63. package/dist/components/topbar-context.d.ts +4 -2
  64. package/dist/content/advisories.d.ts +51 -0
  65. package/dist/content/advisories.js +79 -0
  66. package/dist/content/compose.d.ts +4 -2
  67. package/dist/content/compose.js +1 -0
  68. package/dist/content/excerpt.js +4 -2
  69. package/dist/content/getting-started.d.ts +18 -0
  70. package/dist/content/getting-started.js +12 -0
  71. package/dist/content/links.d.ts +16 -8
  72. package/dist/content/links.js +12 -6
  73. package/dist/content/manifest.d.ts +36 -18
  74. package/dist/content/manifest.js +32 -16
  75. package/dist/content/media-refs.d.ts +4 -2
  76. package/dist/content/media-refs.js +4 -2
  77. package/dist/content/media-rewrite.d.ts +8 -4
  78. package/dist/content/media-rewrite.js +76 -38
  79. package/dist/content/schema.d.ts +20 -10
  80. package/dist/content/site-dictionary.d.ts +4 -2
  81. package/dist/content/site-dictionary.js +8 -4
  82. package/dist/content/types.d.ts +97 -42
  83. package/dist/delivery/CairnHead.svelte +8 -11
  84. package/dist/delivery/content-index.d.ts +16 -8
  85. package/dist/delivery/feeds.js +4 -2
  86. package/dist/delivery/json-ld.d.ts +3 -0
  87. package/dist/delivery/json-ld.js +3 -0
  88. package/dist/delivery/manifest.d.ts +4 -2
  89. package/dist/delivery/manifest.js +4 -2
  90. package/dist/delivery/public-routes.d.ts +12 -6
  91. package/dist/delivery/public-routes.js +4 -2
  92. package/dist/delivery/seo-fields.d.ts +12 -6
  93. package/dist/delivery/seo-fields.js +8 -4
  94. package/dist/delivery/site-indexes.d.ts +4 -2
  95. package/dist/delivery/site-resolver.d.ts +4 -2
  96. package/dist/delivery/site-resolver.js +4 -2
  97. package/dist/doctor/cloudflare-api.d.ts +6 -0
  98. package/dist/doctor/cloudflare-api.js +6 -0
  99. package/dist/doctor/index.d.ts +12 -6
  100. package/dist/doctor/report.d.ts +3 -0
  101. package/dist/doctor/report.js +3 -0
  102. package/dist/doctor/run.d.ts +3 -0
  103. package/dist/doctor/run.js +3 -0
  104. package/dist/doctor/types.d.ts +10 -2
  105. package/dist/doctor/types.js +6 -0
  106. package/dist/doctor/wrangler-config.d.ts +7 -2
  107. package/dist/doctor/wrangler-config.js +3 -0
  108. package/dist/email.d.ts +4 -2
  109. package/dist/env.d.ts +0 -3
  110. package/dist/env.js +0 -3
  111. package/dist/github/branches.d.ts +4 -2
  112. package/dist/github/branches.js +4 -2
  113. package/dist/github/signing.d.ts +1 -1
  114. package/dist/github/signing.js +2 -2
  115. package/dist/log/events.d.ts +1 -1
  116. package/dist/media/bulk-delete-plan.d.ts +8 -4
  117. package/dist/media/config.d.ts +12 -6
  118. package/dist/media/config.js +16 -8
  119. package/dist/media/delivery-bucket.d.ts +4 -2
  120. package/dist/media/library-entry.d.ts +4 -2
  121. package/dist/media/library-entry.js +4 -2
  122. package/dist/media/manifest.d.ts +29 -15
  123. package/dist/media/manifest.js +29 -16
  124. package/dist/media/naming.d.ts +12 -6
  125. package/dist/media/naming.js +24 -12
  126. package/dist/media/orphan-scan.d.ts +4 -2
  127. package/dist/media/reconcile.d.ts +21 -11
  128. package/dist/media/reconcile.js +12 -6
  129. package/dist/media/reference.d.ts +8 -4
  130. package/dist/media/reference.js +12 -6
  131. package/dist/media/rewrite-plan.d.ts +12 -6
  132. package/dist/media/sniff.d.ts +4 -2
  133. package/dist/media/sniff.js +28 -14
  134. package/dist/media/store.d.ts +16 -8
  135. package/dist/media/store.js +4 -2
  136. package/dist/media/transform-url.d.ts +12 -6
  137. package/dist/media/transform-url.js +8 -4
  138. package/dist/media/usage.d.ts +8 -4
  139. package/dist/nav/site-config.d.ts +16 -8
  140. package/dist/render/component-grammar.d.ts +23 -10
  141. package/dist/render/component-grammar.js +19 -8
  142. package/dist/render/component-insert.d.ts +8 -4
  143. package/dist/render/component-insert.js +4 -2
  144. package/dist/render/component-reference.d.ts +4 -2
  145. package/dist/render/component-reference.js +4 -2
  146. package/dist/render/component-validate.d.ts +3 -0
  147. package/dist/render/component-validate.js +3 -0
  148. package/dist/render/glyph.d.ts +4 -2
  149. package/dist/render/glyph.js +4 -2
  150. package/dist/render/pipeline.d.ts +20 -10
  151. package/dist/render/pipeline.js +4 -2
  152. package/dist/render/registry.d.ts +40 -20
  153. package/dist/render/registry.js +16 -8
  154. package/dist/render/rehype-dispatch.d.ts +22 -8
  155. package/dist/render/rehype-dispatch.js +22 -8
  156. package/dist/render/remark-directives.d.ts +3 -0
  157. package/dist/render/remark-directives.js +3 -0
  158. package/dist/render/remark-figure.d.ts +4 -2
  159. package/dist/render/remark-figure.js +4 -2
  160. package/dist/render/resolve-links.d.ts +4 -2
  161. package/dist/render/resolve-links.js +4 -2
  162. package/dist/render/resolve-media.d.ts +16 -8
  163. package/dist/render/resolve-media.js +12 -6
  164. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  165. package/dist/sveltekit/admin-dispatch.js +9 -3
  166. package/dist/sveltekit/auth-routes.d.ts +3 -0
  167. package/dist/sveltekit/auth-routes.js +3 -0
  168. package/dist/sveltekit/cairn-admin.d.ts +16 -5
  169. package/dist/sveltekit/cairn-admin.js +26 -10
  170. package/dist/sveltekit/content-routes.d.ts +191 -86
  171. package/dist/sveltekit/content-routes.js +295 -107
  172. package/dist/sveltekit/editors-routes.d.ts +3 -0
  173. package/dist/sveltekit/editors-routes.js +3 -0
  174. package/dist/sveltekit/guard.d.ts +4 -2
  175. package/dist/sveltekit/guard.js +4 -2
  176. package/dist/sveltekit/https-required-page.d.ts +1 -1
  177. package/dist/sveltekit/https-required-page.js +1 -1
  178. package/dist/sveltekit/index.d.ts +1 -1
  179. package/dist/sveltekit/media-route.d.ts +1 -2
  180. package/dist/sveltekit/media-route.js +13 -8
  181. package/dist/sveltekit/nav-routes.d.ts +7 -2
  182. package/dist/sveltekit/nav-routes.js +3 -0
  183. package/dist/sveltekit/types.d.ts +4 -2
  184. package/dist/vite/index.d.ts +32 -16
  185. package/dist/vite/index.js +52 -26
  186. package/dist/vite/resolve-root.d.ts +8 -4
  187. package/dist/vite/resolve-root.js +4 -2
  188. package/package.json +8 -2
  189. package/src/lib/components/AdminLayout.svelte +22 -0
  190. package/src/lib/components/CairnAdmin.svelte +3 -0
  191. package/src/lib/components/CairnTidySettings.svelte +2 -2
  192. package/src/lib/components/ComponentForm.svelte +0 -1
  193. package/src/lib/components/EditPage.svelte +133 -41
  194. package/src/lib/components/HelpHome.svelte +850 -0
  195. package/src/lib/components/MarkdownHelpDialog.svelte +4 -15
  196. package/src/lib/components/client-ingest.ts +20 -10
  197. package/src/lib/components/editor-media.ts +20 -10
  198. package/src/lib/components/editor-placeholder.ts +12 -6
  199. package/src/lib/components/editor-tidy.ts +28 -14
  200. package/src/lib/components/index.ts +1 -0
  201. package/src/lib/components/link-completion.ts +12 -6
  202. package/src/lib/components/markdown-directives.ts +13 -8
  203. package/src/lib/components/markdown-format.ts +63 -30
  204. package/src/lib/components/markdown-reference.ts +30 -0
  205. package/src/lib/components/media-upload-outcome.ts +12 -6
  206. package/src/lib/components/objective-errors.ts +16 -8
  207. package/src/lib/components/preview-doc.ts +4 -2
  208. package/src/lib/components/spellcheck.ts +92 -40
  209. package/src/lib/components/tidy-categorize.ts +28 -14
  210. package/src/lib/components/tidy-validate.ts +28 -14
  211. package/src/lib/components/topbar-context.ts +4 -2
  212. package/src/lib/content/advisories.ts +141 -0
  213. package/src/lib/content/compose.ts +5 -2
  214. package/src/lib/content/excerpt.ts +4 -2
  215. package/src/lib/content/getting-started.ts +31 -0
  216. package/src/lib/content/links.ts +16 -8
  217. package/src/lib/content/manifest.ts +36 -18
  218. package/src/lib/content/media-refs.ts +4 -2
  219. package/src/lib/content/media-rewrite.ts +100 -50
  220. package/src/lib/content/schema.ts +20 -10
  221. package/src/lib/content/site-dictionary.ts +8 -4
  222. package/src/lib/content/types.ts +97 -42
  223. package/src/lib/delivery/content-index.ts +16 -8
  224. package/src/lib/delivery/feeds.ts +4 -2
  225. package/src/lib/delivery/json-ld.ts +3 -0
  226. package/src/lib/delivery/manifest.ts +4 -2
  227. package/src/lib/delivery/public-routes.ts +16 -8
  228. package/src/lib/delivery/seo-fields.ts +12 -6
  229. package/src/lib/delivery/site-indexes.ts +4 -2
  230. package/src/lib/delivery/site-resolver.ts +4 -2
  231. package/src/lib/doctor/cloudflare-api.ts +6 -0
  232. package/src/lib/doctor/index.ts +12 -6
  233. package/src/lib/doctor/report.ts +3 -0
  234. package/src/lib/doctor/run.ts +3 -0
  235. package/src/lib/doctor/types.ts +10 -2
  236. package/src/lib/doctor/wrangler-config.ts +7 -2
  237. package/src/lib/email.ts +4 -2
  238. package/src/lib/env.ts +0 -3
  239. package/src/lib/github/branches.ts +4 -2
  240. package/src/lib/github/signing.ts +2 -2
  241. package/src/lib/log/events.ts +1 -0
  242. package/src/lib/media/bulk-delete-plan.ts +8 -4
  243. package/src/lib/media/config.ts +24 -12
  244. package/src/lib/media/delivery-bucket.ts +4 -2
  245. package/src/lib/media/library-entry.ts +4 -2
  246. package/src/lib/media/manifest.ts +33 -18
  247. package/src/lib/media/naming.ts +24 -12
  248. package/src/lib/media/orphan-scan.ts +4 -2
  249. package/src/lib/media/reconcile.ts +21 -11
  250. package/src/lib/media/reference.ts +12 -6
  251. package/src/lib/media/rewrite-plan.ts +12 -6
  252. package/src/lib/media/sniff.ts +28 -14
  253. package/src/lib/media/store.ts +16 -8
  254. package/src/lib/media/transform-url.ts +12 -6
  255. package/src/lib/media/usage.ts +8 -4
  256. package/src/lib/nav/site-config.ts +16 -8
  257. package/src/lib/render/component-grammar.ts +23 -10
  258. package/src/lib/render/component-insert.ts +8 -4
  259. package/src/lib/render/component-reference.ts +4 -2
  260. package/src/lib/render/component-validate.ts +3 -0
  261. package/src/lib/render/glyph.ts +4 -2
  262. package/src/lib/render/pipeline.ts +20 -10
  263. package/src/lib/render/registry.ts +44 -22
  264. package/src/lib/render/rehype-dispatch.ts +22 -8
  265. package/src/lib/render/remark-directives.ts +3 -0
  266. package/src/lib/render/remark-figure.ts +4 -2
  267. package/src/lib/render/resolve-links.ts +4 -2
  268. package/src/lib/render/resolve-media.ts +16 -8
  269. package/src/lib/sveltekit/admin-dispatch.ts +10 -4
  270. package/src/lib/sveltekit/auth-routes.ts +3 -0
  271. package/src/lib/sveltekit/cairn-admin.ts +37 -15
  272. package/src/lib/sveltekit/content-routes.ts +492 -197
  273. package/src/lib/sveltekit/editors-routes.ts +3 -0
  274. package/src/lib/sveltekit/guard.ts +4 -2
  275. package/src/lib/sveltekit/https-required-page.ts +1 -1
  276. package/src/lib/sveltekit/index.ts +3 -0
  277. package/src/lib/sveltekit/media-route.ts +13 -8
  278. package/src/lib/sveltekit/nav-routes.ts +7 -2
  279. package/src/lib/sveltekit/types.ts +4 -2
  280. package/src/lib/vite/index.ts +60 -30
  281. package/src/lib/vite/resolve-root.ts +8 -4
@@ -7,975 +7,707 @@ the hidden field mirrors the value throughout. The host owns the toolbar and the
7
7
  selection transforms through the registerFormat seam; the design-accurate preview lives in EditPage
8
8
  through the adapter's render. Swapping the editor stays a one-file change.
9
9
  -->
10
- <script lang="ts">
11
- import { onMount, onDestroy } from 'svelte';
12
- import { applyMarkdownFormat, figureAtImage, insertImage as insertImageFormat, insertInlineLink, type FigureAtImage, type FormatKind, type FormatResult } from './markdown-format.js';
13
- import { fenceScan, caretContainerRange, directiveOpenerName } from './markdown-directives.js';
14
- import { firstImageFile, guardDropTarget } from './client-ingest.js';
15
- import type { MediaLibrary } from '../media/library-entry.js';
16
-
17
- /** The directive container at the caret: the opener's name, the block's markdown, and the
18
- * document character offsets of its inclusive line range. */
19
- interface ComponentAtCaret {
20
- name: string | null;
21
- markdown: string;
22
- from: number;
23
- to: number;
24
- }
25
-
26
- interface Props {
27
- /** The markdown source; bindable so the parent reads edits back. */
28
- value: string;
29
- /** The hidden field name the value is mirrored to for form submit. */
30
- name: string;
31
- /** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
32
- registerInsert?: (insert: (text: string) => void) => void;
33
- /** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
34
- registerInsertLink?: (insert: (href: string, title: string) => void) => void;
35
- /** Receives an `(alt, ref) => void` that inserts an inline image at the caret; the media picker
36
- * and the capture card call it with the chosen alt and the full `media:slug.hash` reference. */
37
- registerInsertImage?: (insert: (alt: string, ref: string) => void) => void;
38
- /** Called with the first image File of a paste or drop onto the surface; the host opens the
39
- * capture card with the bytes. A paste or drop carrying no image falls through untouched. */
40
- onImageIngest?: (file: File) => void;
41
- /** The picker's human layer per stored asset, keyed by the 16-hex content hash (EditData's
42
- * `mediaLibrary`). The source decoration reads it to render a `media:` token as a thumbnail chip;
43
- * reactive, so a just-uploaded image decorates once it joins the library. Empty by default. */
44
- mediaLibrary?: MediaLibrary;
45
- /** Receives a `() => { left; right; top; bottom } | null` returning the caret's viewport
46
- * coordinates; the insert popover anchors itself to the cursor from this. Null before mount or
47
- * when the caret has no measurable position. */
48
- registerCaretCoords?: (
49
- get: () => { left: number; right: number; top: number; bottom: number } | null,
50
- ) => void;
51
- /** Receives a `() => void` that returns focus to the editor; the insert popover calls it on close
52
- * or Escape. The selection is preserved automatically, since opening the popover only blurs the
53
- * editor and never edits the doc. */
54
- registerFocusEditor?: (focus: () => void) => void;
55
- /** Receives the optimistic-placeholder api; the insert popover drives the upload loop through it
56
- * (begin lands a placeholder at the caret, progress moves its bar, resolveTo swaps it for the
57
- * committed image text, cancel removes it leaving the source untouched). */
58
- registerImagePlaceholders?: (api: import('./editor-placeholder.js').ImagePlaceholderApi) => void;
59
- /** Receives a `() => string` returning the selected text; the web link dialog reads it. */
60
- registerGetSelection?: (get: () => string) => void;
61
- /** Receives a `() => { from, to } | null` returning the selection's document offsets, or null when
62
- * the selection is empty (a bare caret). The tidy host reads it so a selection tidy maps onto the
63
- * exact selected span, never an identical-looking passage earlier in the document. */
64
- registerGetSelectionRange?: (get: () => { from: number; to: number } | null) => void;
65
- /** Receives the tidy apply api (spec 2.5): the review surface drives the in-buffer decorations and
66
- * the accept/reject state machine through it. The author's original stays in the buffer until an
67
- * accept writes; a reject or reject-all leaves it byte-identical. */
68
- registerTidy?: (api: import('./editor-tidy.js').TidyApi) => void;
69
- /** Receives a `() => void` that undoes the last editor transaction; the "Undo tidy" chip calls it
70
- * to take the whole applied tidy back in one move (the apply lands as one history entry). */
71
- registerUndo?: (undo: () => void) => void;
72
- /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
73
- registerFormat?: (format: (kind: FormatKind) => void) => void;
74
- /** Reports the directive container at the caret (or null when outside any container) whenever
75
- * the reported value changes; the host resolves it against the registry to offer Edit-block. */
76
- onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
77
- /** Reports the media image at the caret (or null when the caret is not on one) whenever the
78
- * reported value changes; the host opens the figure control over it to wrap, edit, or unwrap a
79
- * `:::figure`. The figure transforms write source through registerReplaceRange. */
80
- onMediaImageAtCaret?: (info: FigureAtImage | null) => void;
81
- /** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
82
- * calls it to write an edited block back over its original range. */
83
- registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
84
- /** Receives a `(from, to) => void` that selects a document span, focuses the editor, and scrolls
85
- * the range into view; the needs-alt notice's jump control calls it to land the author on an
86
- * image that lacks alt text. */
87
- registerSelectRange?: (select: (from: number, to: number) => void) => void;
88
- /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
89
- * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
90
- completionSources?: import('@codemirror/autocomplete').CompletionSource[];
91
- /** Focus mode: dim every line outside the caret's paragraph. Off by default. */
92
- focusMode?: boolean;
93
- /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
94
- typewriter?: boolean;
95
- /** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
96
- * leading); markup is the working surface (fills the card, denser). Prose by default. */
97
- surface?: 'prose' | 'markup';
98
- /** Spellcheck and the objective-error layer: the markdown-aware lint underlines. On by default;
99
- * when off the lint compartment reconfigures to empty (the underlines vanish, the Worker stays
100
- * idle). The footer toggle drives this. */
101
- spellcheck?: boolean;
102
- /** The dialect-resolved dictionary filename, e.g. "dictionary-en-us.txt", from EditData. The
103
- * source resolves it to a real asset URL and hands it to the spellcheck Worker's init. Defaults to
104
- * US English. */
105
- spellcheckDictionary?: string;
106
- /** The committed personal-dictionary words (spec 1.6), from EditData.siteDictionary. The lint
107
- * source seeds the spellcheck Worker's personal layer with these at init, so a word another editor
108
- * committed answers correct from the first lint. Empty by default (dialect-only). */
109
- siteDictionary?: ReadonlyArray<string>;
110
- /** The caller-owned pending personal-dictionary additions. When an author chooses "Add to
111
- * dictionary" the lint source adds the lowercased word here (the underline clears at once); the
112
- * host (EditPage) commits this set through the addDictionaryWord action at save time and reconciles
113
- * it against the merged response. A fresh set by default. */
114
- pendingAdditions?: Set<string>;
115
- /** Test-only seam for the spellcheck Worker. The real wasm and dictionary assets are resolved with
116
- * `import.meta.url` and do not load under the vitest browser dev server, so the component test
117
- * injects a deterministic fake Worker factory and asks the lint source to skip the `ready` wait.
118
- * When this is absent the production path is untouched: the real `new Worker(...)` and the real
119
- * asset resolution. Never set this outside a test. */
120
- spellcheckTest?: {
121
- createWorker?: () => import('./spellcheck.js').SpellWorker;
122
- assumeReady?: boolean;
123
- };
124
- /** Tidy mode: while a tidy review is open the surface is read-only the way Preview disables the
125
- * toolbar, so the author cannot edit underneath a pending review. The host sets this when it opens
126
- * the review and clears it on apply or cancel. Off by default. */
127
- tidyMode?: boolean;
128
- }
129
-
130
- let {
131
- value = $bindable(),
132
- name,
133
- registerInsert,
134
- registerInsertLink,
135
- registerInsertImage,
136
- onImageIngest,
137
- mediaLibrary = {},
138
- registerCaretCoords,
139
- registerFocusEditor,
140
- registerImagePlaceholders,
141
- registerGetSelection,
142
- registerGetSelectionRange,
143
- registerTidy,
144
- registerUndo,
145
- registerFormat,
146
- onComponentAtCaret,
147
- onMediaImageAtCaret,
148
- registerReplaceRange,
149
- registerSelectRange,
150
- completionSources = [],
151
- focusMode = false,
152
- typewriter = false,
153
- surface = 'prose',
154
- spellcheck = true,
155
- spellcheckDictionary = 'dictionary-en-us.txt',
156
- siteDictionary = [],
157
- pendingAdditions = new Set<string>(),
158
- spellcheckTest,
159
- tidyMode = false,
160
- }: Props = $props();
161
-
162
- let host = $state<HTMLDivElement | null>(null);
163
- let mounted = $state(false);
164
- // The CodeMirror view, untyped at the runtime boundary because @codemirror/* loads only in the
165
- // browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
166
- // so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
167
- let view: import('@codemirror/view').EditorView | null = null;
168
- // The writing-mode extensions live in their own compartments so the toolbar toggles swap them
169
- // in and out of the mounted editor without rebuilding it. Assigned in onMount with the rest of
170
- // the dynamic editor modules.
171
- let modes: typeof import('./editor-modes.js') | null = null;
172
- let focusCompartment: import('@codemirror/state').Compartment | null = null;
173
- let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
174
- let surfaceCompartment: import('@codemirror/state').Compartment | null = null;
175
- // The media: source decoration lives in its own compartment, reconfigured when the mediaLibrary
176
- // prop changes so a just-uploaded image decorates the moment it joins the library. The media
177
- // module loads with the other dynamic editor modules in onMount.
178
- let mediaCompartment: import('@codemirror/state').Compartment | null = null;
179
- let mediaMod: typeof import('./editor-media.js') | null = null;
180
- // The spellcheck lint source (and the objective-error layer it bundles) live in their own
181
- // compartment, reconfigured to empty when the footer toggle turns spellcheck off. Both surfaces ride
182
- // the one extension cairnSpellcheck returns, so one compartment gates both. The extension is built
183
- // asynchronously (it lazy-imports CodeMirror and the lint modules), so it is held here once resolved
184
- // and the on/off effect reconfigures against it.
185
- let spellcheckCompartment: import('@codemirror/state').Compartment | null = null;
186
- let spellcheckExt: import('@codemirror/state').Extension | null = null;
187
- // The tidy decoration field lives in its own compartment (entering and leaving tidy is a reconfigure,
188
- // not a rebuild) beside the media and fold decorations. A second compartment carries the read-only +
189
- // edit-disable extension while a review is open, so the author cannot edit underneath a pending
190
- // review (the same posture Preview takes on the toolbar). Both load with the other editor modules.
191
- let tidyMod: typeof import('./editor-tidy.js') | null = null;
192
- let tidyCompartment: import('@codemirror/state').Compartment | null = null;
193
- let tidyReadonlyCompartment: import('@codemirror/state').Compartment | null = null;
194
- let tidyReadonlyExt: import('@codemirror/state').Extension | null = null;
195
- // The posture themes, swapped through the surface compartment. Each owns its type step and
196
- // leading (the base theme deliberately sets neither on the content node, so the postures never
197
- // contest it on adoption order). Built in onMount beside the base theme.
198
- let proseTheme: import('@codemirror/state').Extension | null = null;
199
- let markupTheme: import('@codemirror/state').Extension | null = null;
200
-
201
- onMount(async () => {
202
- const viewMod = await import('@codemirror/view');
203
- const stateMod = await import('@codemirror/state');
204
- const markdownMod = await import('@codemirror/lang-markdown');
205
- const commandsMod = await import('@codemirror/commands');
206
- const languageMod = await import('@codemirror/language');
207
- const lintMod = await import('@codemirror/lint');
208
- const autocompleteMod = await import('@codemirror/autocomplete');
209
- const highlightMod = await import('./editor-highlight.js');
210
- const modesMod = await import('./editor-modes.js');
211
- const foldingMod = await import('./editor-folding.js');
212
- const placeholderMod = await import('./editor-placeholder.js');
213
- mediaMod = await import('./editor-media.js');
214
- tidyMod = await import('./editor-tidy.js');
215
- const spellcheckMod = await import('./spellcheck.js');
216
-
217
- if (!host) return;
218
-
219
- const { EditorView, keymap } = viewMod;
220
- // Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
221
- // tooltip above all) renders dark-on-dark instead of light-on-dark.
222
- const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
223
- // The directive machinery treatment: rails, not bands. A row at depth N draws every rail
224
- // 1..N as literal nested brackets: 2px accent bars on an 8px pitch (x offsets 0-2, 8-10,
225
- // and 16-18) with 6px of surface between them (three times the bar weight, so nested bars
226
- // separate cleanly instead of reading as one thick rule), stacked as inset box shadows (top
227
- // layer first, so each bar sits over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
228
- // cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
229
- // outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
230
- // braces dim to the marker tone while the name and label keep a depth-stepped ink. Leaf and
231
- // inline directives keep a fixed 8% accent chip; the accent ink holds AA on it (4.75:1
232
- // light, 5.20:1 dark).
233
- const railFallbacks = ['72%', '82%', '92%'];
234
- const railColor = (step: number | 'active', fallback: string) =>
235
- `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
236
- // With `active`, the row's own (deepest) bar takes the full-strength -active mix at the same
237
- // 2px width. The emphasis is strength only: a rail column carrying both an active and a
238
- // quiet segment (two sibling containers at one depth) keeps one weight top to bottom. A paired
239
- // opener row paints its full rail like any other fence row; the fold chevron lives in the gutter
240
- // column left of the rails, so the opener no longer drops its innermost bar.
241
- const rails = (depth: number, active = false): string => {
242
- const layers: string[] = [];
243
- for (let d = 1; d <= depth; d++) {
244
- const edge = 8 * d - 6;
245
- if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
246
- const own = active && d === depth;
247
- layers.push(
248
- `inset ${edge}px 0 0 0 ${own ? railColor('active', '100%') : railColor(d, railFallbacks[d - 1] ?? '92%')}`,
249
- );
250
- }
251
- return layers.join(', ');
252
- };
253
- const directiveInk = {
254
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
255
- color: 'var(--color-accent)',
256
- };
257
- // The rail rules, one quiet and one caret-active pair per visual depth step (deeper nesting
258
- // shares the third step). Fence and content rows at a depth share a rule, so a fence and its
259
- // body rail identically. The caret-active selector adds the caret-block class, so it outranks
260
- // its quiet twin on any contested row and the caret's container reads one step stronger.
261
- const railRules: Record<string, { boxShadow: string }> = {};
262
- for (const depth of [1, 2, 3]) {
263
- const row = (prefix: string) =>
264
- `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
265
- railRules[row('')] = { boxShadow: rails(depth) };
266
- railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
10
+ <script lang="ts">import { onMount, onDestroy } from "svelte";
11
+ import { applyMarkdownFormat, figureAtImage, insertImage as insertImageFormat, insertInlineLink } from "./markdown-format.js";
12
+ import { fenceScan, caretContainerRange, directiveOpenerName } from "./markdown-directives.js";
13
+ import { firstImageFile, guardDropTarget } from "./client-ingest.js";
14
+ let {
15
+ value = $bindable(),
16
+ name,
17
+ registerInsert,
18
+ registerInsertLink,
19
+ registerInsertImage,
20
+ onImageIngest,
21
+ mediaLibrary = {},
22
+ registerCaretCoords,
23
+ registerFocusEditor,
24
+ registerImagePlaceholders,
25
+ registerGetSelection,
26
+ registerGetSelectionRange,
27
+ registerTidy,
28
+ registerUndo,
29
+ registerFormat,
30
+ onComponentAtCaret,
31
+ onMediaImageAtCaret,
32
+ registerReplaceRange,
33
+ registerSelectRange,
34
+ completionSources = [],
35
+ focusMode = false,
36
+ typewriter = false,
37
+ surface = "prose",
38
+ spellcheck = true,
39
+ spellcheckDictionary = "dictionary-en-us.txt",
40
+ siteDictionary = [],
41
+ pendingAdditions = /* @__PURE__ */ new Set(),
42
+ spellcheckTest,
43
+ tidyMode = false
44
+ } = $props();
45
+ let host = $state(null);
46
+ let mounted = $state(false);
47
+ let view = null;
48
+ let modes = null;
49
+ let focusCompartment = null;
50
+ let typewriterCompartment = null;
51
+ let surfaceCompartment = null;
52
+ let mediaCompartment = null;
53
+ let mediaMod = null;
54
+ let spellcheckCompartment = null;
55
+ let spellcheckExt = null;
56
+ let tidyMod = null;
57
+ let tidyCompartment = null;
58
+ let tidyReadonlyCompartment = null;
59
+ let tidyReadonlyExt = null;
60
+ let proseTheme = null;
61
+ let markupTheme = null;
62
+ onMount(async () => {
63
+ const viewMod = await import("@codemirror/view");
64
+ const stateMod = await import("@codemirror/state");
65
+ const markdownMod = await import("@codemirror/lang-markdown");
66
+ const commandsMod = await import("@codemirror/commands");
67
+ const languageMod = await import("@codemirror/language");
68
+ const lintMod = await import("@codemirror/lint");
69
+ const autocompleteMod = await import("@codemirror/autocomplete");
70
+ const highlightMod = await import("./editor-highlight.js");
71
+ const modesMod = await import("./editor-modes.js");
72
+ const foldingMod = await import("./editor-folding.js");
73
+ const placeholderMod = await import("./editor-placeholder.js");
74
+ mediaMod = await import("./editor-media.js");
75
+ tidyMod = await import("./editor-tidy.js");
76
+ const spellcheckMod = await import("./spellcheck.js");
77
+ if (!host) return;
78
+ const { EditorView, keymap } = viewMod;
79
+ const isDark = host.closest("[data-theme]")?.getAttribute("data-theme")?.includes("dark") ?? false;
80
+ const railFallbacks = ["72%", "82%", "92%"];
81
+ const railColor = (step, fallback) => `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
82
+ const rails = (depth, active = false) => {
83
+ const layers = [];
84
+ for (let d = 1; d <= depth; d++) {
85
+ const edge = 8 * d - 6;
86
+ if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
87
+ const own = active && d === depth;
88
+ layers.push(
89
+ `inset ${edge}px 0 0 0 ${own ? railColor("active", "100%") : railColor(d, railFallbacks[d - 1] ?? "92%")}`
90
+ );
267
91
  }
268
- const theme = EditorView.theme(
269
- {
270
- '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
271
- // The 60vh floor keeps the surface reading as the page's center stage even when the
272
- // entry is short, and because the contenteditable content area carries the height, a
273
- // click in the empty space below the text still lands in the editor and focuses it.
274
- // No inner measure cap: the surface fills the card the way a code editor fills its
275
- // pane, and the card's own width (the host caps it near 89ch of this face) is the one
276
- // constraint. The surface carries tables, attributed directives, and long URLs, so the
277
- // ceiling leans toward the code-editor end of the ergonomic band rather than the
278
- // long-form ideal; paragraphs wrap comfortably below it.
279
- '.cm-content': {
280
- // The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
281
- // fallback keeps the surface monospace outside an admin theme wrapper.
282
- fontFamily: "var(--font-editor, ui-monospace, monospace)",
283
- // Vertical padding holds at least one line-height of the body (1.8 x 1rem), with a
284
- // touch more below than above (the optical center sits high); the sides then read as
285
- // gutters rather than letterboxing.
286
- padding: '2rem 1.25rem 2.5rem',
287
- minHeight: '60vh',
288
- },
289
- '.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
290
- // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
291
- // focused text-entry surface as keyboard-modal, so a 2px ring would shout through every
292
- // typing session. One subtle line keeps focus visible (WCAG 2.4.7) without competing
293
- // with the manuscript. The 70% primary mix clears the 3:1 non-text contrast floor
294
- // (WCAG 1.4.11) on both themes (3.23:1 light, 3.32:1 dark), where 45% measured near 2:1.
295
- '&.cm-focused': {
296
- outline: '1px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
297
- outlineOffset: '-1px',
298
- },
299
- '.cm-line': { padding: '0' },
300
- // A quote or list line hangs its wrapped continuation under the content: padding-left
301
- // holds the marker width (the --cairn-hang the decoration sets) and the line's own
302
- // negative text-indent (set inline) pulls the first line back, so the marker sits in the
303
- // indent. This rule sits before the gutter rule so a container content line, which
304
- // carries both classes, takes the gutter-plus-hang rule below.
305
- '.cm-cairn-hang': { paddingLeft: 'var(--cairn-hang, 0ch)' },
306
- // The gutter: directive rows pad left so the text clears the deepest rail stack (the
307
- // depth-3 bar ends at 18px; 1.75rem keeps 10px of air beyond it). Static structure
308
- // (caret-independent), so caret movement shifts no layout. The --cairn-hang term composes
309
- // a quote/list marker's hang on top of the gutter; it defaults to 0 on rows without one.
310
- '.cm-cairn-directive-fence, .cm-cairn-directive-content': {
311
- paddingLeft: 'calc(1.75rem + var(--cairn-hang, 0ch))',
312
- },
313
- ...railRules,
314
- '.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
315
- '.cm-cairn-directive-label': { color: 'var(--color-accent)' },
316
- '.cm-cairn-directive-label.cm-cairn-depth-2': { color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))' },
317
- '.cm-cairn-directive-label.cm-cairn-depth-3': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
318
- // Cursor-aware emphasis for the label ink: the caret's container takes the strongest
319
- // ink, through the -active variable in cairn-admin.css. This selector TIES the depth
320
- // rules above at two classes, so its place after them breaks the tie in its favor.
321
- '.cm-cairn-caret-block .cm-cairn-directive-label': {
322
- color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))',
323
- },
324
- '.cm-cairn-directive-leaf': directiveInk,
325
- '.cm-cairn-directive-inline': directiveInk,
326
- // The media: source chip: the inline widget that stands in for a media reference token, in the
327
- // directive accent language (the 8% accent chip, the accent ink that holds AA on it). An
328
- // inline-flex pill carrying a small thumbnail and the asset's display name, so a reference
329
- // reads as the image it points at without leaving the source view.
330
- '.cm-cairn-media-chip': {
331
- display: 'inline-flex',
332
- alignItems: 'center',
333
- gap: '0.3em',
334
- verticalAlign: 'baseline',
335
- padding: '0.05em 0.4em 0.05em 0.25em',
336
- borderRadius: '0.375rem',
337
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
338
- color: 'var(--color-accent)',
339
- fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
340
- fontSize: '0.8125rem',
341
- lineHeight: '1.4',
342
- },
343
- // The thumbnail: a small square crop, rounded to match the chip. object-fit keeps a
344
- // non-square source from distorting. A faint border lifts a light image off the chip tint.
345
- '.cm-cairn-media-thumb': {
346
- width: '1.4em',
347
- height: '1.4em',
348
- objectFit: 'cover',
349
- borderRadius: '0.25rem',
350
- border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
351
- flex: '0 0 auto',
352
- },
353
- '.cm-cairn-media-name': {
354
- fontWeight: '500',
355
- // Keep a long name from stretching the line; the title and the picker carry the full text.
356
- maxWidth: '18ch',
357
- overflow: 'hidden',
358
- textOverflow: 'ellipsis',
359
- whiteSpace: 'nowrap',
360
- },
361
- // The needs-alt marker: a glyph plus a label, never hue alone (the spec accessibility rule).
362
- // It rides the warning tone so it reads as a caution, with the label spelling out the state.
363
- // The text uses --cairn-warning-ink, the on-surface warning text token, not --color-warning
364
- // (a fill tone that fails small-text contrast on the light chip tint, WCAG 1.4.3). The ink
365
- // holds AA on the chip's tint on both themes and stands apart from the accent name.
366
- '.cm-cairn-media-needs-alt': {
367
- display: 'inline-flex',
368
- alignItems: 'center',
369
- gap: '0.2em',
370
- color: 'var(--cairn-warning-ink, oklch(50% 0.13 70))',
371
- fontSize: '0.6875rem',
372
- fontWeight: '600',
373
- textTransform: 'uppercase',
374
- letterSpacing: '0.02em',
375
- },
376
- '.cm-cairn-media-needs-alt-glyph': { fontSize: '0.85em', lineHeight: '1' },
377
- // The figure/role pill: a small bordered pill carrying the placement role (or "figure" for
378
- // the measure default) when a media token sits inside a :::figure, in the directive accent
379
- // language. The accent ink and a color-mix accent border on the base-100 surface read as a
380
- // quiet tag beside the name. The ink is theme-defined, so it holds contrast in both themes
381
- // (Task 8's polish confirms it visually). A bare token renders no pill at all.
382
- '.cm-cairn-media-role': {
383
- fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
384
- fontSize: '0.625rem',
385
- fontWeight: '600',
386
- letterSpacing: '0.01em',
387
- color: 'var(--color-accent)',
388
- backgroundColor: 'var(--color-base-100)',
389
- border: '1px solid color-mix(in oklab, var(--color-accent) 35%, transparent)',
390
- borderRadius: '0.3rem',
391
- padding: '0.04rem 0.34rem',
392
- flex: '0 0 auto',
393
- },
394
- // The optimistic upload placeholder: an inline pill in the accent language, carrying a small
395
- // thumbnail of the image the author is placing and a determinate progress bar beneath it. It
396
- // stands in for the committed image text only while the upload runs; on resolve the seam
397
- // swaps it for the real reference, and on failure the seam removes it (the source untouched).
398
- // The accent tint matches the media chip so the two read as one visual family.
399
- '.cm-cairn-media-placeholder': {
400
- display: 'inline-flex',
401
- flexDirection: 'column',
402
- gap: '0.2em',
403
- verticalAlign: 'baseline',
404
- padding: '0.2em 0.35em',
405
- borderRadius: '0.375rem',
406
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
407
- border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
408
- },
409
- '.cm-cairn-media-placeholder-thumb': {
410
- width: '2.4em',
411
- height: '2.4em',
412
- objectFit: 'cover',
413
- borderRadius: '0.25rem',
414
- // A gentle pulse marks the placeholder as in-flight; reduced-motion drops it below.
415
- opacity: '0.85',
416
- },
417
- // The determinate bar: native <progress> restyled to the accent ink so the fill reads as the
418
- // upload's progress. Sized to the thumbnail width so the pill stays compact.
419
- '.cm-cairn-media-placeholder-bar': {
420
- width: '2.4em',
421
- height: '0.3em',
422
- appearance: 'none',
423
- border: '0',
424
- borderRadius: '0.15em',
425
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 18%, transparent)',
426
- overflow: 'hidden',
427
- },
428
- '.cm-cairn-media-placeholder-bar::-webkit-progress-bar': {
429
- backgroundColor: 'transparent',
430
- },
431
- '.cm-cairn-media-placeholder-bar::-webkit-progress-value': {
432
- backgroundColor: 'var(--color-accent)',
433
- borderRadius: '0.15em',
434
- transition: 'width 200ms ease',
435
- },
436
- '.cm-cairn-media-placeholder-bar::-moz-progress-bar': {
437
- backgroundColor: 'var(--color-accent)',
438
- borderRadius: '0.15em',
439
- },
440
- // Container folding lives in a real gutter column now, not an in-text band. The gutter is a
441
- // fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
442
- // the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
443
- // caret is inside the container. One rotating chevron in the directive ink; the rails carry
444
- // depth, so the ink does not restep. The lone gutter's wrapper loses its default background
445
- // and border so the column blends into the quiet surface.
446
- // Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
447
- // only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
448
- // or lint gutter would need its own chrome and a narrower selector here.
449
- '.cm-gutters': { backgroundColor: 'transparent', border: '0', color: 'inherit' },
450
- // 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
451
- '.cm-cairn-fold-gutter': { width: '24px' },
452
- '.cm-cairn-fold-gutter .cm-gutterElement': { display: 'flex', alignItems: 'stretch', padding: '0' },
453
- '.cm-cairn-fold-btn': {
454
- display: 'flex',
455
- alignItems: 'center',
456
- justifyContent: 'center',
457
- width: '100%',
458
- padding: '0',
459
- background: 'transparent',
460
- border: '0',
461
- cursor: 'pointer',
462
- color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
463
- },
464
- '.cm-cairn-fold-btn svg': {
465
- width: '11px',
466
- height: '11px',
467
- // Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
468
- // force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
469
- opacity: '0',
470
- transition: 'opacity 120ms ease, transform 120ms ease',
471
- },
472
- // Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
473
- // so a focused control shows its glyph, not just the ring.
474
- '.cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg':
475
- { opacity: '1' },
476
- // Folded rotates the single chevron to point right; caret-active takes the stronger ink.
477
- '.cm-cairn-fold-folded svg': { transform: 'rotate(-90deg)' },
478
- '.cm-cairn-fold-active': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
479
- // A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
480
- // the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
481
- '.cm-cairn-fold-btn:focus-visible': {
482
- outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
483
- outlineOffset: '-2px',
484
- borderRadius: '4px',
485
- },
486
- '.cm-cairn-fold-pill:focus-visible': {
487
- outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
488
- outlineOffset: '1px',
489
- },
490
- // No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
491
- // and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
492
- // states still read at full strength on touch rather than this rule clamping them to 0.65.
493
- '@media (hover: none)': {
494
- '.cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg': { opacity: '0.65' },
495
- },
496
- // Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
497
- '@media (prefers-reduced-motion: reduce)': {
498
- '.cm-cairn-fold-btn svg': { transition: 'none' },
499
- '.cm-cairn-fold-flash': { transition: 'none' },
500
- '.cm-cairn-media-placeholder-bar::-webkit-progress-value': { transition: 'none' },
501
- },
502
- // The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
503
- // so folded spots read in a scan. The rails are inset box-shadows on the same line element
504
- // and render above this background, so the rail column runs through the wash unbroken.
505
- '.cm-cairn-folded-row': {
506
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 7%, transparent)',
507
- },
508
- // The fold pill: the placeholder widget and the screen-reader story, a real focusable
509
- // button counting the hidden lines in accent ink. The 30% accent border lifts on hover.
510
- '.cm-cairn-fold-pill': {
511
- fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
512
- fontSize: '0.6875rem',
513
- color: 'var(--color-accent)',
514
- border: '1px solid color-mix(in oklab, var(--color-accent) 30%, transparent)',
515
- borderRadius: '0.375rem',
516
- padding: '1px 7px',
517
- marginLeft: '10px',
518
- verticalAlign: '1px',
519
- backgroundColor: 'var(--color-base-100)',
520
- cursor: 'pointer',
521
- },
522
- '.cm-cairn-fold-pill:hover': {
523
- borderColor: 'color-mix(in oklab, var(--color-accent) 60%, transparent)',
524
- },
525
- // The one-time unfold flash: a low-alpha accent background on the revealed lines, removed
526
- // after the animation. The transition runs as the field clears the class.
527
- '.cm-cairn-fold-flash': {
528
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 12%, transparent)',
529
- transition: 'background-color 400ms ease',
530
- },
531
- // Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
532
- // Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
533
- // the dim tone, and spec order breaks the specificity ties with the label rules above.
534
- // The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
535
- // along with the ink: the dim tone on the code chip or an 8% accent chip measures under
536
- // the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
537
- // span arm outranks the chip rules on specificity (the highlight style's generated
538
- // class, the inline-directive mark); the line arm covers the leaf chip, where spec
539
- // order breaks the tie.
540
- '.cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label': {
541
- color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
542
- backgroundColor: 'transparent',
543
- },
544
- // The fold pill dims with its folded opener row like any machinery line (the pill is a
545
- // widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
546
- // cannot reach by descendant selector, and it is already hidden at rest and forced visible
547
- // only when folded or caret-active, so a folded chevron stays findable without a dim rule.
548
- '.cm-cairn-focus-dim .cm-cairn-fold-pill': {
549
- color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
550
- },
551
- '.cm-cairn-focus-dim.cm-cairn-folded-row': { backgroundColor: 'transparent' },
552
- // The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
553
- // element, so overriding the percentages on dimmed lines re-resolves every bar in place.
554
- // Without this the directive block keeps full-strength bars and becomes the one
555
- // chromatic object in the dimmed field. The active step needs the override too: focus
556
- // mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
557
- // container, so a container holding a blank line has dimmed rows that still carry the
558
- // active rail.
559
- '.cm-cairn-focus-dim': {
560
- '--cairn-directive-rail-1': 'var(--cairn-focus-dim-rail-1, 24%)',
561
- '--cairn-directive-rail-2': 'var(--cairn-focus-dim-rail-2, 28%)',
562
- '--cairn-directive-rail-3': 'var(--cairn-focus-dim-rail-3, 32%)',
563
- '--cairn-directive-rail-active': 'var(--cairn-focus-dim-rail-active, 36%)',
564
- },
565
- // Tidy review decorations (spec 2.5). The author's original stays in the buffer; a deletion run
566
- // strikes through in --cairn-error-ink (reserved for tidy deletions) and the proposed insertion
567
- // shows as decoration content in --color-positive-ink (the locked addition token). The two are
568
- // a locked pair: deletion red and insertion green never speak the same color, so the author sees
569
- // exactly what tidy removes and what it adds. Both carry a non-color cue (the strike-through and
570
- // the leading marker) so the change reads without hue alone.
571
- '.cm-cairn-tidy-del': {
572
- color: 'var(--cairn-error-ink, oklch(50% 0.19 25))',
573
- textDecoration: 'line-through',
574
- textDecorationThickness: '1px',
575
- backgroundColor: 'color-mix(in oklab, var(--cairn-error-ink, oklch(50% 0.19 25)) 12%, transparent)',
576
- borderRadius: '2px',
577
- },
578
- '.cm-cairn-tidy-del-marker': {
579
- // A small leading wedge in the deletion ink, the non-color marker that pairs with the red.
580
- display: 'inline-block',
581
- width: '0',
582
- borderLeft: '2px solid var(--cairn-error-ink, oklch(50% 0.19 25))',
583
- height: '1em',
584
- verticalAlign: '-0.15em',
585
- marginRight: '1px',
586
- },
587
- '.cm-cairn-tidy-ins': {
588
- color: 'var(--color-positive-ink, oklch(48% 0.12 150))',
589
- backgroundColor: 'color-mix(in oklab, var(--color-positive-ink, oklch(48% 0.12 150)) 16%, transparent)',
590
- borderRadius: '2px',
591
- padding: '0 1px',
592
- marginLeft: '2px',
593
- // The non-color cue for an insertion: a leading caret glyph in the addition ink.
594
- '&::before': { content: '"+"', fontSize: '0.8em', opacity: '0.7', marginRight: '1px' },
595
- },
92
+ return layers.join(", ");
93
+ };
94
+ const directiveInk = {
95
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
96
+ color: "var(--color-accent)"
97
+ };
98
+ const railRules = {};
99
+ for (const depth of [1, 2, 3]) {
100
+ const row = (prefix) => `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
101
+ railRules[row("")] = { boxShadow: rails(depth) };
102
+ railRules[row(".cm-cairn-caret-block")] = { boxShadow: rails(depth, true) };
103
+ }
104
+ const theme = EditorView.theme(
105
+ {
106
+ "&": { backgroundColor: "var(--color-base-100)", color: "var(--color-base-content)", fontSize: "1rem" },
107
+ // The 60vh floor keeps the surface reading as the page's center stage even when the
108
+ // entry is short, and because the contenteditable content area carries the height, a
109
+ // click in the empty space below the text still lands in the editor and focuses it.
110
+ // No inner measure cap: the surface fills the card the way a code editor fills its
111
+ // pane, and the card's own width (the host caps it near 89ch of this face) is the one
112
+ // constraint. The surface carries tables, attributed directives, and long URLs, so the
113
+ // ceiling leans toward the code-editor end of the ergonomic band rather than the
114
+ // long-form ideal; paragraphs wrap comfortably below it.
115
+ ".cm-content": {
116
+ // The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
117
+ // fallback keeps the surface monospace outside an admin theme wrapper.
118
+ fontFamily: "var(--font-editor, ui-monospace, monospace)",
119
+ // Vertical padding holds at least one line-height of the body (1.8 x 1rem), with a
120
+ // touch more below than above (the optical center sits high); the sides then read as
121
+ // gutters rather than letterboxing.
122
+ padding: "2rem 1.25rem 2.5rem",
123
+ minHeight: "60vh"
596
124
  },
597
- { dark: isDark },
598
- );
599
-
600
- // The prose posture: the writing instrument. A 72ch measure centered in the card, one type
601
- // step up, looser leading. Markup posture (the base theme) keeps the dense fill for tables,
602
- // directives, and long URLs. Placed after the base theme in the extension list, so its keys
603
- // win the spec-order ties.
604
- proseTheme = EditorView.theme(
605
- {
606
- // Scoped to the content node (not the editor root) so the base theme's root font-size
607
- // never contests it, and so the 72ch measure resolves against the prose type step.
608
- '.cm-content': { fontSize: '1.0625rem', lineHeight: '1.9', maxWidth: '72ch', margin: '0 auto' },
125
+ ".cm-cursor": { borderLeftColor: "var(--color-primary)" },
126
+ // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
127
+ // focused text-entry surface as keyboard-modal, so a 2px ring would shout through every
128
+ // typing session. One subtle line keeps focus visible (WCAG 2.4.7) without competing
129
+ // with the manuscript. The 70% primary mix clears the 3:1 non-text contrast floor
130
+ // (WCAG 1.4.11) on both themes (3.23:1 light, 3.32:1 dark), where 45% measured near 2:1.
131
+ "&.cm-focused": {
132
+ outline: "1px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
133
+ outlineOffset: "-1px"
609
134
  },
610
- { dark: isDark },
611
- );
612
- markupTheme = EditorView.theme({ '.cm-content': { lineHeight: '1.8' } }, { dark: isDark });
613
-
614
- modes = modesMod;
615
- focusCompartment = new stateMod.Compartment();
616
- typewriterCompartment = new stateMod.Compartment();
617
- surfaceCompartment = new stateMod.Compartment();
618
- mediaCompartment = new stateMod.Compartment();
619
- spellcheckCompartment = new stateMod.Compartment();
620
- tidyCompartment = new stateMod.Compartment();
621
- tidyReadonlyCompartment = new stateMod.Compartment();
622
- // The read-only posture while a tidy review is open: EditorState.readOnly bars edits, and
623
- // editable: false drops the contenteditable so the surface is inert under the review (the same
624
- // posture Preview takes). The compartment starts empty and the tidyMode effect swaps this in.
625
- tidyReadonlyExt = [stateMod.EditorState.readOnly.of(true), viewMod.EditorView.editable.of(false)];
626
- // Build the spellcheck extension once: the lint source resolves the dictionary asset URL from the
627
- // dialect-resolved filename and posts it to the Worker's init. The compartment starts with the
628
- // extension only when spellcheck is on, so a site that opens with it off never spins up the Worker.
629
- spellcheckExt = await spellcheckMod.cairnSpellcheck({
630
- dictionaryFile: spellcheckDictionary,
631
- // Seed the Worker's personal layer from the committed site dictionary, and share the host's
632
- // pending-additions set so an add-to-dictionary choice records here for the host to commit.
633
- siteWords: siteDictionary,
634
- pendingAdditions,
635
- // Hand the lint source the editor's own CodeMirror module instances so its extension lands on the
636
- // same copies; a separate dynamic import can resolve to a different instance and break instanceof.
637
- modules: { lint: lintMod, language: languageMod, view: viewMod, state: stateMod },
638
- // The test seam: a deterministic fake Worker and the skip-ready flag, both straight through to the
639
- // lint source. Absent in production, where the real Worker and real asset resolution run.
640
- createWorker: spellcheckTest?.createWorker,
641
- assumeReady: spellcheckTest?.assumeReady,
642
- });
643
-
644
- view = new EditorView({
645
- parent: host,
646
- state: stateMod.EditorState.create({
647
- doc: value,
648
- extensions: [
649
- focusCompartment.of(focusMode ? modesMod.focusMode() : []),
650
- typewriterCompartment.of(typewriter ? modesMod.typewriterScroll() : []),
651
- commandsMod.history(),
652
- keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
653
- // The GFM base (strikethrough, tables, task lists, autolink) over the commonmark
654
- // default. markdown() also wires markdownKeymap (Enter continues a list, Backspace
655
- // removes an empty marker) at high precedence through its addKeymap default.
656
- markdownMod.markdown({ base: markdownMod.markdownLanguage }),
657
- ...(completionSources.length
658
- ? // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
659
- // accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
660
- [autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
661
- : []),
662
- EditorView.lineWrapping,
663
- languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
664
- highlightMod.cairnDirectivePlugin(),
665
- // Container folding: the fold system, the chevron and wash affordance, and the safety
666
- // invariant. Placed after the directive plugin so its chevron widget on an opener row
667
- // composes with the row's rail and gutter; its keymap is internal to the extension.
668
- foldingMod.cairnFolding(),
669
- // The optimistic image placeholder field: a widget-only decoration the insert popover
670
- // drives through the registerImagePlaceholders api. It never writes doc text, so a failed
671
- // upload leaves the source untouched (open risk 2). Placed after folding so a placeholder
672
- // landing inside a directive composes with the rails.
673
- placeholderMod.cairnImagePlaceholders(),
674
- // The media: source decoration, in its own compartment so a mediaLibrary prop change
675
- // reconfigures it without rebuilding the editor. The chip and the atomic ranges read the
676
- // library; an empty library decorates nothing.
677
- mediaCompartment.of(mediaMod.cairnMediaDecorations(mediaLibrary)),
678
- // The spellcheck and objective-error lint sources plus the locked amber underline theme, in
679
- // their own compartment so the footer toggle gates both surfaces at once. Empty when off.
680
- spellcheckCompartment.of(spellcheck ? spellcheckExt : []),
681
- // The tidy decoration field, in its own compartment so entering and leaving a review is a
682
- // reconfigure beside the media and fold decorations. The api the host drives is built below.
683
- tidyCompartment.of(tidyMod.cairnTidy()),
684
- // The read-only posture while a review is open, empty until the host sets tidyMode.
685
- tidyReadonlyCompartment.of(tidyMode ? tidyReadonlyExt : []),
686
- // Paste and drop ingest: an image carried by either gesture is preventDefault'd and handed
687
- // to onImageIngest (the host opens the capture card with the bytes); a gesture carrying no
688
- // image falls through to CodeMirror's default. 2b is single-file per gesture (open risk 3),
689
- // so only the first image routes.
690
- EditorView.domEventHandlers({
691
- dragover(event) {
692
- // Allow the drop only when the drag carries image files; otherwise let it pass so a
693
- // non-image drag (text, a link) keeps its native behavior.
694
- if (event.dataTransfer && firstImageFile(event.dataTransfer)) {
695
- guardDropTarget(event);
696
- return true;
697
- }
698
- return false;
699
- },
700
- drop(event) {
701
- const file = event.dataTransfer ? firstImageFile(event.dataTransfer) : null;
702
- if (!file) return false;
135
+ ".cm-line": { padding: "0" },
136
+ // A quote or list line hangs its wrapped continuation under the content: padding-left
137
+ // holds the marker width (the --cairn-hang the decoration sets) and the line's own
138
+ // negative text-indent (set inline) pulls the first line back, so the marker sits in the
139
+ // indent. This rule sits before the gutter rule so a container content line, which
140
+ // carries both classes, takes the gutter-plus-hang rule below.
141
+ ".cm-cairn-hang": { paddingLeft: "var(--cairn-hang, 0ch)" },
142
+ // The gutter: directive rows pad left so the text clears the deepest rail stack (the
143
+ // depth-3 bar ends at 18px; 1.75rem keeps 10px of air beyond it). Static structure
144
+ // (caret-independent), so caret movement shifts no layout. The --cairn-hang term composes
145
+ // a quote/list marker's hang on top of the gutter; it defaults to 0 on rows without one.
146
+ ".cm-cairn-directive-fence, .cm-cairn-directive-content": {
147
+ paddingLeft: "calc(1.75rem + var(--cairn-hang, 0ch))"
148
+ },
149
+ ...railRules,
150
+ ".cm-cairn-directive-mark": { color: "var(--color-muted)" },
151
+ ".cm-cairn-directive-label": { color: "var(--color-accent)" },
152
+ ".cm-cairn-directive-label.cm-cairn-depth-2": { color: "var(--cairn-directive-ink-2, oklch(50% 0.16 300))" },
153
+ ".cm-cairn-directive-label.cm-cairn-depth-3": { color: "var(--cairn-directive-ink-3, oklch(48% 0.16 300))" },
154
+ // Cursor-aware emphasis for the label ink: the caret's container takes the strongest
155
+ // ink, through the -active variable in cairn-admin.css. This selector TIES the depth
156
+ // rules above at two classes, so its place after them breaks the tie in its favor.
157
+ ".cm-cairn-caret-block .cm-cairn-directive-label": {
158
+ color: "var(--cairn-directive-ink-active, oklch(46% 0.16 300))"
159
+ },
160
+ ".cm-cairn-directive-leaf": directiveInk,
161
+ ".cm-cairn-directive-inline": directiveInk,
162
+ // The media: source chip: the inline widget that stands in for a media reference token, in the
163
+ // directive accent language (the 8% accent chip, the accent ink that holds AA on it). An
164
+ // inline-flex pill carrying a small thumbnail and the asset's display name, so a reference
165
+ // reads as the image it points at without leaving the source view.
166
+ ".cm-cairn-media-chip": {
167
+ display: "inline-flex",
168
+ alignItems: "center",
169
+ gap: "0.3em",
170
+ verticalAlign: "baseline",
171
+ padding: "0.05em 0.4em 0.05em 0.25em",
172
+ borderRadius: "0.375rem",
173
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
174
+ color: "var(--color-accent)",
175
+ fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
176
+ fontSize: "0.8125rem",
177
+ lineHeight: "1.4"
178
+ },
179
+ // The thumbnail: a small square crop, rounded to match the chip. object-fit keeps a
180
+ // non-square source from distorting. A faint border lifts a light image off the chip tint.
181
+ ".cm-cairn-media-thumb": {
182
+ width: "1.4em",
183
+ height: "1.4em",
184
+ objectFit: "cover",
185
+ borderRadius: "0.25rem",
186
+ border: "1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)",
187
+ flex: "0 0 auto"
188
+ },
189
+ ".cm-cairn-media-name": {
190
+ fontWeight: "500",
191
+ // Keep a long name from stretching the line; the title and the picker carry the full text.
192
+ maxWidth: "18ch",
193
+ overflow: "hidden",
194
+ textOverflow: "ellipsis",
195
+ whiteSpace: "nowrap"
196
+ },
197
+ // The needs-alt marker: a glyph plus a label, never hue alone (the spec accessibility rule).
198
+ // It rides the warning tone so it reads as a caution, with the label spelling out the state.
199
+ // The text uses --cairn-warning-ink, the on-surface warning text token, not --color-warning
200
+ // (a fill tone that fails small-text contrast on the light chip tint, WCAG 1.4.3). The ink
201
+ // holds AA on the chip's tint on both themes and stands apart from the accent name.
202
+ ".cm-cairn-media-needs-alt": {
203
+ display: "inline-flex",
204
+ alignItems: "center",
205
+ gap: "0.2em",
206
+ color: "var(--cairn-warning-ink, oklch(50% 0.13 70))",
207
+ fontSize: "0.6875rem",
208
+ fontWeight: "600",
209
+ textTransform: "uppercase",
210
+ letterSpacing: "0.02em"
211
+ },
212
+ ".cm-cairn-media-needs-alt-glyph": { fontSize: "0.85em", lineHeight: "1" },
213
+ // The figure/role pill: a small bordered pill carrying the placement role (or "figure" for
214
+ // the measure default) when a media token sits inside a :::figure, in the directive accent
215
+ // language. The accent ink and a color-mix accent border on the base-100 surface read as a
216
+ // quiet tag beside the name. The ink is theme-defined, so it holds contrast in both themes
217
+ // (Task 8's polish confirms it visually). A bare token renders no pill at all.
218
+ ".cm-cairn-media-role": {
219
+ fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
220
+ fontSize: "0.625rem",
221
+ fontWeight: "600",
222
+ letterSpacing: "0.01em",
223
+ color: "var(--color-accent)",
224
+ backgroundColor: "var(--color-base-100)",
225
+ border: "1px solid color-mix(in oklab, var(--color-accent) 35%, transparent)",
226
+ borderRadius: "0.3rem",
227
+ padding: "0.04rem 0.34rem",
228
+ flex: "0 0 auto"
229
+ },
230
+ // The optimistic upload placeholder: an inline pill in the accent language, carrying a small
231
+ // thumbnail of the image the author is placing and a determinate progress bar beneath it. It
232
+ // stands in for the committed image text only while the upload runs; on resolve the seam
233
+ // swaps it for the real reference, and on failure the seam removes it (the source untouched).
234
+ // The accent tint matches the media chip so the two read as one visual family.
235
+ ".cm-cairn-media-placeholder": {
236
+ display: "inline-flex",
237
+ flexDirection: "column",
238
+ gap: "0.2em",
239
+ verticalAlign: "baseline",
240
+ padding: "0.2em 0.35em",
241
+ borderRadius: "0.375rem",
242
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
243
+ border: "1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)"
244
+ },
245
+ ".cm-cairn-media-placeholder-thumb": {
246
+ width: "2.4em",
247
+ height: "2.4em",
248
+ objectFit: "cover",
249
+ borderRadius: "0.25rem",
250
+ // A gentle pulse marks the placeholder as in-flight; reduced-motion drops it below.
251
+ opacity: "0.85"
252
+ },
253
+ // The determinate bar: native <progress> restyled to the accent ink so the fill reads as the
254
+ // upload's progress. Sized to the thumbnail width so the pill stays compact.
255
+ ".cm-cairn-media-placeholder-bar": {
256
+ width: "2.4em",
257
+ height: "0.3em",
258
+ appearance: "none",
259
+ border: "0",
260
+ borderRadius: "0.15em",
261
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 18%, transparent)",
262
+ overflow: "hidden"
263
+ },
264
+ ".cm-cairn-media-placeholder-bar::-webkit-progress-bar": {
265
+ backgroundColor: "transparent"
266
+ },
267
+ ".cm-cairn-media-placeholder-bar::-webkit-progress-value": {
268
+ backgroundColor: "var(--color-accent)",
269
+ borderRadius: "0.15em",
270
+ transition: "width 200ms ease"
271
+ },
272
+ ".cm-cairn-media-placeholder-bar::-moz-progress-bar": {
273
+ backgroundColor: "var(--color-accent)",
274
+ borderRadius: "0.15em"
275
+ },
276
+ // Container folding lives in a real gutter column now, not an in-text band. The gutter is a
277
+ // fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
278
+ // the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
279
+ // caret is inside the container. One rotating chevron in the directive ink; the rails carry
280
+ // depth, so the ink does not restep. The lone gutter's wrapper loses its default background
281
+ // and border so the column blends into the quiet surface.
282
+ // Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
283
+ // only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
284
+ // or lint gutter would need its own chrome and a narrower selector here.
285
+ ".cm-gutters": { backgroundColor: "transparent", border: "0", color: "inherit" },
286
+ // 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
287
+ ".cm-cairn-fold-gutter": { width: "24px" },
288
+ ".cm-cairn-fold-gutter .cm-gutterElement": { display: "flex", alignItems: "stretch", padding: "0" },
289
+ ".cm-cairn-fold-btn": {
290
+ display: "flex",
291
+ alignItems: "center",
292
+ justifyContent: "center",
293
+ width: "100%",
294
+ padding: "0",
295
+ background: "transparent",
296
+ border: "0",
297
+ cursor: "pointer",
298
+ color: "var(--cairn-directive-ink-2, oklch(50% 0.16 300))"
299
+ },
300
+ ".cm-cairn-fold-btn svg": {
301
+ width: "11px",
302
+ height: "11px",
303
+ // Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
304
+ // force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
305
+ opacity: "0",
306
+ transition: "opacity 120ms ease, transform 120ms ease"
307
+ },
308
+ // Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
309
+ // so a focused control shows its glyph, not just the ring.
310
+ ".cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg": { opacity: "1" },
311
+ // Folded rotates the single chevron to point right; caret-active takes the stronger ink.
312
+ ".cm-cairn-fold-folded svg": { transform: "rotate(-90deg)" },
313
+ ".cm-cairn-fold-active": { color: "var(--cairn-directive-ink-active, oklch(46% 0.16 300))" },
314
+ // A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
315
+ // the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
316
+ ".cm-cairn-fold-btn:focus-visible": {
317
+ outline: "2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
318
+ outlineOffset: "-2px",
319
+ borderRadius: "4px"
320
+ },
321
+ ".cm-cairn-fold-pill:focus-visible": {
322
+ outline: "2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
323
+ outlineOffset: "1px"
324
+ },
325
+ // No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
326
+ // and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
327
+ // states still read at full strength on touch rather than this rule clamping them to 0.65.
328
+ "@media (hover: none)": {
329
+ ".cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg": { opacity: "0.65" }
330
+ },
331
+ // Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
332
+ "@media (prefers-reduced-motion: reduce)": {
333
+ ".cm-cairn-fold-btn svg": { transition: "none" },
334
+ ".cm-cairn-fold-flash": { transition: "none" },
335
+ ".cm-cairn-media-placeholder-bar::-webkit-progress-value": { transition: "none" }
336
+ },
337
+ // The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
338
+ // so folded spots read in a scan. The rails are inset box-shadows on the same line element
339
+ // and render above this background, so the rail column runs through the wash unbroken.
340
+ ".cm-cairn-folded-row": {
341
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 7%, transparent)"
342
+ },
343
+ // The fold pill: the placeholder widget and the screen-reader story, a real focusable
344
+ // button counting the hidden lines in accent ink. The 30% accent border lifts on hover.
345
+ ".cm-cairn-fold-pill": {
346
+ fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
347
+ fontSize: "0.6875rem",
348
+ color: "var(--color-accent)",
349
+ border: "1px solid color-mix(in oklab, var(--color-accent) 30%, transparent)",
350
+ borderRadius: "0.375rem",
351
+ padding: "1px 7px",
352
+ marginLeft: "10px",
353
+ verticalAlign: "1px",
354
+ backgroundColor: "var(--color-base-100)",
355
+ cursor: "pointer"
356
+ },
357
+ ".cm-cairn-fold-pill:hover": {
358
+ borderColor: "color-mix(in oklab, var(--color-accent) 60%, transparent)"
359
+ },
360
+ // The one-time unfold flash: a low-alpha accent background on the revealed lines, removed
361
+ // after the animation. The transition runs as the field clears the class.
362
+ ".cm-cairn-fold-flash": {
363
+ backgroundColor: "color-mix(in oklab, var(--color-accent) 12%, transparent)",
364
+ transition: "background-color 400ms ease"
365
+ },
366
+ // Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
367
+ // Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
368
+ // the dim tone, and spec order breaks the specificity ties with the label rules above.
369
+ // The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
370
+ // along with the ink: the dim tone on the code chip or an 8% accent chip measures under
371
+ // the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
372
+ // span arm outranks the chip rules on specificity (the highlight style's generated
373
+ // class, the inline-directive mark); the line arm covers the leaf chip, where spec
374
+ // order breaks the tie.
375
+ ".cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label": {
376
+ color: "var(--cairn-focus-dim-ink, oklch(66% 0.01 75))",
377
+ backgroundColor: "transparent"
378
+ },
379
+ // The fold pill dims with its folded opener row like any machinery line (the pill is a
380
+ // widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
381
+ // cannot reach by descendant selector, and it is already hidden at rest and forced visible
382
+ // only when folded or caret-active, so a folded chevron stays findable without a dim rule.
383
+ ".cm-cairn-focus-dim .cm-cairn-fold-pill": {
384
+ color: "var(--cairn-focus-dim-ink, oklch(66% 0.01 75))"
385
+ },
386
+ ".cm-cairn-focus-dim.cm-cairn-folded-row": { backgroundColor: "transparent" },
387
+ // The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
388
+ // element, so overriding the percentages on dimmed lines re-resolves every bar in place.
389
+ // Without this the directive block keeps full-strength bars and becomes the one
390
+ // chromatic object in the dimmed field. The active step needs the override too: focus
391
+ // mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
392
+ // container, so a container holding a blank line has dimmed rows that still carry the
393
+ // active rail.
394
+ ".cm-cairn-focus-dim": {
395
+ "--cairn-directive-rail-1": "var(--cairn-focus-dim-rail-1, 24%)",
396
+ "--cairn-directive-rail-2": "var(--cairn-focus-dim-rail-2, 28%)",
397
+ "--cairn-directive-rail-3": "var(--cairn-focus-dim-rail-3, 32%)",
398
+ "--cairn-directive-rail-active": "var(--cairn-focus-dim-rail-active, 36%)"
399
+ },
400
+ // Tidy review decorations (spec 2.5). The author's original stays in the buffer; a deletion run
401
+ // strikes through in --cairn-error-ink (reserved for tidy deletions) and the proposed insertion
402
+ // shows as decoration content in --color-positive-ink (the locked addition token). The two are
403
+ // a locked pair: deletion red and insertion green never speak the same color, so the author sees
404
+ // exactly what tidy removes and what it adds. Both carry a non-color cue (the strike-through and
405
+ // the leading marker) so the change reads without hue alone.
406
+ ".cm-cairn-tidy-del": {
407
+ color: "var(--cairn-error-ink, oklch(50% 0.19 25))",
408
+ textDecoration: "line-through",
409
+ textDecorationThickness: "1px",
410
+ backgroundColor: "color-mix(in oklab, var(--cairn-error-ink, oklch(50% 0.19 25)) 12%, transparent)",
411
+ borderRadius: "2px"
412
+ },
413
+ ".cm-cairn-tidy-del-marker": {
414
+ // A small leading wedge in the deletion ink, the non-color marker that pairs with the red.
415
+ display: "inline-block",
416
+ width: "0",
417
+ borderLeft: "2px solid var(--cairn-error-ink, oklch(50% 0.19 25))",
418
+ height: "1em",
419
+ verticalAlign: "-0.15em",
420
+ marginRight: "1px"
421
+ },
422
+ ".cm-cairn-tidy-ins": {
423
+ color: "var(--color-positive-ink, oklch(48% 0.12 150))",
424
+ backgroundColor: "color-mix(in oklab, var(--color-positive-ink, oklch(48% 0.12 150)) 16%, transparent)",
425
+ borderRadius: "2px",
426
+ padding: "0 1px",
427
+ marginLeft: "2px",
428
+ // The non-color cue for an insertion: a leading caret glyph in the addition ink.
429
+ "&::before": { content: '"+"', fontSize: "0.8em", opacity: "0.7", marginRight: "1px" }
430
+ }
431
+ },
432
+ { dark: isDark }
433
+ );
434
+ proseTheme = EditorView.theme(
435
+ {
436
+ // Scoped to the content node (not the editor root) so the base theme's root font-size
437
+ // never contests it, and so the 72ch measure resolves against the prose type step.
438
+ ".cm-content": { fontSize: "1.0625rem", lineHeight: "1.9", maxWidth: "72ch", margin: "0 auto" }
439
+ },
440
+ { dark: isDark }
441
+ );
442
+ markupTheme = EditorView.theme({ ".cm-content": { lineHeight: "1.8" } }, { dark: isDark });
443
+ modes = modesMod;
444
+ focusCompartment = new stateMod.Compartment();
445
+ typewriterCompartment = new stateMod.Compartment();
446
+ surfaceCompartment = new stateMod.Compartment();
447
+ mediaCompartment = new stateMod.Compartment();
448
+ spellcheckCompartment = new stateMod.Compartment();
449
+ tidyCompartment = new stateMod.Compartment();
450
+ tidyReadonlyCompartment = new stateMod.Compartment();
451
+ tidyReadonlyExt = [stateMod.EditorState.readOnly.of(true), viewMod.EditorView.editable.of(false)];
452
+ spellcheckExt = await spellcheckMod.cairnSpellcheck({
453
+ dictionaryFile: spellcheckDictionary,
454
+ // Seed the Worker's personal layer from the committed site dictionary, and share the host's
455
+ // pending-additions set so an add-to-dictionary choice records here for the host to commit.
456
+ siteWords: siteDictionary,
457
+ pendingAdditions,
458
+ // Hand the lint source the editor's own CodeMirror module instances so its extension lands on the
459
+ // same copies; a separate dynamic import can resolve to a different instance and break instanceof.
460
+ modules: { lint: lintMod, language: languageMod, view: viewMod, state: stateMod },
461
+ // The test seam: a deterministic fake Worker and the skip-ready flag, both straight through to the
462
+ // lint source. Absent in production, where the real Worker and real asset resolution run.
463
+ createWorker: spellcheckTest?.createWorker,
464
+ assumeReady: spellcheckTest?.assumeReady
465
+ });
466
+ view = new EditorView({
467
+ parent: host,
468
+ state: stateMod.EditorState.create({
469
+ doc: value,
470
+ extensions: [
471
+ focusCompartment.of(focusMode ? modesMod.focusMode() : []),
472
+ typewriterCompartment.of(typewriter ? modesMod.typewriterScroll() : []),
473
+ commandsMod.history(),
474
+ keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
475
+ // The GFM base (strikethrough, tables, task lists, autolink) over the commonmark
476
+ // default. markdown() also wires markdownKeymap (Enter continues a list, Backspace
477
+ // removes an empty marker) at high precedence through its addKeymap default.
478
+ markdownMod.markdown({ base: markdownMod.markdownLanguage }),
479
+ ...completionSources.length ? (
480
+ // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
481
+ // accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
482
+ [autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
483
+ ) : [],
484
+ EditorView.lineWrapping,
485
+ languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
486
+ highlightMod.cairnDirectivePlugin(),
487
+ // Container folding: the fold system, the chevron and wash affordance, and the safety
488
+ // invariant. Placed after the directive plugin so its chevron widget on an opener row
489
+ // composes with the row's rail and gutter; its keymap is internal to the extension.
490
+ foldingMod.cairnFolding(),
491
+ // The optimistic image placeholder field: a widget-only decoration the insert popover
492
+ // drives through the registerImagePlaceholders api. It never writes doc text, so a failed
493
+ // upload leaves the source untouched (open risk 2). Placed after folding so a placeholder
494
+ // landing inside a directive composes with the rails.
495
+ placeholderMod.cairnImagePlaceholders(),
496
+ // The media: source decoration, in its own compartment so a mediaLibrary prop change
497
+ // reconfigures it without rebuilding the editor. The chip and the atomic ranges read the
498
+ // library; an empty library decorates nothing.
499
+ mediaCompartment.of(mediaMod.cairnMediaDecorations(mediaLibrary)),
500
+ // The spellcheck and objective-error lint sources plus the locked amber underline theme, in
501
+ // their own compartment so the footer toggle gates both surfaces at once. Empty when off.
502
+ spellcheckCompartment.of(spellcheck ? spellcheckExt : []),
503
+ // The tidy decoration field, in its own compartment so entering and leaving a review is a
504
+ // reconfigure beside the media and fold decorations. The api the host drives is built below.
505
+ tidyCompartment.of(tidyMod.cairnTidy()),
506
+ // The read-only posture while a review is open, empty until the host sets tidyMode.
507
+ tidyReadonlyCompartment.of(tidyMode ? tidyReadonlyExt : []),
508
+ // Paste and drop ingest: an image carried by either gesture is preventDefault'd and handed
509
+ // to onImageIngest (the host opens the capture card with the bytes); a gesture carrying no
510
+ // image falls through to CodeMirror's default. 2b is single-file per gesture (open risk 3),
511
+ // so only the first image routes.
512
+ EditorView.domEventHandlers({
513
+ dragover(event) {
514
+ if (event.dataTransfer && firstImageFile(event.dataTransfer)) {
703
515
  guardDropTarget(event);
704
- onImageIngest?.(file);
705
516
  return true;
706
- },
707
- paste(event) {
708
- const file = event.clipboardData ? firstImageFile(event.clipboardData) : null;
709
- if (!file) return false; // a text or markdown paste falls through untouched
710
- event.preventDefault();
711
- onImageIngest?.(file);
712
- return true;
713
- },
714
- }),
715
- // No native text-correction override here (Task 7). The old `spellcheck: 'true'` is gone, so
716
- // the content node falls back to CodeMirror's own defaults: spellcheck "false", autocorrect
717
- // "off", autocapitalize "off". The cairn lint source replaces the browser's spellcheck
718
- // (running both would double-underline), and autocorrect/autocapitalize stay off so a browser
719
- // never silently rewrites a `media:` token, a directive name, or frontmatter.
720
- theme,
721
- surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
722
- EditorView.updateListener.of((update) => {
723
- if (update.docChanged) value = update.state.doc.toString();
724
- // A doc edit can change the block's span and a caret move can change which block the
725
- // caret sits in, so the reporter runs on either; the dedupe below absorbs the no-ops.
726
- if (onComponentAtCaret && (update.docChanged || update.selectionSet))
727
- reportComponentAtCaret(update.state);
728
- // The media-image reporter rides the same two triggers: a caret move lands on or off an
729
- // image, an edit shifts the figure's span. The dedupe absorbs the keystroke no-ops.
730
- if (onMediaImageAtCaret && (update.docChanged || update.selectionSet))
731
- reportMediaImageAtCaret(update.state);
732
- }),
733
- ],
734
- }),
735
- });
736
-
737
- registerInsert?.(insertAtCursor);
738
- registerInsertLink?.(insertLink);
739
- registerInsertImage?.(insertImage);
740
- registerCaretCoords?.(caretCoords);
741
- registerFocusEditor?.(focusEditor);
742
- registerImagePlaceholders?.(placeholderMod.imagePlaceholderApi(view));
743
- registerGetSelection?.(selectedText);
744
- registerGetSelectionRange?.(selectedRange);
745
- registerTidy?.(tidyMod.tidyApi(view));
746
- registerUndo?.(() => {
747
- if (view) commandsMod.undo(view);
748
- });
749
- registerFormat?.(applyFormat);
750
- registerReplaceRange?.(replaceRange);
751
- registerSelectRange?.(selectRange);
752
- // Report the caret's starting container once the editor exists, so a caret that mounts inside
753
- // a block is known without waiting for the first move.
754
- if (onComponentAtCaret) reportComponentAtCaret(view.state);
755
- if (onMediaImageAtCaret) reportMediaImageAtCaret(view.state);
756
- mounted = true;
757
- });
758
-
759
- onDestroy(() => view?.destroy());
760
-
761
- // Reconcile an externally reassigned `value` into the mounted editor. A no-op until `view` exists,
762
- // and the doc-equality guard ignores the updateListener's own writes so the two never feed back.
763
- $effect(() => {
764
- const incoming = value;
765
- if (!view) return;
766
- const current = view.state.doc.toString();
767
- if (incoming === current) return;
768
- view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
769
- });
770
-
771
- // Reconfigure the writing-mode compartments when their props change. Reading `mounted` re-runs
772
- // the effect once the editor exists, so a preference arriving between render and mount still
773
- // applies; the reconfigure is idempotent, so the extra pass after mount costs nothing.
774
- $effect(() => {
775
- const focus = focusMode;
776
- const typing = typewriter;
777
- const posture = surface;
778
- if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
779
- view.dispatch({
780
- effects: [
781
- focusCompartment.reconfigure(focus ? modes.focusMode() : []),
782
- typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
783
- surfaceCompartment.reconfigure((posture === 'prose' ? proseTheme : markupTheme) ?? []),
784
- ],
785
- });
786
- });
787
-
788
- // Reconfigure the media decoration when the mediaLibrary prop changes, so a just-uploaded image
789
- // (added to the library by the host) decorates without rebuilding the editor. Reading the prop
790
- // tracks it; the guard waits for the mounted editor and its media module.
791
- $effect(() => {
792
- const library = mediaLibrary;
793
- if (!mounted || !view || !mediaMod || !mediaCompartment) return;
794
- view.dispatch({ effects: mediaCompartment.reconfigure(mediaMod.cairnMediaDecorations(library)) });
517
+ }
518
+ return false;
519
+ },
520
+ drop(event) {
521
+ const file = event.dataTransfer ? firstImageFile(event.dataTransfer) : null;
522
+ if (!file) return false;
523
+ guardDropTarget(event);
524
+ onImageIngest?.(file);
525
+ return true;
526
+ },
527
+ paste(event) {
528
+ const file = event.clipboardData ? firstImageFile(event.clipboardData) : null;
529
+ if (!file) return false;
530
+ event.preventDefault();
531
+ onImageIngest?.(file);
532
+ return true;
533
+ }
534
+ }),
535
+ // No native text-correction override here (Task 7). The old `spellcheck: 'true'` is gone, so
536
+ // the content node falls back to CodeMirror's own defaults: spellcheck "false", autocorrect
537
+ // "off", autocapitalize "off". The cairn lint source replaces the browser's spellcheck
538
+ // (running both would double-underline), and autocorrect/autocapitalize stay off so a browser
539
+ // never silently rewrites a `media:` token, a directive name, or frontmatter.
540
+ theme,
541
+ surfaceCompartment.of(surface === "prose" ? proseTheme : markupTheme),
542
+ EditorView.updateListener.of((update) => {
543
+ if (update.docChanged) value = update.state.doc.toString();
544
+ if (onComponentAtCaret && (update.docChanged || update.selectionSet))
545
+ reportComponentAtCaret(update.state);
546
+ if (onMediaImageAtCaret && (update.docChanged || update.selectionSet))
547
+ reportMediaImageAtCaret(update.state);
548
+ })
549
+ ]
550
+ })
795
551
  });
796
-
797
- // Reconfigure the spellcheck compartment when the footer toggle flips. On restores the bundled
798
- // extension (both lint sources and the theme); off swaps in an empty extension, so the underlines
799
- // vanish and the Worker goes idle. Reading the prop tracks it; the guard waits for the mounted
800
- // editor and the resolved extension.
801
- $effect(() => {
802
- const on = spellcheck;
803
- if (!mounted || !view || !spellcheckCompartment || !spellcheckExt) return;
804
- view.dispatch({ effects: spellcheckCompartment.reconfigure(on ? spellcheckExt : []) });
552
+ registerInsert?.(insertAtCursor);
553
+ registerInsertLink?.(insertLink);
554
+ registerInsertImage?.(insertImage);
555
+ registerCaretCoords?.(caretCoords);
556
+ registerFocusEditor?.(focusEditor);
557
+ registerImagePlaceholders?.(placeholderMod.imagePlaceholderApi(view));
558
+ registerGetSelection?.(selectedText);
559
+ registerGetSelectionRange?.(selectedRange);
560
+ registerTidy?.(tidyMod.tidyApi(view));
561
+ registerUndo?.(() => {
562
+ if (view) commandsMod.undo(view);
805
563
  });
806
-
807
- // Reconfigure the read-only posture when tidyMode flips. On makes the surface inert under the open
808
- // review (no edits beneath a pending review); off restores editing on apply or cancel. Reading the
809
- // prop tracks it; the guard waits for the mounted editor and the resolved extension.
810
- $effect(() => {
811
- const on = tidyMode;
812
- if (!mounted || !view || !tidyReadonlyCompartment || !tidyReadonlyExt) return;
813
- view.dispatch({ effects: tidyReadonlyCompartment.reconfigure(on ? tidyReadonlyExt : []) });
564
+ registerFormat?.(applyFormat);
565
+ registerReplaceRange?.(replaceRange);
566
+ registerSelectRange?.(selectRange);
567
+ if (onComponentAtCaret) reportComponentAtCaret(view.state);
568
+ if (onMediaImageAtCaret) reportMediaImageAtCaret(view.state);
569
+ mounted = true;
570
+ });
571
+ onDestroy(() => view?.destroy());
572
+ $effect(() => {
573
+ const incoming = value;
574
+ if (!view) return;
575
+ const current = view.state.doc.toString();
576
+ if (incoming === current) return;
577
+ view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
578
+ });
579
+ $effect(() => {
580
+ const focus = focusMode;
581
+ const typing = typewriter;
582
+ const posture = surface;
583
+ if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
584
+ view.dispatch({
585
+ effects: [
586
+ focusCompartment.reconfigure(focus ? modes.focusMode() : []),
587
+ typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
588
+ surfaceCompartment.reconfigure((posture === "prose" ? proseTheme : markupTheme) ?? [])
589
+ ]
814
590
  });
815
-
816
- // The last value handed to onComponentAtCaret, so the reporter fires only on a change. The
817
- // identity compared is name + markdown + from + to. A pure caret move within one block leaves all
818
- // four unchanged, so it does not refire; an edit inside the block changes the markdown even when
819
- // it keeps the same length (an equal-length replacement leaves from and to unchanged), so the
820
- // markdown must be part of the equality or such an edit would keep a stale report.
821
- let lastCaretReport: ComponentAtCaret | null = null;
822
-
823
- // Compute the directive container at the caret from a CodeMirror state and report it through
824
- // onComponentAtCaret, deduped so a caret move within the same block does not refire. fenceScan
825
- // lines are 0-based; doc.line(n) is 1-based, so the line range maps with a +1 on each bound.
826
- function reportComponentAtCaret(state: import('@codemirror/state').EditorState) {
827
- const doc = state.doc;
828
- const lines: string[] = [];
829
- for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
830
- const caretLine = doc.lineAt(state.selection.main.head).number - 1;
831
- const range = caretContainerRange(fenceScan(lines), caretLine);
832
- let next: ComponentAtCaret | null = null;
833
- if (range) {
834
- const fromPos = doc.line(range.fromLine + 1).from;
835
- const toPos = doc.line(range.toLine + 1).to;
836
- next = {
837
- name: directiveOpenerName(lines[range.fromLine] ?? ''),
838
- markdown: doc.sliceString(fromPos, toPos),
839
- from: fromPos,
840
- to: toPos,
841
- };
842
- }
843
- const prev = lastCaretReport;
844
- const same =
845
- prev === next ||
846
- (prev !== null &&
847
- next !== null &&
848
- prev.name === next.name &&
849
- prev.markdown === next.markdown &&
850
- prev.from === next.from &&
851
- prev.to === next.to);
852
- if (same) return;
853
- lastCaretReport = next;
854
- onComponentAtCaret?.(next);
855
- }
856
-
857
- // The last media-image report, so the reporter fires only on a change. The compared identity is
858
- // the image span plus the figure's range/caption/role; a pure caret move within one image (or one
859
- // figure) leaves them unchanged, so it does not refire, while an edit that shifts the figure span
860
- // or rewrites the caption does. A null transitions to or from null on entering/leaving an image.
861
- let lastMediaReport: FigureAtImage | null = null;
862
-
863
- // Compute the media image at the caret from a CodeMirror state and report it through
864
- // onMediaImageAtCaret, deduped so a caret move that stays on the same image does not refire.
865
- function reportMediaImageAtCaret(state: import('@codemirror/state').EditorState) {
866
- const next = figureAtImage(state.doc.toString(), state.selection.main.head);
867
- const prev = lastMediaReport;
868
- const same =
869
- prev === next ||
870
- (prev !== null &&
871
- next !== null &&
872
- prev.imageFrom === next.imageFrom &&
873
- prev.imageTo === next.imageTo &&
874
- (prev.figure?.from ?? null) === (next.figure?.from ?? null) &&
875
- (prev.figure?.to ?? null) === (next.figure?.to ?? null) &&
876
- (prev.figure?.caption ?? null) === (next.figure?.caption ?? null) &&
877
- (prev.figure?.role ?? null) === (next.figure?.role ?? null));
878
- if (same) return;
879
- lastMediaReport = next;
880
- onMediaImageAtCaret?.(next);
881
- }
882
-
883
- // Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
884
- // dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
885
- function replaceRange(from: number, to: number, text: string) {
886
- if (!view) return;
887
- view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
888
- view.focus();
889
- }
890
-
891
- // Select a document span, focus the surface, and scroll the range into view. The needs-alt notice's
892
- // jump control calls it to land the author on an image that lacks alt text. A no-op before the
893
- // editor mounts, the same guard the other seams carry.
894
- function selectRange(from: number, to: number) {
895
- if (!view) return;
896
- view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
897
- view.focus();
898
- }
899
-
900
- function insertAtCursor(text: string) {
901
- if (!view) {
902
- value = value ? `${value}\n\n${text}` : text;
903
- return;
904
- }
905
- const pos = view.state.selection.main.head;
906
- const prefix = pos > 0 ? '\n\n' : '';
907
- const insert = `${prefix}${text}`;
908
- view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
909
- view.focus();
910
- }
911
-
912
- // Run a pure selection transform over the mounted editor: hand it the document and selection,
913
- // dispatch the document and selection it returns, and put focus back on the surface.
914
- function transformSelection(transform: (doc: string, from: number, to: number) => FormatResult) {
915
- if (!view) return;
916
- const { from, to } = view.state.selection.main;
917
- const doc = view.state.doc.toString();
918
- const next = transform(doc, from, to);
919
- view.dispatch({
920
- changes: { from: 0, to: doc.length, insert: next.doc },
921
- selection: { anchor: next.from, head: next.to },
922
- });
923
- view.focus();
924
- }
925
-
926
- function insertLink(href: string, title: string) {
927
- if (!view) {
928
- // The editor has not mounted yet; append the link to the raw value so a pick is never lost,
929
- // mirroring insertAtCursor's pre-mount fallback.
930
- const link = insertInlineLink('', 0, 0, href, title).doc;
931
- value = value ? `${value} ${link}` : link;
932
- return;
933
- }
934
- transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
935
- }
936
-
937
- function insertImage(alt: string, ref: string) {
938
- if (!view) {
939
- // The editor has not mounted yet; append the image to the raw value so a pick is never lost,
940
- // mirroring insertLink's pre-mount fallback.
941
- const image = insertImageFormat('', 0, 0, alt, ref).doc;
942
- value = value ? `${value} ${image}` : image;
943
- return;
944
- }
945
- transformSelection((doc, from, to) => insertImageFormat(doc, from, to, alt, ref));
946
- }
947
-
948
- function selectedText(): string {
949
- if (!view) return '';
950
- const { from, to } = view.state.selection.main;
951
- return view.state.sliceDoc(from, to);
952
- }
953
-
954
- // The selection's document offsets, for the tidy host to scope a selection tidy to the exact span.
955
- // Null when the selection is empty (a bare caret), which the host reads as document scope.
956
- function selectedRange(): { from: number; to: number } | null {
957
- if (!view) return null;
958
- const { from, to } = view.state.selection.main;
959
- return from === to ? null : { from, to };
591
+ });
592
+ $effect(() => {
593
+ const library = mediaLibrary;
594
+ if (!mounted || !view || !mediaMod || !mediaCompartment) return;
595
+ view.dispatch({ effects: mediaCompartment.reconfigure(mediaMod.cairnMediaDecorations(library)) });
596
+ });
597
+ $effect(() => {
598
+ const on = spellcheck;
599
+ if (!mounted || !view || !spellcheckCompartment || !spellcheckExt) return;
600
+ view.dispatch({ effects: spellcheckCompartment.reconfigure(on ? spellcheckExt : []) });
601
+ });
602
+ $effect(() => {
603
+ const on = tidyMode;
604
+ if (!mounted || !view || !tidyReadonlyCompartment || !tidyReadonlyExt) return;
605
+ view.dispatch({ effects: tidyReadonlyCompartment.reconfigure(on ? tidyReadonlyExt : []) });
606
+ });
607
+ let lastCaretReport = null;
608
+ function reportComponentAtCaret(state) {
609
+ const doc = state.doc;
610
+ const lines = [];
611
+ for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
612
+ const caretLine = doc.lineAt(state.selection.main.head).number - 1;
613
+ const range = caretContainerRange(fenceScan(lines), caretLine);
614
+ let next = null;
615
+ if (range) {
616
+ const fromPos = doc.line(range.fromLine + 1).from;
617
+ const toPos = doc.line(range.toLine + 1).to;
618
+ next = {
619
+ name: directiveOpenerName(lines[range.fromLine] ?? ""),
620
+ markdown: doc.sliceString(fromPos, toPos),
621
+ from: fromPos,
622
+ to: toPos
623
+ };
960
624
  }
961
-
962
- // The caret's viewport coordinates, for the insert popover to anchor itself to the cursor. Null
963
- // before the editor mounts or when the caret has no measurable position (an unrendered line).
964
- function caretCoords(): { left: number; right: number; top: number; bottom: number } | null {
965
- if (!view) return null;
966
- const rect = view.coordsAtPos(view.state.selection.main.head);
967
- return rect ? { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } : null;
625
+ const prev = lastCaretReport;
626
+ const same = prev === next || prev !== null && next !== null && prev.name === next.name && prev.markdown === next.markdown && prev.from === next.from && prev.to === next.to;
627
+ if (same) return;
628
+ lastCaretReport = next;
629
+ onComponentAtCaret?.(next);
630
+ }
631
+ let lastMediaReport = null;
632
+ function reportMediaImageAtCaret(state) {
633
+ const next = figureAtImage(state.doc.toString(), state.selection.main.head);
634
+ const prev = lastMediaReport;
635
+ const same = prev === next || prev !== null && next !== null && prev.imageFrom === next.imageFrom && prev.imageTo === next.imageTo && (prev.figure?.from ?? null) === (next.figure?.from ?? null) && (prev.figure?.to ?? null) === (next.figure?.to ?? null) && (prev.figure?.caption ?? null) === (next.figure?.caption ?? null) && (prev.figure?.role ?? null) === (next.figure?.role ?? null);
636
+ if (same) return;
637
+ lastMediaReport = next;
638
+ onMediaImageAtCaret?.(next);
639
+ }
640
+ function replaceRange(from, to, text) {
641
+ if (!view) return;
642
+ view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
643
+ view.focus();
644
+ }
645
+ function selectRange(from, to) {
646
+ if (!view) return;
647
+ view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
648
+ view.focus();
649
+ }
650
+ function insertAtCursor(text) {
651
+ if (!view) {
652
+ value = value ? `${value}
653
+
654
+ ${text}` : text;
655
+ return;
968
656
  }
969
-
970
- // Return focus to the editor surface; the popover calls it on close or Escape. The selection is
971
- // intact because opening the popover only blurred the editor, it never edited the doc.
972
- function focusEditor() {
973
- view?.focus();
657
+ const pos = view.state.selection.main.head;
658
+ const prefix = pos > 0 ? "\n\n" : "";
659
+ const insert = `${prefix}${text}`;
660
+ view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
661
+ view.focus();
662
+ }
663
+ function transformSelection(transform) {
664
+ if (!view) return;
665
+ const { from, to } = view.state.selection.main;
666
+ const doc = view.state.doc.toString();
667
+ const next = transform(doc, from, to);
668
+ view.dispatch({
669
+ changes: { from: 0, to: doc.length, insert: next.doc },
670
+ selection: { anchor: next.from, head: next.to }
671
+ });
672
+ view.focus();
673
+ }
674
+ function insertLink(href, title) {
675
+ if (!view) {
676
+ const link = insertInlineLink("", 0, 0, href, title).doc;
677
+ value = value ? `${value} ${link}` : link;
678
+ return;
974
679
  }
975
-
976
- function applyFormat(kind: FormatKind) {
977
- transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
680
+ transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
681
+ }
682
+ function insertImage(alt, ref) {
683
+ if (!view) {
684
+ const image = insertImageFormat("", 0, 0, alt, ref).doc;
685
+ value = value ? `${value} ${image}` : image;
686
+ return;
978
687
  }
688
+ transformSelection((doc, from, to) => insertImageFormat(doc, from, to, alt, ref));
689
+ }
690
+ function selectedText() {
691
+ if (!view) return "";
692
+ const { from, to } = view.state.selection.main;
693
+ return view.state.sliceDoc(from, to);
694
+ }
695
+ function selectedRange() {
696
+ if (!view) return null;
697
+ const { from, to } = view.state.selection.main;
698
+ return from === to ? null : { from, to };
699
+ }
700
+ function caretCoords() {
701
+ if (!view) return null;
702
+ const rect = view.coordsAtPos(view.state.selection.main.head);
703
+ return rect ? { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } : null;
704
+ }
705
+ function focusEditor() {
706
+ view?.focus();
707
+ }
708
+ function applyFormat(kind) {
709
+ transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
710
+ }
979
711
  </script>
980
712
 
981
713
  <input type="hidden" {name} {value} />