@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
@@ -17,8 +17,9 @@ transient flashes, and the editor card's footer is the writing-environment strip
17
17
  count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
18
18
  -->
19
19
  <script lang="ts">
20
- import { flushSync, untrack } from 'svelte';
20
+ import { flushSync, untrack, getContext } from 'svelte';
21
21
  import { beforeNavigate } from '$app/navigation';
22
+ import { deserialize } from '$app/forms';
22
23
  import { page } from '$app/state';
23
24
  import BlocksIcon from '@lucide/svelte/icons/blocks';
24
25
  import SquarePenIcon from '@lucide/svelte/icons/square-pen';
@@ -40,6 +41,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
40
41
  import RenameDialog from './RenameDialog.svelte';
41
42
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
42
43
  import ShortcutsDialog from './ShortcutsDialog.svelte';
44
+ import TidyReview from './TidyReview.svelte';
45
+ import SparklesIcon from '@lucide/svelte/icons/sparkles';
46
+ import { validateTidy, TIDY_REJECTION_MESSAGE } from './tidy-validate.js';
47
+ import type { Change } from './tidy-diff.js';
43
48
  import { cairnLinkCompletionSource } from './link-completion.js';
44
49
  import {
45
50
  findMediaImagesNeedingAlt,
@@ -64,6 +69,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
64
69
  import { manifestMediaResolver } from '../render/resolve-media.js';
65
70
  import type { MediaEntry } from '../media/manifest.js';
66
71
  import { mediaLibraryEntry } from '../media/library-entry.js';
72
+ import { CSRF_CONTEXT_KEY } from './csrf-context.js';
67
73
 
68
74
  interface Props {
69
75
  /** The edit load's data, plus the site name for the heading. */
@@ -84,6 +90,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
84
90
 
85
91
  let { data, registry, render, icons, form }: Props = $props();
86
92
 
93
+ // The client-side tidy deadline (spec 2.1, Task 14): a slow call becomes a cancel/retry rather than a
94
+ // hung review. Set above the action's own 30s Worker deadline so the server's retryable fail lands
95
+ // first when the model is merely slow; this catches a stalled connection past that.
96
+ const TIDY_CLIENT_TIMEOUT_MS = 45_000;
97
+
87
98
  // The topbar context portal (AdminLayout owns the holder). The desk snippet below carries the
88
99
  // document's status and action clusters; this effect registers it into the band on mount and
89
100
  // nulls it on teardown, so CairnAdmin's view switch (which unmounts EditPage) clears the band.
@@ -120,6 +131,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
120
131
  const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
121
132
  if (formaction === '?/publish') publishing = true;
122
133
  else saving = true;
134
+ // Commit any pending personal-dictionary additions alongside the save. Fire-and-forget: the words
135
+ // are already live in the Worker, so the in-flight commit never blocks the save navigation; an add
136
+ // that does not land stays pending for the next save (declared before the navigation reads it).
137
+ void commitPendingDictionary();
123
138
  }
124
139
  // Either in-flight submit disables both buttons, so a second click cannot fire a second POST
125
140
  // while the first navigation is still pending.
@@ -294,7 +309,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
294
309
  // Preview is read-only, so the insert controls the page renders into the toolbar disable with the
295
310
  // strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
296
311
  // is in scope before its first use.
297
- const insertDisabled = $derived(mode === 'preview');
312
+ // Tidy mode disables the toolbar and makes the surface read-only while a review is open. The host
313
+ // sets it when the review opens and clears it on apply or cancel. Declared here so the insert-disable
314
+ // derivation below can read it.
315
+ let tidyMode = $state(false);
316
+ // The tidy request in-flight flag, so the Tidy control reads busy while a call runs.
317
+ let tidyBusy = $state(false);
318
+ // The insert controls disable in Preview (read-only) and while a tidy review is open (the author
319
+ // cannot edit underneath a pending review, the same posture Preview takes).
320
+ const insertDisabled = $derived(mode === 'preview' || tidyMode);
298
321
  let previewHtml = $state('');
299
322
  // True after a render call threw, so the preview pane can say so instead of going blank.
300
323
  let previewFailed = $state(false);
@@ -319,8 +342,14 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
319
342
  const typewriterStorageKey = 'cairn-editor-typewriter';
320
343
  const surfaceStorageKey = 'cairn-editor-surface';
321
344
  const zenStorageKey = 'cairn-editor-zen';
345
+ // Spellcheck (the markdown-aware lint underlines) defaults ON, so a fresh editor checks spelling
346
+ // without a choice. The toggle joins the editor-preference family on the same pattern: a localStorage
347
+ // key read once in the effect below, written by the footer setter. Stored as 'false' only when the
348
+ // author turns it off; any other value (including unset) reads as on.
349
+ const spellcheckStorageKey = 'cairn-editor-spellcheck';
322
350
  let focusMode = $state(false);
323
351
  let typewriter = $state(false);
352
+ let spellcheck = $state(true);
324
353
  // Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
325
354
  // strip, and the footer go; the editing surface stays. It joins the editor-preference family on
326
355
  // the same pattern (a localStorage key, read once below, written by the setter), and composes
@@ -334,6 +363,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
334
363
  typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
335
364
  zen = localStorage.getItem(zenStorageKey) === 'true';
336
365
  if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
366
+ // Spellcheck is on unless the author explicitly stored it off.
367
+ spellcheck = localStorage.getItem(spellcheckStorageKey) !== 'false';
337
368
  });
