@glw907/cairn-cms 0.59.0 → 0.60.0
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.
- package/CHANGELOG.md +47 -0
- package/dist/components/CairnAdmin.svelte +3 -0
- package/dist/components/CairnTidySettings.svelte +553 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/EditPage.svelte +371 -2
- package/dist/components/MarkdownEditor.svelte +168 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/TidyReview.svelte +463 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/cairn-admin.css +764 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +146 -0
- package/dist/components/spellcheck.js +541 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +10 -1
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +679 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- 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">·</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">·</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"> </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">−</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"> </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);
|