@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
@@ -0,0 +1,355 @@
1
+ <!--
2
+ @component
3
+ The tidy review surface (spec 2.5, the approved rev.2 mockup). A native `<dialog>` opened with
4
+ `showModal()`, so the focus trap, Escape, and inert background come from the platform. It shows the
5
+ proposed copy-edit as a git-style diff, one hunk per change, ranked by safety: objective hunks
6
+ (spelling, doubled word, whitespace, punctuation) read quiet and come pre-kept; judgment hunks (a
7
+ declared normalization, or a grammar reword) carry the review-this treatment, default to undecided,
8
+ and are NEVER swept by Accept fixes until the author confirms each. That safety property is the spine.
9
+
10
+ The author's original stays in the editor buffer the whole time; the apply seam (registerTidy) shows
11
+ the proposed edits as decorations and writes nothing until Apply. Apply lands the kept hunks in ONE
12
+ batched transaction (one undoable step), so the whole tidy is one move back. Cancel and Reject all
13
+ leave the document byte-identical.
14
+
15
+ The category of each hunk is inferred LOCALLY from the diff shape and the enabled config, never a claim
16
+ the model made and never a count of the author's own usage. A normalization names ONLY the config
17
+ setting that authorized it; counting the author's own habit is the harmonize-to-author judgment cairn
18
+ must never make, so no such count exists.
19
+ -->
20
+ <script lang="ts">import SparklesIcon from "@lucide/svelte/icons/sparkles";
21
+ import CheckIcon from "@lucide/svelte/icons/check";
22
+ import XIcon from "@lucide/svelte/icons/x";
23
+ import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert";
24
+ import LightbulbIcon from "@lucide/svelte/icons/lightbulb";
25
+ import EyeIcon from "@lucide/svelte/icons/eye";
26
+ import { lineLabel } from "./tidy-diff.js";
27
+ import {
28
+ categorize,
29
+ isObjective,
30
+ buildBecause,
31
+ categoryLabel
32
+ } from "./tidy-categorize.js";
33
+ let { changes, original, conventions, model, title, api, onclose, onshow } = $props();
34
+ const hunks = $derived(changes.map((c) => {
35
+ const category = categorize(c, original, conventions);
36
+ const objective = isObjective(category);
37
+ const removed = original.slice(c.from, c.to);
38
+ const added = c.replacement;
39
+ const line = lineLabel(original, c.from);
40
+ const lines = original.split("\n");
41
+ const contextBefore = line >= 2 ? lines[line - 2] ?? "" : "";
42
+ const contextAfter = line < lines.length ? lines[line] ?? "" : "";
43
+ const lineStart = original.lastIndexOf("\n", c.from - 1) + 1;
44
+ const nextNewline = original.indexOf("\n", c.from);
45
+ const lineEnd = nextNewline === -1 ? original.length : nextNewline;
46
+ const fullLine = original.slice(lineStart, lineEnd);
47
+ const pre = original.slice(lineStart, c.from);
48
+ const post = original.slice(c.to, lineEnd);
49
+ const because = category.kind === "normalization" ? buildBecause(category.convention, conventions) : null;
50
+ return {
51
+ index: c.index,
52
+ category,
53
+ objective,
54
+ line,
55
+ contextBefore,
56
+ contextAfter,
57
+ delText: fullLine,
58
+ addText: pre + added + post,
59
+ delRun: { pre, mid: removed, post },
60
+ addRun: { pre, mid: added, post },
61
+ because,
62
+ label: categoryLabel(category)
63
+ };
64
+ }));
65
+ let overrides = $state({});
66
+ function effectiveDisposition(h, map) {
67
+ return map[h.index] ?? (h.objective ? "kept" : "undecided");
68
+ }
69
+ const dispositions = $derived(
70
+ Object.fromEntries(hunks.map((h) => [h.index, effectiveDisposition(h, overrides)]))
71
+ );
72
+ let focusedPos = $state(0);
73
+ let tallyMessage = $state("");
74
+ let actionMessage = $state("");
75
+ let announceNonce = 0;
76
+ function nonce() {
77
+ return announceNonce++ % 2 === 0 ? "" : "​";
78
+ }
79
+ const keptCount = $derived(hunks.filter((h) => dispositions[h.index] === "kept").length);
80
+ const reviewCount = $derived(hunks.filter((h) => dispositions[h.index] === "undecided").length);
81
+ const skipCount = $derived(hunks.filter((h) => dispositions[h.index] === "rejected").length);
82
+ let dialog = $state(null);
83
+ $effect(() => {
84
+ dialog?.showModal();
85
+ });
86
+ function setDisposition(index, next) {
87
+ overrides = { ...overrides, [index]: next };
88
+ }
89
+ function narrate(h, verb) {
90
+ const where = `Hunk ${hunks.indexOf(h) + 1} of ${hunks.length}`;
91
+ const what = h.delRun.mid && h.addRun.mid ? `${h.delRun.mid.trim()} becomes ${h.addRun.mid.trim()}` : h.label;
92
+ const why = h.because ? `, your ${h.because.label} setting is ${h.because.variant}` : "";
93
+ actionMessage = `${where}. ${h.label}. ${what}${why}. ${verb}.${nonce()}`;
94
+ }
95
+ function acceptHunk(h) {
96
+ setDisposition(h.index, "kept");
97
+ narrate(h, "Kept");
98
+ }
99
+ function rejectHunk(h) {
100
+ setDisposition(h.index, "rejected");
101
+ narrate(h, "Skipped");
102
+ }
103
+ function acceptFixes() {
104
+ const next = { ...overrides };
105
+ for (const h of hunks) if (h.objective) next[h.index] = "kept";
106
+ overrides = next;
107
+ const n = hunks.filter((h) => h.objective).length;
108
+ const stillReview = hunks.filter((h) => effectiveDisposition(h, next) === "undecided").length;
109
+ tallyMessage = `${n} fixes kept. ${stillReview} still to review.${nonce()}`;
110
+ }
111
+ function rejectAll() {
112
+ overrides = Object.fromEntries(hunks.map((h) => [h.index, "rejected"]));
113
+ tallyMessage = `All ${hunks.length} changes skipping.${nonce()}`;
114
+ }
115
+ function apply() {
116
+ const keptIndexes = hunks.filter((h) => dispositions[h.index] === "kept").map((h) => h.index);
117
+ api.acceptMany(keptIndexes);
118
+ api.exit();
119
+ dialog?.close();
120
+ onclose(true);
121
+ }
122
+ function cancel() {
123
+ api.exit();
124
+ dialog?.close();
125
+ onclose(false);
126
+ }
127
+ function showInText(h) {
128
+ const c = changes.find((ch) => ch.index === h.index);
129
+ if (c) onshow(c.from, c.to);
130
+ }
131
+ function moveFocus(next) {
132
+ focusedPos = next;
133
+ const h = hunks[focusedPos];
134
+ if (h) narrate(h, "Focused");
135
+ }
136
+ function onListKeydown(e) {
137
+ const h = hunks[focusedPos];
138
+ if (e.key === "j" || e.key === "n") {
139
+ moveFocus(Math.min(focusedPos + 1, hunks.length - 1));
140
+ e.preventDefault();
141
+ } else if (e.key === "k" || e.key === "p") {
142
+ moveFocus(Math.max(focusedPos - 1, 0));
143
+ e.preventDefault();
144
+ } else if (e.key === "a" && !e.shiftKey) {
145
+ if (h) acceptHunk(h);
146
+ e.preventDefault();
147
+ } else if (e.key === "r" && !e.shiftKey) {
148
+ if (h) rejectHunk(h);
149
+ e.preventDefault();
150
+ } else if (e.key === "A" || e.key === "a" && e.shiftKey) {
151
+ acceptFixes();
152
+ e.preventDefault();
153
+ }
154
+ }
155
+ function onDialogCancel(e) {
156
+ e.preventDefault();
157
+ cancel();
158
+ }
159
+ function actsLabel(h) {
160
+ return h.objective ? "Accept or reject this fix" : "Accept or reject this change";
161
+ }
162
+ </script>
163
+
164
+ <dialog
165
+ bind:this={dialog}
166
+ class="modal"
167
+ aria-labelledby="cairn-tidy-title"
168
+ oncancel={onDialogCancel}
169
+ onkeydown={onListKeydown}
170
+ data-testid="tidy-review"
171
+ >
172
+ <div class="modal-box flex max-h-[85vh] w-[54rem] max-w-full flex-col overflow-hidden p-0">
173
+ <!-- the review head -->
174
+ <div class="flex items-center gap-3 border-b border-[var(--cairn-card-border)] px-4 py-3">
175
+ <span class="flex size-9 flex-none items-center justify-center rounded-lg bg-primary/10 text-primary">
176
+ <SparklesIcon class="size-5" aria-hidden="true" />
177
+ </span>
178
+ <div class="min-w-0 flex-1">
179
+ <div id="cairn-tidy-title" class="text-lg font-bold leading-tight">Review tidy</div>
180
+ <div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
181
+ <span><b class="text-base-content">{hunks.length} {hunks.length === 1 ? 'change' : 'changes'}</b> to <b class="text-base-content">{title}</b></span>
182
+ <span class="rounded-full border border-[var(--cairn-card-border)] px-2 py-0.5 text-[0.6875rem] font-semibold">{model}</span>
183
+ </div>
184
+ </div>
185
+ <span class="hidden flex-none items-center gap-1.5 text-[0.6875rem] text-[var(--color-muted)] sm:inline-flex" aria-hidden="true">
186
+ <kbd class="kbd kbd-xs">j</kbd><kbd class="kbd kbd-xs">k</kbd> move
187
+ <kbd class="kbd kbd-xs">a</kbd><kbd class="kbd kbd-xs">r</kbd> accept / reject
188
+ </span>
189
+ <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Cancel review" onclick={cancel}>
190
+ <XIcon class="size-4" aria-hidden="true" />
191
+ </button>
192
+ </div>
193
+
194
+ <!-- the bulk bar: the live tally (role=status, bulk-only) + Accept fixes / Reject all -->
195
+ <div class="flex items-center gap-3 border-b border-[var(--cairn-card-border)] bg-base-200 px-4 py-2.5">
196
+ <span class="inline-flex flex-wrap items-center gap-2 text-sm text-[var(--color-muted)]" data-testid="tidy-tally">
197
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--color-positive-ink)]">
198
+ <CheckIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{keptCount}</span> kept
199
+ </span>
200
+ <span class="opacity-40" aria-hidden="true">&middot;</span>
201
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--cairn-warning-ink)]">
202
+ <TriangleAlertIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{reviewCount}</span> to review
203
+ </span>
204
+ <span class="opacity-40" aria-hidden="true">&middot;</span>
205
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--cairn-error-ink)]">
206
+ <XIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{skipCount}</span> skipping
207
+ </span>
208
+ </span>
209
+ <span class="flex-1"></span>
210
+ <button type="button" class="btn btn-sm btn-outline" onclick={acceptFixes}>
211
+ <CheckIcon class="size-3 text-[var(--color-positive-ink)]" aria-hidden="true" />Accept fixes
212
+ </button>
213
+ <button type="button" class="btn btn-sm btn-outline" onclick={rejectAll}>
214
+ <XIcon class="size-3 text-[var(--cairn-error-ink)]" aria-hidden="true" />Reject all
215
+ </button>
216
+ </div>
217
+
218
+ <!-- the hunk list: the scroll container -->
219
+ <div class="flex flex-col gap-3 overflow-y-auto px-4 py-3.5">
220
+ {#each hunks as h, i (h.index)}
221
+ {@const decided = dispositions[h.index]}
222
+ {@const isJudgment = !h.objective}
223
+ {@const undecided = decided === 'undecided'}
224
+ <div
225
+ class="relative overflow-hidden rounded-xl border bg-base-100 {isJudgment && undecided
226
+ ? 'border-[color-mix(in_oklab,var(--cairn-warning-ink)_30%,var(--cairn-card-border))] shadow-[inset_3px_0_0_0_color-mix(in_oklab,var(--cairn-warning-ink)_55%,transparent)]'
227
+ : 'border-[var(--cairn-card-border)]'} {decided === 'rejected' ? 'opacity-70' : ''} {i ===
228
+ focusedPos
229
+ ? 'outline outline-2 outline-offset-1 outline-[var(--color-primary)]'
230
+ : ''}"
231
+ data-testid="tidy-hunk"
232
+ data-objective={h.objective}
233
+ data-disposition={decided}
234
+ >
235
+ <!-- the hunk head -->
236
+ <div
237
+ class="flex items-center gap-2 border-b border-[var(--cairn-card-border)] px-3 py-2 {isJudgment
238
+ ? 'bg-[color-mix(in_oklab,var(--cairn-warning-ink)_7%,transparent)]'
239
+ : 'bg-[color-mix(in_oklab,var(--color-base-content)_1.5%,transparent)]'}"
240
+ >
241
+ <span
242
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold {isJudgment
243
+ ? 'bg-[color-mix(in_oklab,var(--cairn-warning-ink)_11%,transparent)] text-[var(--cairn-warning-ink)]'
244
+ : 'bg-[color-mix(in_oklab,var(--color-base-content)_6%,transparent)] text-[var(--color-muted)]'}"
245
+ >
246
+ {h.label}
247
+ </span>
248
+ {#if isJudgment}
249
+ <span class="inline-flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--cairn-warning-ink)]">
250
+ <EyeIcon class="size-3" aria-hidden="true" />Review this
251
+ </span>
252
+ {/if}
253
+ <button
254
+ type="button"
255
+ class="inline-flex min-h-6 items-center gap-1 rounded px-1.5 py-1.5 font-mono text-[0.6875rem] text-[var(--color-muted)] underline decoration-[color-mix(in_oklab,currentColor_35%,transparent)] underline-offset-2 hover:bg-primary/[0.08] hover:text-primary"
256
+ title="Show this line in the editor"
257
+ onclick={() => showInText(h)}
258
+ >
259
+ <EyeIcon class="size-3" aria-hidden="true" />line {h.line}
260
+ </button>
261
+ <span class="flex-1"></span>
262
+ <span class="inline-flex flex-none items-center overflow-hidden rounded-md border border-[var(--cairn-card-border)]" role="group" aria-label={actsLabel(h)}>
263
+ <button
264
+ type="button"
265
+ class="inline-flex min-h-6 items-center gap-1 px-2.5 py-1.5 text-[0.6875rem] font-medium {decided ===
266
+ 'kept'
267
+ ? 'bg-[color-mix(in_oklab,var(--color-positive-ink)_13%,transparent)] text-[var(--color-positive-ink)]'
268
+ : 'text-[var(--color-muted)]'}"
269
+ aria-pressed={decided === 'kept'}
270
+ onclick={() => acceptHunk(h)}
271
+ >
272
+ <CheckIcon class="size-3" aria-hidden="true" />Accept
273
+ </button>
274
+ <button
275
+ type="button"
276
+ class="inline-flex min-h-6 items-center gap-1 border-l border-[var(--cairn-card-border)] px-2.5 py-1.5 text-[0.6875rem] font-medium {decided ===
277
+ 'rejected'
278
+ ? 'bg-[color-mix(in_oklab,var(--cairn-error-ink)_12%,transparent)] text-[var(--cairn-error-ink)]'
279
+ : 'text-[var(--color-muted)]'}"
280
+ aria-pressed={decided === 'rejected'}
281
+ onclick={() => rejectHunk(h)}
282
+ >
283
+ <XIcon class="size-3" aria-hidden="true" />Reject
284
+ </button>
285
+ </span>
286
+ </div>
287
+
288
+ <!-- the unified diff body: context, deletion, insertion, optional because-line -->
289
+ <div class="font-mono text-[0.8125rem] leading-relaxed">
290
+ {#if h.contextBefore}
291
+ <div class="flex items-baseline">
292
+ <span class="w-6 flex-none select-none text-center text-[var(--color-muted)] opacity-60" aria-hidden="true">&nbsp;</span>
293
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5 text-[var(--color-muted)]">{h.contextBefore}</span>
294
+ </div>
295
+ {/if}
296
+ <div class="flex items-baseline bg-[var(--cairn-tidy-del-row)]">
297
+ <span class="w-6 flex-none select-none text-center font-semibold text-[var(--cairn-error-ink)]" aria-hidden="true">&minus;</span>
298
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5">{h.delRun.pre}<span
299
+ class="rounded-sm bg-[var(--cairn-tidy-del-run)] px-px text-[var(--cairn-error-ink)] line-through decoration-1"
300
+ data-testid="tidy-del"
301
+ >{h.delRun.mid}</span>{h.delRun.post}</span>
302
+ </div>
303
+ <div class="flex items-baseline bg-[var(--cairn-tidy-add-row)] {decided === 'rejected' ? 'opacity-70' : ''}">
304
+ <span class="w-6 flex-none select-none text-center font-semibold text-[var(--color-positive-ink)]" aria-hidden="true">+</span>
305
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5">{h.addRun.pre}<span
306
+ class="rounded-sm bg-[var(--cairn-tidy-add-run)] px-px text-[var(--color-positive-ink)] {decided ===
307
+ 'rejected'
308
+ ? 'line-through opacity-70'
309
+ : ''}"
310
+ data-testid="tidy-add"
311
+ >{h.addRun.mid}</span>{h.addRun.post}</span>
312
+ </div>
313
+ {#if h.contextAfter}
314
+ <div class="flex items-baseline">
315
+ <span class="w-6 flex-none select-none text-center text-[var(--color-muted)] opacity-60" aria-hidden="true">&nbsp;</span>
316
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5 text-[var(--color-muted)]">{h.contextAfter}</span>
317
+ </div>
318
+ {/if}
319
+ </div>
320
+
321
+ {#if h.because}
322
+ <!-- the mandatory because-line: names ONLY the config setting that authorized this hunk -->
323
+ <div
324
+ class="flex items-start gap-2 border-t border-dashed border-[var(--cairn-card-border)] bg-[color-mix(in_oklab,var(--cairn-warning-ink)_5%,transparent)] px-3 py-2 text-xs leading-snug text-[var(--color-subtle)]"
325
+ data-testid="tidy-because"
326
+ >
327
+ <LightbulbIcon class="mt-px size-3 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
328
+ <span>Your <b class="text-base-content">{h.because.label} setting</b> is <b class="text-base-content">{h.because.variant}</b>, so {h.because.effect}.</span>
329
+ </div>
330
+ {/if}
331
+ </div>
332
+ {/each}
333
+ </div>
334
+
335
+ <!-- the review footer: the commit note + Cancel + the one-transaction Apply -->
336
+ <div class="flex items-center gap-2.5 border-t border-[var(--cairn-card-border)] px-4 py-3.5">
337
+ <span class="flex flex-1 items-center gap-1.5 text-[0.6875rem] leading-snug text-[var(--color-muted)]">
338
+ <CheckIcon class="size-3 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
339
+ Applies to the editor only. Your next Save commits it like any edit, and Undo takes the whole tidy back.
340
+ </span>
341
+ <button type="button" class="btn btn-sm" onclick={cancel}>Cancel</button>
342
+ <button type="button" class="btn btn-sm btn-primary" onclick={apply} disabled={keptCount === 0}>
343
+ <CheckIcon class="size-3.5" aria-hidden="true" />Apply {keptCount} {keptCount === 1 ? 'change' : 'changes'}
344
+ </button>
345
+ </div>
346
+
347
+ <!-- the two live regions (the MediaPicker discipline), both visually hidden. The tally (role=status)
348
+ speaks only on a bulk action; the polite region narrates the single last per-hunk action. -->
349
+ <span class="sr-only" role="status" data-testid="tidy-tally-live">{tallyMessage}</span>
350
+ <span class="sr-only" aria-live="polite" data-testid="tidy-action-live">{actionMessage}</span>
351
+ </div>
352
+ <form method="dialog" class="modal-backdrop">
353
+ <button type="button" tabindex="-1" aria-label="Close" onclick={cancel}>close</button>
354
+ </form>
355
+ </dialog>
@@ -0,0 +1,47 @@
1
+ import type { Change } from './tidy-diff.js';
2
+ import type { TidyConventions } from '../nav/site-config.js';
3
+ interface Props {
4
+ /** The validated change set (Task 13 output), the unit the surface accepts and rejects. */
5
+ changes: Change[];
6
+ /** The captured original the diff was computed against; the source of every line label and the
7
+ * before/after rows. Positions index this string. */
8
+ original: string;
9
+ /** The resolved tidy conventions, the ONLY data source for a normalization's because-line and the
10
+ * category inference. Never the buffer's usage. */
11
+ conventions: TidyConventions;
12
+ /** The model that produced the result, for the head pill (e.g. "claude-sonnet-4-6"). */
13
+ model: string;
14
+ /** The document's display title, for the head. */
15
+ title: string;
16
+ /** The apply seam from MarkdownEditor: the surface drives the in-buffer decorations and the batched
17
+ * apply through it. Typed with an inline `import(...)` so no static editor-module edge sits in this
18
+ * component (the editor-boundary test bars that edge by a textual scan). */
19
+ api: import('./editor-tidy.js').TidyApi;
20
+ /** Called when the review closes (apply or cancel), so the host clears tidy mode and re-enables the
21
+ * editor. `applied` is true when the author applied changes, false on cancel/reject-all. */
22
+ onclose: (applied: boolean) => void;
23
+ /** Called to scroll the editor underneath to a hunk's source line; the host drives the editor's
24
+ * selectRange seam. */
25
+ onshow: (from: number, to: number) => void;
26
+ }
27
+ /**
28
+ * The tidy review surface (spec 2.5, the approved rev.2 mockup). A native `<dialog>` opened with
29
+ * `showModal()`, so the focus trap, Escape, and inert background come from the platform. It shows the
30
+ * proposed copy-edit as a git-style diff, one hunk per change, ranked by safety: objective hunks
31
+ * (spelling, doubled word, whitespace, punctuation) read quiet and come pre-kept; judgment hunks (a
32
+ * declared normalization, or a grammar reword) carry the review-this treatment, default to undecided,
33
+ * and are NEVER swept by Accept fixes until the author confirms each. That safety property is the spine.
34
+ *
35
+ * The author's original stays in the editor buffer the whole time; the apply seam (registerTidy) shows
36
+ * the proposed edits as decorations and writes nothing until Apply. Apply lands the kept hunks in ONE
37
+ * batched transaction (one undoable step), so the whole tidy is one move back. Cancel and Reject all
38
+ * leave the document byte-identical.
39
+ *
40
+ * The category of each hunk is inferred LOCALLY from the diff shape and the enabled config, never a claim
41
+ * the model made and never a count of the author's own usage. A normalization names ONLY the config
42
+ * setting that authorized it; counting the author's own habit is the harmonize-to-author judgment cairn
43
+ * must never make, so no such count exists.
44
+ */
45
+ declare const TidyReview: import("svelte").Component<Props, {}, "">;
46
+ type TidyReview = ReturnType<typeof TidyReview>;
47
+ export default TidyReview;
@@ -6,46 +6,25 @@ text; when the editor holds a selection it arrives as the default text, and the
6
6
  that selection either way. Built on a native <dialog>, following the LinkPicker a11y conventions,
7
7
  and opened by the host's Ctrl/Cmd+K shortcut through the exported open().
8
8
  -->
9
- <script lang="ts">
10
- interface Props {
11
- /** Insert an inline link at the editor cursor; the editor's registerInsertLink seam. */
12
- insert: (href: string, title: string) => void;
13
- /** Read the editor's current selection, for the Text field's default. */
14
- selection?: () => string;
15
- /** Disable the trigger; the host sets it while Preview shows. */
16
- disabled?: boolean;
17
- /** Render the built-in Web link trigger. False mounts only the dialog, for a host that
18
- * supplies its own trigger and opens the dialog through the exported open(). */
19
- trigger?: boolean;
20
- }
21
-
22
- let { insert, selection, disabled = false, trigger = true }: Props = $props();
23
-
24
- let dialog = $state<HTMLDialogElement | null>(null);
25
- let hrefInput = $state<HTMLInputElement | null>(null);
26
- let href = $state('');
27
- let text = $state('');
28
-
29
- /** Open the dialog with fresh fields; the edit page's Ctrl/Cmd+K shortcut calls it too. */
30
- export function open() {
31
- href = '';
32
- text = selection?.() ?? '';
33
- dialog?.showModal();
34
- // showModal() lands focus on the first focusable element (the header Close button), so move
35
- // it to the address input the dialog exists for (WCAG 2.4.3). A microtask defers past the
36
- // dialog's own focus handling, the RenameDialog recipe.
37
- queueMicrotask(() => hrefInput?.focus());
38
- }
39
- function close() {
40
- dialog?.close();
41
- }
42
- function submit(e: SubmitEvent) {
43
- e.preventDefault();
44
- // With no text and no selection the address itself becomes the display text, so the link
45
- // never renders as an invisible pair of brackets.
46
- insert(href, text.trim() || href);
47
- close();
48
- }
9
+ <script lang="ts">let { insert, selection, disabled = false, trigger = true } = $props();
10
+ let dialog = $state(null);
11
+ let hrefInput = $state(null);
12
+ let href = $state("");
13
+ let text = $state("");
14
+ export function open() {
15
+ href = "";
16
+ text = selection?.() ?? "";
17
+ dialog?.showModal();
18
+ queueMicrotask(() => hrefInput?.focus());
19
+ }
20
+ function close() {
21
+ dialog?.close();
22
+ }
23
+ function submit(e) {
24
+ e.preventDefault();
25
+ insert(href, text.trim() || href);
26
+ close();
27
+ }
49
28
  </script>
50
29
 
51
30
  {#if trigger}