338
369
  function setFocusMode(on: boolean) {
339
370
  focusMode = on;
@@ -343,6 +374,57 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
343
374
  typewriter = on;
344
375
  localStorage.setItem(typewriterStorageKey, String(on));
345
376
  }
377
+ function setSpellcheck(on: boolean) {
378
+ spellcheck = on;
379
+ localStorage.setItem(spellcheckStorageKey, String(on));
380
+ }
381
+
382
+ // The personal-dictionary pending additions (spec 1.6), owned here and shared with MarkdownEditor's
383
+ // lint source: an add-to-dictionary choice records the lowercased word here (and clears the underline
384
+ // at once), and this host commits the set through the addDictionaryWord action at save time. An add
385
+ // that fails to commit stays here for the session and re-attempts on the next save, so the word is
386
+ // never silently dropped. A plain Set, not $state: the lint source mutates it, and nothing renders
387
+ // from it, so reactivity is unneeded.
388
+ const pendingAdditions = new Set<string>();
389
+ // The CSRF token getter from the admin layout context, for the raw-body dictionary commit.
390
+ const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
391
+
392
+ /** Commit the pending personal-dictionary additions through the addDictionaryWord action, then drop
393
+ * the words the server confirms from the pending set. Fire-and-forget at save time: the words are
394
+ * already live in the Worker's in-memory set, so a slow or failed commit never blocks the save. A
395
+ * failure leaves the words pending for the next save (never dropped). The transport mirrors the media
396
+ * raw-body actions: a text/plain POST, the CSRF token in X-Cairn-CSRF, a JSON `{ words }` body. */
397
+ async function commitPendingDictionary(): Promise<void> {
398
+ if (pendingAdditions.size === 0) return;
399
+ const words = [...pendingAdditions];
400
+ try {
401
+ const res = await fetch(`/admin/${data.conceptId}/${data.id}?/addDictionaryWord`, {
402
+ method: 'POST',
403
+ redirect: 'manual',
404
+ headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
405
+ body: JSON.stringify({ words }),
406
+ });
407
+ // The guard's expired-session 303 under redirect: 'manual' surfaces as an opaque, status-0
408
+ // response with no body: leave the words pending and bail.
409
+ if (res.type === 'opaqueredirect' || res.status === 0) return;
410
+ // deserialize turns the devalue-encoded form-action result back into an ActionResult; a 500 HTML
411
+ // error page is not devalue-encoded, so it throws into the catch below. Only a success carries
412
+ // the merged word list; a fail (csrf, 400, 409) leaves the words pending for the next save.
413
+ const result = deserialize(await res.text()) as
414
+ | { type: 'success'; data?: { words?: unknown } }
415
+ | { type: 'failure' | 'error' | 'redirect' };
416
+ if (result.type !== 'success') return;
417
+ const merged = result.data?.words;
418
+ if (!Array.isArray(merged)) return;
419
+ // Reconcile: drop every now-committed word (matched lowercased, the form the action stored) from
420
+ // the pending set so it is not re-sent. A word the server did not confirm stays pending.
421
+ const committed = new Set(merged.filter((w): w is string => typeof w === 'string').map((w) => w.toLowerCase()));
422
+ for (const w of words) if (committed.has(w.toLowerCase())) pendingAdditions.delete(w);
423
+ } catch {
424
+ // A network failure or an unparseable server response leaves the pending set intact for the next
425
+ // save; the words stay live in the Worker for the session, so the author sees no regression.
426
+ }
427
+ }
346
428
  function setSurface(posture: 'prose' | 'markup') {
347
429
  surface = posture;
348
430
  localStorage.setItem(surfaceStorageKey, posture);
@@ -395,9 +477,180 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
395
477
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
396
478
  // reads it for the Text field's default.
397
479
  let getSelection = $state.raw<() => string>(() => '');
480
+ // The editor's selection range, registered by MarkdownEditor on mount; tidy reads it for the exact
481
+ // selected span's offset so a selection tidy never maps onto an identical passage earlier in the
482
+ // document. Returns null when the selection is empty (a bare caret), which reads as document scope.
483
+ let getSelectionRange = $state.raw<() => { from: number; to: number } | null>(() => null);
398
484
  // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
399
485
  let format = $state.raw<(kind: FormatKind) => void>(() => {});
400
486
 
487
+ // The tidy apply seam, registered by MarkdownEditor on mount; the review surface drives the in-buffer
488
+ // decorations and the batched apply through it. Null until the editor mounts.
489
+ let tidyApi = $state.raw<import('./editor-tidy.js').TidyApi | null>(null);
490
+ // The editor's undo, registered on mount; the Undo-tidy chip calls it. A no-op until then.
491
+ let undoEditor = $state.raw<() => void>(() => {});
492
+ // The open review's data: the validated change set, the captured original it was diffed against, the
493
+ // scope, and the model. Null when no review is open. The diff positions index `tidyOriginal`, which
494
+ // for a selection tidy is the FULL document (the changes are offset back before they reach here).
495
+ let tidyReview = $state.raw<{ changes: Change[]; original: string; model: string } | null>(null);
496
+ // The error message a refused or failed tidy surfaces. The working state is cancelable through the
497
+ // AbortController; a validation rejection or an action failure lands here.
498
+ let tidyMessage = $state<string | null>(null);
499
+ // The no-op confirmation: a clean result (tidy found nothing to fix) shows "Nothing to fix" and never
500
+ // opens an empty review. Cleared on the next tidy run.
501
+ let tidyNoop = $state(false);
502
+ // The session-level "Undo tidy" affordance: surfaced right after Apply, dismissed on the next edit.
503
+ let tidyApplied = $state(false);
504
+ // The in-flight controller, for Cancel and the bounded client timeout.
505
+ let tidyController: AbortController | null = null;
506
+
507
+ // The three tidy status dialogs (working, no-op, message). Each is promoted to the top layer with
508
+ // showModal() the way TidyReview does, so the focus trap, Escape, and inert background come from the
509
+ // platform. The $effect below opens each when its flag flips and closes it when the flag clears; the
510
+ // {#if} mounts the element, so the ref is set before the effect reads it.
511
+ let tidyWorkingDialog = $state<HTMLDialogElement | null>(null);
512
+ let tidyNoopDialog = $state<HTMLDialogElement | null>(null);
513
+ let tidyMessageDialog = $state<HTMLDialogElement | null>(null);
514
+ $effect(() => {
515
+ if (tidyBusy) tidyWorkingDialog?.showModal();
516
+ });
517
+ $effect(() => {
518
+ if (tidyNoop) tidyNoopDialog?.showModal();
519
+ });
520
+ $effect(() => {
521
+ if (tidyMessage) tidyMessageDialog?.showModal();
522
+ });
523
+
524
+ // True when tidy is enabled for the site (the developer-tier master switch). Gates the Tidy control.
525
+ // The optional chain mirrors the component's tolerance of a partial data load: a degraded load that
526
+ // omits the tidy block simply reads disabled rather than throwing.
527
+ const tidyEnabled = $derived(data.tidy?.enabled ?? false);
528
+
529
+ /** Run tidy (spec 2.1, Task 11) over the whole document or the current selection. The action receives
530
+ * only the selected text plus a scope flag; the diff is computed against that text and the changes'
531
+ * ranges are offset back into the full document before they reach the apply seam. On success the
532
+ * result is validated as a proofread (Task 13); a rejection shows the honest message and writes
533
+ * nothing; a clean result shows "Nothing to fix"; otherwise the review opens. */
534
+ async function runTidy() {
535
+ if (!tidyEnabled || tidyBusy || tidyMode) return;
536
+ tidyMessage = null;
537
+ tidyNoop = false;
538
+ tidyApplied = false;
539
+ // Scope: a non-empty selection tidies that range; otherwise the whole body. The offset is where the
540
+ // selected text begins in the full document, so the diff positions map back. The range seam carries
541
+ // the exact selection offsets, so a passage that repeats earlier in the body still maps the
542
+ // corrections onto the actually-selected occurrence. Fall back to the first textual match only when
543
+ // no range is available (offset 0 keeps document-scope tidy unchanged).
544
+ const selected = getSelection();
545
+ const range = getSelectionRange();
546
+ const useSelection = selected.length > 0;
547
+ let offset = 0;
548
+ if (range) {
549
+ offset = range.from;
550
+ } else if (useSelection) {
551
+ offset = Math.max(body.indexOf(selected), 0);
552
+ }
553
+ const text = useSelection ? selected : body;
554
+
555
+ tidyBusy = true;
556
+ tidyController = new AbortController();
557
+ // The bounded client timeout: a slow call becomes a cancel/retry rather than hanging the review.
558
+ const timer = setTimeout(() => tidyController?.abort(), TIDY_CLIENT_TIMEOUT_MS);
559
+ try {
560
+ const res = await fetch(`/admin/${data.conceptId}/${data.id}?/tidy`, {
561
+ method: 'POST',
562
+ redirect: 'manual',
563
+ headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
564
+ body: JSON.stringify({ text, scope: useSelection ? 'selection' : 'document' }),
565
+ signal: tidyController.signal,
566
+ });
567
+ if (res.type === 'opaqueredirect' || res.status === 0) {
568
+ tidyMessage = 'Your session expired. Sign in again to tidy.';
569
+ return;
570
+ }
571
+ const result = deserialize(await res.text()) as
572
+ | { type: 'success'; data?: { corrected?: unknown; model?: unknown } }
573
+ | { type: 'failure'; data?: { error?: unknown } }
574
+ | { type: 'error' | 'redirect' };
575
+ if (result.type !== 'success') {
576
+ tidyMessage =
577
+ result.type === 'failure' && typeof result.data?.error === 'string' && result.data.error !== 'csrf'
578
+ ? result.data.error
579
+ : 'Tidy could not finish. Try again.';
580
+ return;
581
+ }
582
+ const corrected = typeof result.data?.corrected === 'string' ? result.data.corrected : '';
583
+ const model = typeof result.data?.model === 'string' ? result.data.model : data.tidy.model;
584
+ if (corrected.length === 0 || corrected === text) {
585
+ // A clean result: tidy found nothing to fix. Never open an empty review.
586
+ tidyNoop = true;
587
+ return;
588
+ }
589
+ // Validate the result as a proofread (Task 13). A rejection writes nothing and shows the message.
590
+ const validation = validateTidy(text, corrected);
591
+ if (!validation.ok) {
592
+ tidyMessage = TIDY_REJECTION_MESSAGE;
593
+ return;
594
+ }
595
+ if (validation.changes.length === 0) {
596
+ tidyNoop = true;
597
+ return;
598
+ }
599
+ // Offset the changes back into the full document (a selection tidy diffs the selected text). The
600
+ // captured original handed to the review is the full body, so every line label and context row is
601
+ // computed against the real document.
602
+ const changes: Change[] = validation.changes.map((c) => ({
603
+ ...c,
604
+ from: c.from + offset,
605
+ to: c.to + offset,
606
+ }));
607
+ tidyReview = { changes, original: body, model };
608
+ tidyMode = true;
609
+ tidyApi?.enter(changes);
610
+ } catch {
611
+ // An abort (Cancel or the client timeout) or a network/parse failure both map to the same
612
+ // retryable message; the buffer is untouched.
613
+ tidyMessage = tidyController?.signal.aborted ? null : 'Tidy could not finish. Try again.';
614
+ } finally {
615
+ clearTimeout(timer);
616
+ tidyController = null;
617
+ tidyBusy = false;
618
+ }
619
+ }
620
+
621
+ /** Cancel an in-flight tidy: abort the request and clear the working state. The buffer is untouched. */
622
+ function cancelTidy() {
623
+ tidyController?.abort();
624
+ tidyBusy = false;
625
+ tidyMessage = null;
626
+ }
627
+
628
+ /** Close the review: clear tidy mode and the review data. On apply the "Undo tidy" affordance shows
629
+ * until the next edit; on cancel nothing changed. */
630
+ function closeTidyReview(applied: boolean) {
631
+ tidyMode = false;
632
+ tidyReview = null;
633
+ tidyApplied = applied;
634
+ // Record the body the apply produced, so the next edit (a different body) dismisses the Undo chip.
635
+ tidyAppliedBody = applied ? body : null;
636
+ }
637
+ // The body snapshot right after Apply; the Undo-tidy chip dismisses once the body diverges from it.
638
+ let tidyAppliedBody = $state<string | null>(null);
639
+ $effect(() => {
640
+ const current = body;
641
+ if (tidyApplied && tidyAppliedBody !== null && current !== tidyAppliedBody) {
642
+ tidyApplied = false;
643
+ tidyAppliedBody = null;
644
+ }
645
+ });
646
+ // Undo the whole applied tidy in one move (ordinary editor Undo of the one batched transaction). The
647
+ // chip names it so the author knows the whole tidy is one move back.
648
+ function undoTidy() {
649
+ undoEditor();
650
+ tidyApplied = false;
651
+ tidyAppliedBody = null;
652
+ }
653
+
401
654
  // The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
402
655
  // above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
403
656
  // restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
@@ -988,6 +1241,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
988
1241
  {#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
989
1242
  {saveState}
990
1243
  </span>
1244
+ {#if tidyApplied}
1245
+ <!-- The session-level Undo tidy (graft 6): surfaced right after Apply, dismissed on the next
1246
+ edit. Ordinary editor Undo covers it mechanically (the apply is one history entry); this
1247
+ chip names it so the author knows the whole tidy is one move back. -->
1248
+ <span class="flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3 text-xs text-[var(--color-muted)]" data-testid="tidy-undo-chip">
1249
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--color-positive-ink)]">Tidy applied</span>
1250
+ <button type="button" class="underline decoration-[color-mix(in_oklab,currentColor_40%,transparent)] underline-offset-2 hover:text-primary" onclick={undoTidy}>Undo tidy</button>
1251
+ </span>
1252
+ {/if}
991
1253
  </div>
992
1254
 
993
1255
  <div class="ml-auto flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3">
@@ -1295,6 +1557,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1295
1557
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
1296
1558
  </svg>
1297
1559
  </button>
1560
+ {#if tidyEnabled}
1561
+ <!-- Tidy (spec 2.1): the single desk entry point for the light copy-edit. A labelled
1562
+ accent-quiet action (something you invoke, not a format you toggle). Disabled in
1563
+ Preview, while a review is open, and while a request is in flight. -->
1564
+ <button
1565
+ type="button"
1566
+ class="btn btn-sm btn-ghost gap-1.5"
1567
+ aria-label="Tidy"
1568
+ title="Tidy: a light copy-edit you review before it lands"
1569
+ disabled={insertDisabled || tidyBusy}
1570
+ onclick={runTidy}
1571
+ >
1572
+ <SparklesIcon class="h-4 w-4" aria-hidden="true" />Tidy
1573
+ </button>
1574
+ {/if}
1298
1575
  <!-- The Figure control: always rendered, enabled only when the caret sits on a media image
1299
1576
  (and the Write surface is up). It never mounts or unmounts on caret movement; only its
1300
1577
  enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
@@ -1332,7 +1609,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1332
1609
  registerSelectRange={(fn) => (selectRange = fn)}
1333
1610
  registerInsertLink={(fn) => (insertLink = fn)}
1334
1611
  registerGetSelection={(fn) => (getSelection = fn)}
1612
+ registerGetSelectionRange={(fn) => (getSelectionRange = fn)}
1335
1613
  registerFormat={(fn) => (format = fn)}
1614
+ registerTidy={(api) => (tidyApi = api)}
1615
+ registerUndo={(fn) => (undoEditor = fn)}
1616
+ {tidyMode}
1336
1617
  registerCaretCoords={(fn) => (caretCoords = fn)}
1337
1618
  registerFocusEditor={(fn) => (focusEditor = fn)}
1338
1619
  registerImagePlaceholders={(api) => (placeholders = api)}
@@ -1342,6 +1623,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1342
1623
  {mediaLibrary}
1343
1624
  {focusMode}
1344
1625
  {typewriter}
1626
+ {spellcheck}
1627
+ spellcheckDictionary={data.spellcheckDictionary}
1628
+ siteDictionary={data.siteDictionary}
1629
+ {pendingAdditions}
1345
1630
  />
1346
1631
  <!-- The accumulated uploaded records ride the save form alongside the body. The save action
1347
1632
  reads `media` and merges these records into media.json (publish submits the same form). -->
@@ -1454,6 +1739,17 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1454
1739
  {#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
1455
1740
  Typewriter
1456
1741
  </button>
1742
+ <!-- Spellcheck: the markdown-aware lint underlines. Off reconfigures the lint compartment
1743
+ to empty and idles the Worker. Same check-and-tint grammar as the modes beside it. -->
1744
+ <button
1745
+ type="button"
1746
+ class={ftrToggleClass(spellcheck)}
1747
+ aria-pressed={spellcheck}
1748
+ onclick={() => setSpellcheck(!spellcheck)}
1749
+ >
1750
+ {#if spellcheck}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
1751
+ Spellcheck
1752
+ </button>
1457
1753
  <!-- Zen enters from the footer (and Ctrl+Shift+.); it reads as a peer writing-mode
1458
1754
  toggle here, but once on it hides the whole footer, so the chip carries the way out. -->
1459
1755
  <button
@@ -1719,6 +2015,79 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1719
2015
  />
1720
2016
  <MarkdownHelpDialog bind:this={helpDialog} />
1721
2017
  <ShortcutsDialog bind:this={shortcutsDialog} />
2018
+
2019
+ <!-- The tidy review surface (spec 2.5). Mounted only while a review is open, keyed by the validated
2020
+ change set so a fresh review remounts. It drives the in-buffer decorations and the batched apply
2021
+ through tidyApi; the host clears tidy mode on close. -->
2022
+ {#if tidyReview && tidyApi}
2023
+ <TidyReview
2024
+ changes={tidyReview.changes}
2025
+ original={tidyReview.original}
2026
+ conventions={data.tidy.conventions}
2027
+ model={tidyReview.model}
2028
+ title={data.title}
2029
+ api={tidyApi}
2030
+ onclose={closeTidyReview}
2031
+ onshow={(from, to) => selectRange(from, to)}
2032
+ />
2033
+ {/if}
2034
+
2035
+ <!-- The tidy working state: a cancelable dialog wired to the real abort (Task 11's AbortController
2036
+ plus the bounded client timeout). Shown while the model call is in flight. -->
2037
+ {#if tidyBusy}
2038
+ <dialog
2039
+ class="modal"
2040
+ aria-labelledby="cairn-tidy-working-title"
2041
+ data-testid="tidy-working"
2042
+ bind:this={tidyWorkingDialog}
2043
+ onclose={cancelTidy}
2044
+ >
2045
+ <div class="modal-box flex flex-col items-center gap-3 text-center">
2046
+ <span class="loading loading-spinner loading-lg text-primary" aria-hidden="true"></span>
2047
+ <h2 id="cairn-tidy-working-title" class="text-base font-semibold">Tidying your text</h2>
2048
+ <p class="max-w-prose text-sm text-[var(--color-muted)]">
2049
+ Claude is reading your draft for a light copy-edit. You will review every change before it lands.
2050
+ </p>
2051
+ <button type="button" class="btn btn-sm" onclick={() => tidyWorkingDialog?.close()}>Cancel</button>
2052
+ </div>
2053
+ </dialog>
2054
+ {/if}
2055
+
2056
+ <!-- The no-op confirmation: tidy found nothing to fix. Quiet, never an empty review. -->
2057
+ {#if tidyNoop}
2058
+ <dialog
2059
+ class="modal"
2060
+ aria-labelledby="cairn-tidy-noop-title"
2061
+ data-testid="tidy-noop"
2062
+ bind:this={tidyNoopDialog}
2063
+ onclose={() => (tidyNoop = false)}
2064
+ >
2065
+ <div class="modal-box flex flex-col items-center gap-3 text-center">
2066
+ <h2 id="cairn-tidy-noop-title" class="text-base font-semibold">Nothing to fix</h2>
2067
+ <p class="max-w-prose text-sm text-[var(--color-muted)]">Tidy read your text and found nothing to change.</p>
2068
+ <button type="button" class="btn btn-sm btn-primary" onclick={() => tidyNoopDialog?.close()}>Close</button>
2069
+ </div>
2070
+ </dialog>
2071
+ {/if}
2072
+
2073
+ <!-- A refused, failed, or rejected tidy: the honest message; the document is unchanged. -->
2074
+ {#if tidyMessage}
2075
+ <dialog
2076
+ class="modal"
2077
+ aria-labelledby="cairn-tidy-message-title"
2078
+ data-testid="tidy-message"
2079
+ bind:this={tidyMessageDialog}
2080
+ onclose={() => (tidyMessage = null)}
2081
+ >
2082
+ <div class="modal-box flex flex-col gap-3">
2083
+ <h2 id="cairn-tidy-message-title" class="text-base font-semibold">Tidy could not run</h2>
2084
+ <p class="text-sm text-[var(--color-muted)]">{tidyMessage}</p>
2085
+ <div class="flex justify-end">
2086
+ <button type="button" class="btn btn-sm btn-primary" onclick={() => tidyMessageDialog?.close()}>Close</button>
2087
+ </div>
2088
+ </div>
2089
+ </dialog>
2090
+ {/if}
1722
2091
  <DeleteDialog
1723
2092
  bind:this={deleteDialog}
1724
2093
  trigger={false}