@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,463 @@
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">
21
+ import SparklesIcon from '@lucide/svelte/icons/sparkles';
22
+ import CheckIcon from '@lucide/svelte/icons/check';
23
+ import XIcon from '@lucide/svelte/icons/x';
24
+ import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
25
+ import LightbulbIcon from '@lucide/svelte/icons/lightbulb';
26
+ import EyeIcon from '@lucide/svelte/icons/eye';
27
+ import type { Change } from './tidy-diff.js';
28
+ import { lineLabel } from './tidy-diff.js';
29
+ import {
30
+ categorize,
31
+ isObjective,
32
+ buildBecause,
33
+ categoryLabel,
34
+ type TidyCategory,
35
+ } from './tidy-categorize.js';
36
+ import type { TidyConventions } from '../nav/site-config.js';
37
+
38
+ interface Props {
39
+ /** The validated change set (Task 13 output), the unit the surface accepts and rejects. */
40
+ changes: Change[];
41
+ /** The captured original the diff was computed against; the source of every line label and the
42
+ * before/after rows. Positions index this string. */
43
+ original: string;
44
+ /** The resolved tidy conventions, the ONLY data source for a normalization's because-line and the
45
+ * category inference. Never the buffer's usage. */
46
+ conventions: TidyConventions;
47
+ /** The model that produced the result, for the head pill (e.g. "claude-sonnet-4-6"). */
48
+ model: string;
49
+ /** The document's display title, for the head. */
50
+ title: string;
51
+ /** The apply seam from MarkdownEditor: the surface drives the in-buffer decorations and the batched
52
+ * apply through it. Typed with an inline `import(...)` so no static editor-module edge sits in this
53
+ * component (the editor-boundary test bars that edge by a textual scan). */
54
+ api: import('./editor-tidy.js').TidyApi;
55
+ /** Called when the review closes (apply or cancel), so the host clears tidy mode and re-enables the
56
+ * editor. `applied` is true when the author applied changes, false on cancel/reject-all. */
57
+ onclose: (applied: boolean) => void;
58
+ /** Called to scroll the editor underneath to a hunk's source line; the host drives the editor's
59
+ * selectRange seam. */
60
+ onshow: (from: number, to: number) => void;
61
+ }
62
+
63
+ let { changes, original, conventions, model, title, api, onclose, onshow }: Props = $props();
64
+
65
+ // One hunk per change, with its locally-inferred category, line label, diff rows, and because-line.
66
+ // Computed once from the immutable inputs; the disposition lives in its own reactive array so the
67
+ // rows do not recompute on every toggle.
68
+ interface Hunk {
69
+ index: number;
70
+ category: TidyCategory;
71
+ objective: boolean;
72
+ line: number;
73
+ contextBefore: string;
74
+ contextAfter: string;
75
+ delText: string;
76
+ addText: string;
77
+ delRun: { pre: string; mid: string; post: string };
78
+ addRun: { pre: string; mid: string; post: string };
79
+ because: ReturnType<typeof buildBecause>;
80
+ label: string;
81
+ }
82
+
83
+ const hunks: Hunk[] = $derived(changes.map((c) => {
84
+ const category = categorize(c, original, conventions);
85
+ const objective = isObjective(category);
86
+ const removed = original.slice(c.from, c.to);
87
+ const added = c.replacement;
88
+ // The line containing the change, and the one line of context above and below it (graft 4).
89
+ const line = lineLabel(original, c.from);
90
+ const lines = original.split('\n');
91
+ const contextBefore = line >= 2 ? lines[line - 2] ?? '' : '';
92
+ const contextAfter = line < lines.length ? lines[line] ?? '' : '';
93
+ // The changed line, split around the changed run so the diff can underline/strike just the run.
94
+ const lineStart = original.lastIndexOf('\n', c.from - 1) + 1;
95
+ const nextNewline = original.indexOf('\n', c.from);
96
+ const lineEnd = nextNewline === -1 ? original.length : nextNewline;
97
+ const fullLine = original.slice(lineStart, lineEnd);
98
+ const pre = original.slice(lineStart, c.from);
99
+ const post = original.slice(c.to, lineEnd);
100
+ const because =
101
+ category.kind === 'normalization' ? buildBecause(category.convention, conventions) : null;
102
+ return {
103
+ index: c.index,
104
+ category,
105
+ objective,
106
+ line,
107
+ contextBefore,
108
+ contextAfter,
109
+ delText: fullLine,
110
+ addText: pre + added + post,
111
+ delRun: { pre, mid: removed, post },
112
+ addRun: { pre, mid: added, post },
113
+ because,
114
+ label: categoryLabel(category),
115
+ };
116
+ }));
117
+
118
+ // The per-hunk disposition. Objective hunks open pre-kept; judgment hunks open undecided. The defaults
119
+ // come from the hunks (their safety rank); the author's per-hunk and bulk choices land in `overrides`,
120
+ // and `dispositions` is the merged effective map keyed by the stable change index. Splitting the two
121
+ // keeps the default reactive to the derived hunks without capturing only their initial value.
122
+ type Disposition = 'kept' | 'rejected' | 'undecided';
123
+ let overrides = $state<Record<number, Disposition>>({});
124
+
125
+ // The disposition a hunk takes under a given override map: the author's choice if present, else the
126
+ // safety-rank default (objective hunks pre-kept, judgment hunks undecided). One source for the default.
127
+ function effectiveDisposition(h: Hunk, map: Record<number, Disposition>): Disposition {
128
+ return map[h.index] ?? (h.objective ? 'kept' : 'undecided');
129
+ }
130
+
131
+ const dispositions = $derived<Record<number, Disposition>>(
132
+ Object.fromEntries(hunks.map((h) => [h.index, effectiveDisposition(h, overrides)] as const)),
133
+ );
134
+
135
+ // The keyboard step-through cursor: the focused hunk's array position. j/k move; a/r act on it.
136
+ let focusedPos = $state(0);
137
+
138
+ // The two live regions (the MediaPicker discipline). The tally region (role=status) speaks only on a
139
+ // bulk action; the action region (aria-live=polite) narrates the single per-hunk action and each
140
+ // cursor move. A live region re-announces only when its text changes, so a deterministic message
141
+ // (the same hunk, the same verb) would go silent on a repeat. Each writer appends an invisible
142
+ // incrementing nonce so the region text always mutates and the screen reader always speaks it.
143
+ let tallyMessage = $state('');
144
+ let actionMessage = $state('');
145
+ let announceNonce = 0;
146
+
147
+ // An invisible suffix that flips on every call, so a repeated identical announcement still changes
148
+ // the region text and re-fires the live region. It is a zero-width space, never voiced, so the heard
149
+ // sentence is unchanged. Each region keeps its own parity through the shared counter.
150
+ function nonce(): string {
151
+ return announceNonce++ % 2 === 0 ? '' : '​';
152
+ }
153
+
154
+ const keptCount = $derived(hunks.filter((h) => dispositions[h.index] === 'kept').length);
155
+ const reviewCount = $derived(hunks.filter((h) => dispositions[h.index] === 'undecided').length);
156
+ const skipCount = $derived(hunks.filter((h) => dispositions[h.index] === 'rejected').length);
157
+
158
+ let dialog = $state<HTMLDialogElement | null>(null);
159
+
160
+ $effect(() => {
161
+ // Open the dialog once on mount; showModal supplies the focus trap and Escape.
162
+ dialog?.showModal();
163
+ });
164
+
165
+ function setDisposition(index: number, next: Disposition) {
166
+ overrides = { ...overrides, [index]: next };
167
+ }
168
+
169
+ // Narrate one hunk in the polite region. The verb says what just happened to it ("Kept", "Skipped",
170
+ // or "Focused" as the cursor lands on it). The sentence carries the kind and the before/after text,
171
+ // and for a normalization appends the config-named rationale (never a usage count). The trailing
172
+ // nonce keeps a repeated identical action audible.
173
+ function narrate(h: Hunk, verb: string) {
174
+ const where = `Hunk ${hunks.indexOf(h) + 1} of ${hunks.length}`;
175
+ const what = h.delRun.mid && h.addRun.mid ? `${h.delRun.mid.trim()} becomes ${h.addRun.mid.trim()}` : h.label;
176
+ const why = h.because ? `, your ${h.because.label} setting is ${h.because.variant}` : '';
177
+ actionMessage = `${where}. ${h.label}. ${what}${why}. ${verb}.${nonce()}`;
178
+ }
179
+
180
+ function acceptHunk(h: Hunk) {
181
+ setDisposition(h.index, 'kept');
182
+ narrate(h, 'Kept');
183
+ }
184
+ function rejectHunk(h: Hunk) {
185
+ setDisposition(h.index, 'rejected');
186
+ narrate(h, 'Skipped');
187
+ }
188
+
189
+ // Accept fixes (the bulk action): mark EVERY OBJECTIVE hunk kept and nothing else. A judgment hunk is
190
+ // never touched here, so it stays undecided and is never swept. The tally region announces the result.
191
+ function acceptFixes() {
192
+ const next = { ...overrides };
193
+ for (const h of hunks) if (h.objective) next[h.index] = 'kept';
194
+ overrides = next;
195
+ const n = hunks.filter((h) => h.objective).length;
196
+ const stillReview = hunks.filter((h) => effectiveDisposition(h, next) === 'undecided').length;
197
+ tallyMessage = `${n} fixes kept. ${stillReview} still to review.${nonce()}`;
198
+ }
199
+
200
+ // Reject all: mark every hunk rejected; no text is written. The tally region announces it.
201
+ function rejectAll() {
202
+ overrides = Object.fromEntries(hunks.map((h) => [h.index, 'rejected'] as const));
203
+ tallyMessage = `All ${hunks.length} changes skipping.${nonce()}`;
204
+ }
205
+
206
+ // Apply: write the kept hunks in ONE batched transaction through the apply seam, then close. The
207
+ // seam's acceptMany dispatches a single view.dispatch({ changes }), so the whole tidy is one undoable
208
+ // step. ONLY the kept indexes are passed, so an undecided judgment hunk is never written.
209
+ function apply() {
210
+ const keptIndexes = hunks.filter((h) => dispositions[h.index] === 'kept').map((h) => h.index);
211
+ api.acceptMany(keptIndexes);
212
+ api.exit();
213
+ dialog?.close();
214
+ onclose(true);
215
+ }
216
+
217
+ // Cancel: write nothing, clear the decorations, leave the document byte-identical.
218
+ function cancel() {
219
+ api.exit();
220
+ dialog?.close();
221
+ onclose(false);
222
+ }
223
+
224
+ function showInText(h: Hunk) {
225
+ const c = changes.find((ch) => ch.index === h.index);
226
+ if (c) onshow(c.from, c.to);
227
+ }
228
+
229
+ // Move the step-through cursor and announce the hunk it lands on. A screen-reader user pressing j/k
230
+ // hears the newly-focused hunk (kind plus before/after text, plus the because-line for a judgment
231
+ // hunk), the same spec invariant the per-hunk action narration holds. Without this a move was silent.
232
+ function moveFocus(next: number) {
233
+ focusedPos = next;
234
+ const h = hunks[focusedPos];
235
+ if (h) narrate(h, 'Focused');
236
+ }
237
+
238
+ // Keyboard step-through on the hunk list (graft 3): j/k or n/p move; a/r accept/reject the focused
239
+ // hunk; A accepts all objective; Escape cancels (the native dialog supplies Escape, handled below).
240
+ function onListKeydown(e: KeyboardEvent) {
241
+ const h = hunks[focusedPos];
242
+ if (e.key === 'j' || e.key === 'n') {
243
+ moveFocus(Math.min(focusedPos + 1, hunks.length - 1));
244
+ e.preventDefault();
245
+ } else if (e.key === 'k' || e.key === 'p') {
246
+ moveFocus(Math.max(focusedPos - 1, 0));
247
+ e.preventDefault();
248
+ } else if (e.key === 'a' && !e.shiftKey) {
249
+ if (h) acceptHunk(h);
250
+ e.preventDefault();
251
+ } else if (e.key === 'r' && !e.shiftKey) {
252
+ if (h) rejectHunk(h);
253
+ e.preventDefault();
254
+ } else if (e.key === 'A' || (e.key === 'a' && e.shiftKey)) {
255
+ acceptFixes();
256
+ e.preventDefault();
257
+ }
258
+ }
259
+
260
+ // The native dialog raises a cancel event on Escape; map it to the surface's cancel so the buffer is
261
+ // left untouched and the host clears tidy mode.
262
+ function onDialogCancel(e: Event) {
263
+ e.preventDefault();
264
+ cancel();
265
+ }
266
+
267
+ function actsLabel(h: Hunk): string {
268
+ return h.objective ? 'Accept or reject this fix' : 'Accept or reject this change';
269
+ }
270
+ </script>
271
+
272
+ <dialog
273
+ bind:this={dialog}
274
+ class="modal"
275
+ aria-labelledby="cairn-tidy-title"
276
+ oncancel={onDialogCancel}
277
+ onkeydown={onListKeydown}
278
+ data-testid="tidy-review"
279
+ >
280
+ <div class="modal-box flex max-h-[85vh] w-[54rem] max-w-full flex-col overflow-hidden p-0">
281
+ <!-- the review head -->
282
+ <div class="flex items-center gap-3 border-b border-[var(--cairn-card-border)] px-4 py-3">
283
+ <span class="flex size-9 flex-none items-center justify-center rounded-lg bg-primary/10 text-primary">
284
+ <SparklesIcon class="size-5" aria-hidden="true" />
285
+ </span>
286
+ <div class="min-w-0 flex-1">
287
+ <div id="cairn-tidy-title" class="text-lg font-bold leading-tight">Review tidy</div>
288
+ <div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
289
+ <span><b class="text-base-content">{hunks.length} {hunks.length === 1 ? 'change' : 'changes'}</b> to <b class="text-base-content">{title}</b></span>
290
+ <span class="rounded-full border border-[var(--cairn-card-border)] px-2 py-0.5 text-[0.6875rem] font-semibold">{model}</span>
291
+ </div>
292
+ </div>
293
+ <span class="hidden flex-none items-center gap-1.5 text-[0.6875rem] text-[var(--color-muted)] sm:inline-flex" aria-hidden="true">
294
+ <kbd class="kbd kbd-xs">j</kbd><kbd class="kbd kbd-xs">k</kbd> move
295
+ <kbd class="kbd kbd-xs">a</kbd><kbd class="kbd kbd-xs">r</kbd> accept / reject
296
+ </span>
297
+ <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Cancel review" onclick={cancel}>
298
+ <XIcon class="size-4" aria-hidden="true" />
299
+ </button>
300
+ </div>
301
+
302
+ <!-- the bulk bar: the live tally (role=status, bulk-only) + Accept fixes / Reject all -->
303
+ <div class="flex items-center gap-3 border-b border-[var(--cairn-card-border)] bg-base-200 px-4 py-2.5">
304
+ <span class="inline-flex flex-wrap items-center gap-2 text-sm text-[var(--color-muted)]" data-testid="tidy-tally">
305
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--color-positive-ink)]">
306
+ <CheckIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{keptCount}</span> kept
307
+ </span>
308
+ <span class="opacity-40" aria-hidden="true">&middot;</span>
309
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--cairn-warning-ink)]">
310
+ <TriangleAlertIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{reviewCount}</span> to review
311
+ </span>
312
+ <span class="opacity-40" aria-hidden="true">&middot;</span>
313
+ <span class="inline-flex items-center gap-1 font-semibold text-[var(--cairn-error-ink)]">
314
+ <XIcon class="size-3" aria-hidden="true" /><span class="tabular-nums">{skipCount}</span> skipping
315
+ </span>
316
+ </span>
317
+ <span class="flex-1"></span>
318
+ <button type="button" class="btn btn-sm btn-outline" onclick={acceptFixes}>
319
+ <CheckIcon class="size-3 text-[var(--color-positive-ink)]" aria-hidden="true" />Accept fixes
320
+ </button>
321
+ <button type="button" class="btn btn-sm btn-outline" onclick={rejectAll}>
322
+ <XIcon class="size-3 text-[var(--cairn-error-ink)]" aria-hidden="true" />Reject all
323
+ </button>
324
+ </div>
325
+
326
+ <!-- the hunk list: the scroll container -->
327
+ <div class="flex flex-col gap-3 overflow-y-auto px-4 py-3.5">
328
+ {#each hunks as h, i (h.index)}
329
+ {@const decided = dispositions[h.index]}
330
+ {@const isJudgment = !h.objective}
331
+ {@const undecided = decided === 'undecided'}
332
+ <div
333
+ class="relative overflow-hidden rounded-xl border bg-base-100 {isJudgment && undecided
334
+ ? '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)]'
335
+ : 'border-[var(--cairn-card-border)]'} {decided === 'rejected' ? 'opacity-70' : ''} {i ===
336
+ focusedPos
337
+ ? 'outline outline-2 outline-offset-1 outline-[var(--color-primary)]'
338
+ : ''}"
339
+ data-testid="tidy-hunk"
340
+ data-objective={h.objective}
341
+ data-disposition={decided}
342
+ >
343
+ <!-- the hunk head -->
344
+ <div
345
+ class="flex items-center gap-2 border-b border-[var(--cairn-card-border)] px-3 py-2 {isJudgment
346
+ ? 'bg-[color-mix(in_oklab,var(--cairn-warning-ink)_7%,transparent)]'
347
+ : 'bg-[color-mix(in_oklab,var(--color-base-content)_1.5%,transparent)]'}"
348
+ >
349
+ <span
350
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold {isJudgment
351
+ ? 'bg-[color-mix(in_oklab,var(--cairn-warning-ink)_11%,transparent)] text-[var(--cairn-warning-ink)]'
352
+ : 'bg-[color-mix(in_oklab,var(--color-base-content)_6%,transparent)] text-[var(--color-muted)]'}"
353
+ >
354
+ {h.label}
355
+ </span>
356
+ {#if isJudgment}
357
+ <span class="inline-flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--cairn-warning-ink)]">
358
+ <EyeIcon class="size-3" aria-hidden="true" />Review this
359
+ </span>
360
+ {/if}
361
+ <button
362
+ type="button"
363
+ 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"
364
+ title="Show this line in the editor"
365
+ onclick={() => showInText(h)}
366
+ >
367
+ <EyeIcon class="size-3" aria-hidden="true" />line {h.line}
368
+ </button>
369
+ <span class="flex-1"></span>
370
+ <span class="inline-flex flex-none items-center overflow-hidden rounded-md border border-[var(--cairn-card-border)]" role="group" aria-label={actsLabel(h)}>
371
+ <button
372
+ type="button"
373
+ class="inline-flex min-h-6 items-center gap-1 px-2.5 py-1.5 text-[0.6875rem] font-medium {decided ===
374
+ 'kept'
375
+ ? 'bg-[color-mix(in_oklab,var(--color-positive-ink)_13%,transparent)] text-[var(--color-positive-ink)]'
376
+ : 'text-[var(--color-muted)]'}"
377
+ aria-pressed={decided === 'kept'}
378
+ onclick={() => acceptHunk(h)}
379
+ >
380
+ <CheckIcon class="size-3" aria-hidden="true" />Accept
381
+ </button>
382
+ <button
383
+ type="button"
384
+ 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 ===
385
+ 'rejected'
386
+ ? 'bg-[color-mix(in_oklab,var(--cairn-error-ink)_12%,transparent)] text-[var(--cairn-error-ink)]'
387
+ : 'text-[var(--color-muted)]'}"
388
+ aria-pressed={decided === 'rejected'}
389
+ onclick={() => rejectHunk(h)}
390
+ >
391
+ <XIcon class="size-3" aria-hidden="true" />Reject
392
+ </button>
393
+ </span>
394
+ </div>
395
+
396
+ <!-- the unified diff body: context, deletion, insertion, optional because-line -->
397
+ <div class="font-mono text-[0.8125rem] leading-relaxed">
398
+ {#if h.contextBefore}
399
+ <div class="flex items-baseline">
400
+ <span class="w-6 flex-none select-none text-center text-[var(--color-muted)] opacity-60" aria-hidden="true">&nbsp;</span>
401
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5 text-[var(--color-muted)]">{h.contextBefore}</span>
402
+ </div>
403
+ {/if}
404
+ <div class="flex items-baseline bg-[var(--cairn-tidy-del-row)]">
405
+ <span class="w-6 flex-none select-none text-center font-semibold text-[var(--cairn-error-ink)]" aria-hidden="true">&minus;</span>
406
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5">{h.delRun.pre}<span
407
+ class="rounded-sm bg-[var(--cairn-tidy-del-run)] px-px text-[var(--cairn-error-ink)] line-through decoration-1"
408
+ data-testid="tidy-del"
409
+ >{h.delRun.mid}</span>{h.delRun.post}</span>
410
+ </div>
411
+ <div class="flex items-baseline bg-[var(--cairn-tidy-add-row)] {decided === 'rejected' ? 'opacity-70' : ''}">
412
+ <span class="w-6 flex-none select-none text-center font-semibold text-[var(--color-positive-ink)]" aria-hidden="true">+</span>
413
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5">{h.addRun.pre}<span
414
+ class="rounded-sm bg-[var(--cairn-tidy-add-run)] px-px text-[var(--color-positive-ink)] {decided ===
415
+ 'rejected'
416
+ ? 'line-through opacity-70'
417
+ : ''}"
418
+ data-testid="tidy-add"
419
+ >{h.addRun.mid}</span>{h.addRun.post}</span>
420
+ </div>
421
+ {#if h.contextAfter}
422
+ <div class="flex items-baseline">
423
+ <span class="w-6 flex-none select-none text-center text-[var(--color-muted)] opacity-60" aria-hidden="true">&nbsp;</span>
424
+ <span class="flex-1 whitespace-pre-wrap break-words px-1 py-0.5 text-[var(--color-muted)]">{h.contextAfter}</span>
425
+ </div>
426
+ {/if}
427
+ </div>
428
+
429
+ {#if h.because}
430
+ <!-- the mandatory because-line: names ONLY the config setting that authorized this hunk -->
431
+ <div
432
+ 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)]"
433
+ data-testid="tidy-because"
434
+ >
435
+ <LightbulbIcon class="mt-px size-3 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
436
+ <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>
437
+ </div>
438
+ {/if}
439
+ </div>
440
+ {/each}
441
+ </div>
442
+
443
+ <!-- the review footer: the commit note + Cancel + the one-transaction Apply -->
444
+ <div class="flex items-center gap-2.5 border-t border-[var(--cairn-card-border)] px-4 py-3.5">
445
+ <span class="flex flex-1 items-center gap-1.5 text-[0.6875rem] leading-snug text-[var(--color-muted)]">
446
+ <CheckIcon class="size-3 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
447
+ Applies to the editor only. Your next Save commits it like any edit, and Undo takes the whole tidy back.
448
+ </span>
449
+ <button type="button" class="btn btn-sm" onclick={cancel}>Cancel</button>
450
+ <button type="button" class="btn btn-sm btn-primary" onclick={apply} disabled={keptCount === 0}>
451
+ <CheckIcon class="size-3.5" aria-hidden="true" />Apply {keptCount} {keptCount === 1 ? 'change' : 'changes'}
452
+ </button>
453
+ </div>
454
+
455
+ <!-- the two live regions (the MediaPicker discipline), both visually hidden. The tally (role=status)
456
+ speaks only on a bulk action; the polite region narrates the single last per-hunk action. -->
457
+ <span class="sr-only" role="status" data-testid="tidy-tally-live">{tallyMessage}</span>
458
+ <span class="sr-only" aria-live="polite" data-testid="tidy-action-live">{actionMessage}</span>
459
+ </div>
460
+ <form method="dialog" class="modal-backdrop">
461
+ <button type="button" tabindex="-1" aria-label="Close" onclick={cancel}>close</button>
462
+ </form>
463
+ </dialog>
@@ -107,6 +107,20 @@
107
107
  --cairn-error-tint: oklch(96% 0.03 25);
