Contribute rewrite Phase 4: §8.10 gutter accent in CM6 raw pane

Restores the §8.10 paragraph-margin marker layer Phase 1 dropped when
Tiptap left, this time against the CM6 raw pane on the left half of
the Contribute split. The matching inline tracked-insert/tracked-
delete overlay Phase 3 shipped lives on the preview pane to the
right; the two visual layers answer different questions on the two
halves of the split — "did anything change in this region?" (gutter,
amber, scannable) vs. "what changed here?" (inline, green/red,
precise) — and are deliberately separate signals.

New file: frontend/src/components/diffGutterExtension.js. The
extension exposes a `setBaseline` StateEffect and a gutter that marks
every line whose text differs from the same-indexed line of the
baseline (the last server-confirmed branch body). Per-line, not
paragraph-block — CM6's natural unit; collapsing to paragraph ranges
is more spec-faithful but adds code, and the per-line stance is the
pre-fancy default. A TODO is left for a future paragraph-collapse
pass if the result reads noisy.

MarkdownSourceEditor.jsx changes:
- Install the gutter extension in the editor's extension list.
- Seed the baseline to `initialDoc` at construct time.
- In the existing `initialDoc`-watching effect, dispatch the doc
  replacement AND a `setBaseline` effect in the SAME transaction so
  there's no one-frame window where the new doc reads as "divergent"
  against the old baseline. This carries through every server-
  confirmed branch refresh that RFCView already wires (accept,
  decline, manual flush, branch switch); no RFCView changes needed
  because all four paths already re-push `initialDoc` after pulling
  fresh state.

Design calls per the Phase 4 prompt's open list:
  • Per-line marks, not paragraph-block ranges. Pre-fancy stance.
  • Amber (#f59e0b) thin 3px vertical bar, distinct from the
    preview pane's green-on-light / red-on-light tracked-change
    inline overlay. Reads as "in-flight / not yet on the server."
  • Baseline reset on every branchView refresh (accept / decline /
    manual flush / branch switch), matching RFCView's existing
    originalSourceLinesRef discipline. Gutter then reads as "what's
    in the buffer but not on the server."
  • No hover / no click. The inline overlay already carries the
    click-to-card binding; the gutter is scannable only.

Known caveats kept deliberately:
  • Insert/delete shifts. Adding a line in the middle shifts every
    subsequent line's index and the naive compare lights up
    everything below — tolerated as the honest "you've touched
    stuff below this point" cue. A future LCS-anchored pass could
    fix it; Phase 4 doesn't need to.

SPEC §8.10:
- First paragraph rewritten to name the CM6 raw pane as the gutter's
  home and replace the stale "ProseMirror plugin" wording with the
  CodeMirror gutter framing. Mirrors how Phase 3 named the preview
  pane as the inline overlay's home.
- DiffView retirement paragraph adjusted: gutter and inline overlay
  together (across the split) cover its review affordance, not
  "both layers in the same surface" — the layers are deliberately on
  different surfaces.

Verification:
- Vite preview sandbox eval — standalone CM6 mount, dispatch tests
  across construct (no marks on identity), per-line edit (mark on
  exactly the touched line, confirmed by Y-coordinate matching the
  line-number gutter element), baseline reset clears, atomic
  doc+baseline dispatch leaves zero marks (the RFCView accept-flow
  path), insert-in-middle exhibits the expected cascade, and
  computed-style proof for the amber bar (`#f59e0b`, 3px wide,
  positioned left of the line-numbers gutter at x=21 vs x=24).
- Screenshot captures the bar on the touched line only.
- 125 backend integration tests still green.
- Live RFCView accept → branch refresh → gutter clear flow against a
  real branch was not driven (backend not running this session,
  carrying forward Phase 2/3's verification gap). The sandbox-level
  proof covers the atomic dispatch correctness; the next session
  with a backend up should drive that golden path before piling
  Phase 5 work on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 11:00:45 -07:00
parent 2a7c099a33
commit 886bbf5512
4 changed files with 139 additions and 12 deletions
+17
View File
@@ -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;
@@ -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])
@@ -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
},
}),
]
}