diff --git a/SPEC.md b/SPEC.md index fc3d3c5..8886be7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -829,18 +829,20 @@ declined predecessor still visible. ### 8.10 Tracked-change markup and the review-mode toggle -Two visual layers carry change information in the editor. The first -is a paragraph-margin marker — a thin gutter accent on any paragraph -that differs from the branch's open-session baseline, rendered by a -ProseMirror plugin against a baseline snapshot taken when the editor -opens. The second is inline `tracked-delete` / `tracked-insert` -markup at the exact range of an accepted change, with the deleted -text shown struck-through and the inserted text shown in an additive +Two visual layers carry change information in the Contribute split. +The first is a paragraph-margin marker on the left raw-source pane — +a thin gutter accent on any line that differs from the branch's +open-session baseline, rendered by a CodeMirror gutter extension +against a baseline reset on every server-confirmed branch refresh +(accept, decline, manual flush, branch switch). The second is inline +`tracked-delete` / `tracked-insert` markup on the right preview pane +at the exact range of an accepted change, with the deleted text +shown struck-through and the inserted text shown in an additive style; both carry the change's ID via a data attribute, enabling the click-to-card binding from §8.8. The margin marker is scannable ("did anything change in this region?"); the inline markup is precise ("what changed here?"). The two answer different questions -and both are kept. +on the two halves of the split and both are kept. The inline markup lives in the rendered preview surface, not on the writable editor. With the Contribute-mode split (§8.3) the raw markdown @@ -865,10 +867,9 @@ selection-quote that drove the change, and the AI's `reason`. DiffView is the legacy read-only render surface invoked via the Contribute-mode toolbar toggle (§8.15) — a full-editor swap-in that reads the same `changes` table. It is retained as an interim path while -the preview-pane overlay matures and is slated for retirement once -gutter markers (the §8.10 paragraph-margin layer) land in the preview -itself; at that point both visual layers live in the same surface and -the toolbar toggle collapses. +the split-pane layers mature, and is slated for retirement once +the raw-pane gutter and the preview-pane inline overlay together cover +its review affordance; at that point the toolbar toggle collapses. ### 8.11 Manual edits and collisions with AI proposals diff --git a/frontend/src/App.css b/frontend/src/App.css index f40a9a8..4227b82 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -580,6 +580,23 @@ .cm-source-editor .cm-activeLineGutter { background: #f3f4f6; color: #555; } .cm-source-editor .cm-activeLine { background: #fafbfc; } +/* §8.10 paragraph-margin marker (Phase 4) — gutter accent on lines + that differ from the open-session baseline (last server-confirmed + body). A scannable "did anything change in this region?" cue, + visually distinct from the preview pane's green/red tracked-insert + / tracked-delete overlay. Amber signals in-flight / uncommitted. */ +.cm-source-editor .cm-diff-gutter { + width: 3px; padding: 0; + background: transparent; +} +.cm-source-editor .cm-diff-gutter .cm-gutterElement { + padding: 0; +} +.cm-source-editor .cm-diff-gutter-mark { + width: 3px; height: 100%; + background: #f59e0b; +} + .readonly-bar { border-top: 1px solid #e5e5e5; padding: 10px 16px; text-align: center; diff --git a/frontend/src/components/MarkdownSourceEditor.jsx b/frontend/src/components/MarkdownSourceEditor.jsx index 6052a0e..b93a94b 100644 --- a/frontend/src/components/MarkdownSourceEditor.jsx +++ b/frontend/src/components/MarkdownSourceEditor.jsx @@ -16,6 +16,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLi import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language' +import { diffGutterExtension, setBaseline } from './diffGutterExtension.js' export default function MarkdownSourceEditor({ initialDoc = '', @@ -34,6 +35,7 @@ export default function MarkdownSourceEditor({ doc: initialDoc, extensions: [ history(), + diffGutterExtension(), lineNumbers(), highlightActiveLine(), highlightActiveLineGutter(), @@ -51,6 +53,11 @@ export default function MarkdownSourceEditor({ ], }) const view = new EditorView({ state, parent: hostRef.current }) + // Seed the §8.10 gutter baseline to the initial doc — nothing + // diverges at construct time. Subsequent baseline resets ride the + // initialDoc-watching effect below so they dispatch atomically + // with the doc replacement. + view.dispatch({ effects: setBaseline.of(initialDoc ?? '') }) viewRef.current = view if (editorRef) { editorRef.current = { @@ -75,12 +82,16 @@ export default function MarkdownSourceEditor({ }, []) // Reflect external doc changes (branch reload, server-side flush, etc). + // The §8.10 gutter baseline is reset in the same dispatch so the + // newly-pushed doc doesn't briefly read as "divergent" against the + // old baseline. useEffect(() => { const v = viewRef.current if (!v) return if (v.state.doc.toString() === initialDoc) return v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: initialDoc ?? '' }, + effects: setBaseline.of(initialDoc ?? ''), }) }, [initialDoc]) diff --git a/frontend/src/components/diffGutterExtension.js b/frontend/src/components/diffGutterExtension.js new file mode 100644 index 0000000..8d9edb8 --- /dev/null +++ b/frontend/src/components/diffGutterExtension.js @@ -0,0 +1,98 @@ +// diffGutterExtension.js — Phase 4 of the Contribute rewrite. Restores +// §8.10's paragraph-margin gutter accent against the CM6 raw pane in +// the Contribute split, the surface where editing happens. The matching +// inline tracked-change overlay (Phase 3) lives in the preview pane on +// the right; the two visual layers answer different questions — +// "did anything change in this region?" (gutter) vs. "what changed +// here?" (inline) — and live on the two halves of the split for that +// reason. Discuss mode has no editor, so no gutter. +// +// Surface model +// ------------- +// CM6's natural unit is the line, not the paragraph, so the marker is +// per-line: every line whose text differs from the same-indexed line of +// the open-session baseline gets a thin amber bar in its own gutter +// (sat left of the line-numbers gutter). The spec talks +// "paragraph-margin marker"; collapsing line-marks back to +// paragraph-block ranges is more spec-faithful but adds code and risks +// hiding small inline changes inside large paragraphs. Per-line is the +// pre-fancy stance — a future pass can collapse if it reads noisy. +// +// Baseline reset +// -------------- +// Baseline = the last server-confirmed branch body. Initialized to +// `initialDoc` at editor construct; re-set via the `setBaseline` effect +// on accept / decline / manual flush / branch switch — i.e. every time +// the parent re-pushes `initialDoc`. The marker thus reads as +// "what's in the buffer but not on the server." +// +// The reset must dispatch atomically with the doc replacement — +// otherwise there's a one-frame window where the new doc differs from +// the old baseline and every line lights up. MarkdownSourceEditor's +// initialDoc effect bundles both into a single dispatch. +// +// Caveats kept deliberately +// ------------------------- +// Insert/delete shifts. Adding a line in the middle shifts every +// subsequent line's index and the naive index-compare lights up +// everything below the insertion. We tolerate it: the gutter is a +// scannable cue, not a precision diff, and "you've touched stuff below +// this point" remains honest. A future pass against a proper LCS diff +// could anchor the marks to stable lines, but Phase 4 doesn't need it. + +import { StateEffect, StateField } from '@codemirror/state' +import { GutterMarker, gutter } from '@codemirror/view' + +export const setBaseline = StateEffect.define() + +class DiffMarker extends GutterMarker { + toDOM() { + const el = document.createElement('div') + el.className = 'cm-diff-gutter-mark' + return el + } +} + +const diffMarker = new DiffMarker() + +// Baseline doc as a frozen line array. We split once on reset rather +// than on every gutter query — the doc itself can change far more +// often than the baseline. +const baselineField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) { + if (e.is(setBaseline)) return splitLines(e.value || '') + } + return value + }, +}) + +function splitLines(text) { + return (text ?? '').split('\n') +} + +export function diffGutterExtension() { + return [ + baselineField, + gutter({ + class: 'cm-diff-gutter', + lineMarker(view, line) { + const baseline = view.state.field(baselineField, false) + if (!baseline || baseline.length === 0) return null + const lineNumber = view.state.doc.lineAt(line.from).number + const baselineLine = baseline[lineNumber - 1] + const currentLine = view.state.doc.lineAt(line.from).text + if (baselineLine === undefined) return diffMarker + return currentLine === baselineLine ? null : diffMarker + }, + lineMarkerChange(update) { + if (update.docChanged) return true + for (const tr of update.transactions) { + for (const e of tr.effects) if (e.is(setBaseline)) return true + } + return false + }, + }), + ] +}