108
108
  --cairn-error-border: oklch(85% 0.06 25);
109
109
 
110
+ /* The tidy review diff tints (TidyReview). A deletion row and an insertion row each carry a faint
111
+ full-row tint, with a stronger run-highlight on the changed span; the changed-run TEXT is drawn in
112
+ --cairn-error-ink (deletion) or --color-positive-ink (insertion). Those inks were locked only
113
+ against base-100 and the fixed error/positive tints, never against the run-highlight stacked over
114
+ the row tint, so a self-mixed highlight pushed the text below 4.5:1 (WCAG 1.4.3). These are explicit
115
+ locked tones, not an ink-mix, so the stacked background is fixed and measurable. Locked (light):
116
+ deletion ink on the run tint measures 5.08:1 and on the row tint 5.81:1; insertion ink on the run
117
+ tint 4.98:1 and on the row tint 5.56:1. Do not lighten an ink or darken a tint without re-checking.
118
+ The +/- gutter glyph keeps the non-color cue, so hue is never the only signal. */
119
+ --cairn-tidy-del-row: oklch(96% 0.025 25);
120
+ --cairn-tidy-del-run: oklch(92% 0.05 25);
121
+ --cairn-tidy-add-row: oklch(96% 0.03 150);
122
+ --cairn-tidy-add-run: oklch(92% 0.06 150);
123
+
110
124
  /* Accessible muted text tones: >= 4.5:1 contrast on base-100/base-200. */
