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