111
125
  --color-muted: oklch(48% 0.01 75);
112
126
  --color-subtle: oklch(42% 0.01 75);
@@ -220,6 +234,17 @@
220
234
  --cairn-error-tint: oklch(28% 0.06 25);
221
235
  --cairn-error-border: oklch(40% 0.09 25);
222
236
 
237
+ /* The tidy review diff tints on dark, the counterpart to the light root's. On dark the inks are light,
238
+ so a self-mixed highlight lightens the row toward the ink and collapses contrast (the run text
239
+ measured ~2.7:1, well under the 4.5:1 floor); these explicit low-lightness tones keep the row dark
240
+ and the light ink readable. Locked (dark): deletion ink on the run tint measures 5.89:1 and on the
241
+ row tint 7.12:1; insertion ink on the run tint 6.48:1 and on the row tint 8.01:1. Do not darken an
242
+ ink or lighten a tint without re-checking. */
243
+ --cairn-tidy-del-row: oklch(26% 0.05 25);
244
+ --cairn-tidy-del-run: oklch(32% 0.07 25);
245
+ --cairn-tidy-add-row: oklch(26% 0.045 150);
246
+ --cairn-tidy-add-run: oklch(32% 0.06 150);
247
+
223
248
  /* Accessible muted text tones on the dark bases. */
224
249
  --color-muted: oklch(72% 0.01 75);
225
250
  --color-subtle: oklch(80% 0.008 75);