A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Live preview Tier 1: complete partial node-type rendering

Address the "recognized but marker left raw" cases from the audit, plus
code syntax colors:

- Headings: hide the optional closing `##` sequence, not just the opening.
- Escapes / hard breaks (new escapeDecoration.ts): hide the backslash of
`\x` escapes and the `\` hard-line-break form, so the escaped character
renders literally.
- Reference links: hide the `[ref]` label (no URL child) and mute the
`[ref]: url` definition line via cm-link-def.
- Fenced code: hide the language label (CodeInfo) off-block so no stray
`js` floats above the box.
- Code syntax colors: add a scoped tok-* palette (light + data-theme dark)
so classHighlighter output is actually colored.
- Nested blockquotes: cm-quote-{depth} by `>` count per line, with
progressive indent/bar instead of flat styling.

Extends the headless builder tests with a case per fix (146 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

authored by

Ethan Graf
Claude Opus 4.8
and committed by
Tangled
(Jul 22, 2026, 4:34 AM +0300) 69464b68 1b9605da

+314 -39
+112 -2
src/editors/automerge/automergeEditor.css
··· 121 121 border-bottom-right-radius: 6px; 122 122 } 123 123 124 + /* 125 + * Syntax colors for code (fenced blocks and inline code). `classHighlighter` 126 + * from @lezer/highlight tags tokens with `tok-*` classes; without these rules 127 + * code renders monochrome. Values are GitHub-ish; the dark set follows the 128 + * app's `:root[data-theme='dark']` theme switch. 129 + */ 130 + .automerge-editor-container .tok-keyword { 131 + color: #cf222e; 132 + } 133 + .automerge-editor-container .tok-controlKeyword, 134 + .automerge-editor-container .tok-moduleKeyword { 135 + color: #cf222e; 136 + } 137 + .automerge-editor-container .tok-string, 138 + .automerge-editor-container .tok-string2, 139 + .automerge-editor-container .tok-special { 140 + color: #0a3069; 141 + } 142 + .automerge-editor-container .tok-comment, 143 + .automerge-editor-container .tok-lineComment, 144 + .automerge-editor-container .tok-blockComment { 145 + color: #6e7781; 146 + font-style: italic; 147 + } 148 + .automerge-editor-container .tok-number, 149 + .automerge-editor-container .tok-bool, 150 + .automerge-editor-container .tok-atom { 151 + color: #0550ae; 152 + } 153 + .automerge-editor-container .tok-variableName { 154 + color: #24292f; 155 + } 156 + .automerge-editor-container .tok-function, 157 + .automerge-editor-container .tok-definition { 158 + color: #8250df; 159 + } 160 + .automerge-editor-container .tok-typeName, 161 + .automerge-editor-container .tok-className, 162 + .automerge-editor-container .tok-namespace { 163 + color: #953800; 164 + } 165 + .automerge-editor-container .tok-propertyName { 166 + color: #0550ae; 167 + } 168 + .automerge-editor-container .tok-operator, 169 + .automerge-editor-container .tok-punctuation, 170 + .automerge-editor-container .tok-separator, 171 + .automerge-editor-container .tok-bracket { 172 + color: #57606a; 173 + } 174 + 175 + :root[data-theme='dark'] .automerge-editor-container .tok-keyword, 176 + :root[data-theme='dark'] .automerge-editor-container .tok-controlKeyword, 177 + :root[data-theme='dark'] .automerge-editor-container .tok-moduleKeyword { 178 + color: #ff7b72; 179 + } 180 + :root[data-theme='dark'] .automerge-editor-container .tok-string, 181 + :root[data-theme='dark'] .automerge-editor-container .tok-string2, 182 + :root[data-theme='dark'] .automerge-editor-container .tok-special { 183 + color: #a5d6ff; 184 + } 185 + :root[data-theme='dark'] .automerge-editor-container .tok-comment, 186 + :root[data-theme='dark'] .automerge-editor-container .tok-lineComment, 187 + :root[data-theme='dark'] .automerge-editor-container .tok-blockComment { 188 + color: #8b949e; 189 + } 190 + :root[data-theme='dark'] .automerge-editor-container .tok-number, 191 + :root[data-theme='dark'] .automerge-editor-container .tok-bool, 192 + :root[data-theme='dark'] .automerge-editor-container .tok-atom { 193 + color: #79c0ff; 194 + } 195 + :root[data-theme='dark'] .automerge-editor-container .tok-variableName { 196 + color: #e6edf3; 197 + } 198 + :root[data-theme='dark'] .automerge-editor-container .tok-function, 199 + :root[data-theme='dark'] .automerge-editor-container .tok-definition { 200 + color: #d2a8ff; 201 + } 202 + :root[data-theme='dark'] .automerge-editor-container .tok-typeName, 203 + :root[data-theme='dark'] .automerge-editor-container .tok-className, 204 + :root[data-theme='dark'] .automerge-editor-container .tok-namespace { 205 + color: #ffa657; 206 + } 207 + :root[data-theme='dark'] .automerge-editor-container .tok-propertyName { 208 + color: #79c0ff; 209 + } 210 + :root[data-theme='dark'] .automerge-editor-container .tok-operator, 211 + :root[data-theme='dark'] .automerge-editor-container .tok-punctuation, 212 + :root[data-theme='dark'] .automerge-editor-container .tok-separator, 213 + :root[data-theme='dark'] .automerge-editor-container .tok-bracket { 214 + color: #8b949e; 215 + } 216 + 124 217 /* Lists */ 125 218 .automerge-editor-container .cm-list-1 { 126 219 padding-left: 1.5em; ··· 132 225 padding-left: 3.5em; 133 226 } 134 227 135 - /* Blockquotes */ 228 + /* Blockquotes — the depth class carries the bar + indent so nested quotes 229 + * (`> >`) read as progressively deeper. */ 136 230 .automerge-editor-container .cm-quote { 137 231 color: #656d76; 232 + margin-left: 0; 233 + } 234 + .automerge-editor-container .cm-quote-1 { 138 235 border-left: 3px solid #d0d7de; 139 236 padding-left: 1em; 140 - margin-left: 0; 237 + } 238 + .automerge-editor-container .cm-quote-2 { 239 + border-left: 6px double #d0d7de; 240 + padding-left: 1.5em; 241 + } 242 + .automerge-editor-container .cm-quote-3 { 243 + border-left: 9px double #d0d7de; 244 + padding-left: 2em; 245 + } 246 + 247 + /* Reference-link definition line (`[ref]: url`) — muted, it is plumbing. */ 248 + .automerge-editor-container .cm-link-def { 249 + color: #8c959f; 250 + font-size: 0.9em; 141 251 } 142 252 143 253 /* Task lists */
+21 -26
src/editors/automerge/livePreview/blockquoteDecoration.ts
··· 1 1 /** 2 2 * Blockquotes (`> …`). 3 3 * 4 - * Applies a `cm-quote` line decoration to each quoted line and hides the `>` 5 - * (plus trailing space) prefix when the cursor is not on that line. Handles a 6 - * single level of quoting via `QuoteMark` nodes; nested blockquotes reuse the 7 - * same line class (dedup keeps it flat) and their inner `>` markers are hidden 8 - * independently — deeper-nesting-specific styling is a follow-up. 4 + * Applies a `cm-quote cm-quote-{depth}` line decoration to each quoted line and 5 + * hides the `>` (plus trailing space) prefix when the cursor is not on that 6 + * line. Nesting depth is the number of `>` markers on the line (`> >` → 2), so 7 + * nested quotes indent/tint progressively rather than rendering flat. 9 8 */ 10 9 import { type EditorState } from '@codemirror/state'; 11 10 import { Decoration } from '@codemirror/view'; ··· 21 20 } from './context'; 22 21 import { blockquoteFormattingHiddenMark } from './hiddenClasses'; 23 22 24 - const quoteLine = Decoration.line({ class: 'cm-quote' }); 23 + const MAX_DEPTH = 3; 24 + const quoteLines = [1, 2, 3].map((depth) => 25 + Decoration.line({ class: `cm-quote cm-quote-${depth}` }), 26 + ); 25 27 26 28 export function buildBlockquoteDecorations( 27 29 state: EditorState, ··· 29 31 ): BuildResult { 30 32 const cursorLine = cursorLineNumber(state); 31 33 const out = new DecoCollector(); 34 + // Nesting depth per line = number of `>` markers on that line. 35 + const depthByLine = new Map<number, number>(); 32 36 33 37 for (const { from, to } of ranges) { 34 38 syntaxTree(state).iterate({ 35 39 from, 36 40 to, 37 41 enter(node) { 38 - if (node.name === 'Blockquote') { 39 - const endPos = Math.min(node.to, state.doc.length); 40 - const startLine = state.doc.lineAt(node.from).number; 41 - let endLine = state.doc.lineAt(endPos).number; 42 - if (endPos === state.doc.lineAt(endPos).from && endLine > startLine) { 43 - endLine--; 44 - } 45 - for (let l = startLine; l <= endLine; l++) { 46 - const line = state.doc.line(l); 47 - out.push(line.from, line.from, quoteLine); 48 - } 49 - return; 50 - } 51 - 52 - if (node.name === 'QuoteMark') { 53 - const line = state.doc.lineAt(node.from); 54 - if (line.number !== cursorLine) { 55 - const hideEnd = Math.min(node.to + 1, line.to); 56 - out.push(node.from, hideEnd, blockquoteFormattingHiddenMark); 57 - } 42 + if (node.name !== 'QuoteMark') return; 43 + const line = state.doc.lineAt(node.from); 44 + depthByLine.set(line.number, (depthByLine.get(line.number) ?? 0) + 1); 45 + if (line.number !== cursorLine) { 46 + const hideEnd = Math.min(node.to + 1, line.to); 47 + out.push(node.from, hideEnd, blockquoteFormattingHiddenMark); 58 48 } 59 49 }, 60 50 }); 51 + } 52 + 53 + for (const [lineNo, depth] of depthByLine) { 54 + const line = state.doc.line(lineNo); 55 + out.push(line.from, line.from, quoteLines[Math.min(depth, MAX_DEPTH) - 1]); 61 56 } 62 57 63 58 return out.result();
+6
src/editors/automerge/livePreview/codeDecoration.ts
··· 111 111 if (last?.name === 'CodeMark') { 112 112 out.push(last.from, last.to, inlineFormattingHiddenMark, true); 113 113 } 114 + // Hide the language label too, so the opening fence line does not 115 + // leave a stray `js` floating above the block. 116 + const info = node.node.getChild('CodeInfo'); 117 + if (info) { 118 + out.push(info.from, info.to, inlineFormattingHiddenMark, true); 119 + } 114 120 } 115 121 return false; 116 122 }
+54
src/editors/automerge/livePreview/escapeDecoration.ts
··· 1 + /** 2 + * Backslash markers: character escapes (`\*`) and the hard-line-break 3 + * backslash (`text\` + newline). 4 + * 5 + * - `Escape` nodes span `\x`; we hide the leading `\` so the escaped character 6 + * renders literally (e.g. `\*` shows a plain `*`, not italics). 7 + * - `HardBreak` nodes cover the break; the backslash form starts with `\` 8 + * (the two-space form is already invisible), so we hide that `\`. 9 + * 10 + * Both reveal when the cursor is near (same line) for editability, and the 11 + * hidden ranges are registered atomic so cursor motion skips them. 12 + */ 13 + import { type EditorState } from '@codemirror/state'; 14 + import { syntaxTree } from '@codemirror/language'; 15 + 16 + import { 17 + type BuildResult, 18 + type Range, 19 + DecoCollector, 20 + decorationExtension, 21 + fullRanges, 22 + isCursorNearSameLine, 23 + } from './context'; 24 + import { inlineFormattingHiddenMark } from './hiddenClasses'; 25 + 26 + export function buildEscapeDecorations( 27 + state: EditorState, 28 + ranges: readonly Range[] = fullRanges(state), 29 + ): BuildResult { 30 + const out = new DecoCollector(); 31 + 32 + for (const { from, to } of ranges) { 33 + syntaxTree(state).iterate({ 34 + from, 35 + to, 36 + enter(node) { 37 + if (node.name !== 'Escape' && node.name !== 'HardBreak') return; 38 + // Only the backslash form of a hard break has a marker to hide. 39 + if ( 40 + node.name === 'HardBreak' && 41 + state.doc.sliceString(node.from, node.from + 1) !== '\\' 42 + ) { 43 + return; 44 + } 45 + if (isCursorNearSameLine(state, node.from, node.to)) return; 46 + out.push(node.from, node.from + 1, inlineFormattingHiddenMark, true); 47 + }, 48 + }); 49 + } 50 + 51 + return out.result(); 52 + } 53 + 54 + export const escapeDecoration = decorationExtension(buildEscapeDecorations);
+20 -4
src/editors/automerge/livePreview/headingDecoration.ts
··· 46 46 deco: HEADING_LINE_DECOS[level - 1], 47 47 }); 48 48 if (line.number !== cursorLine) { 49 - const headerMark = node.node.getChild('HeaderMark'); 50 - if (headerMark) { 51 - const hideEnd = Math.min(headerMark.to + 1, line.to); 49 + // ATX headings have an opening HeaderMark (`## `) and, optionally, a 50 + // closing one (`## Title ##`). Hide both so neither shows as text. 51 + const marks = node.node.getChildren('HeaderMark'); 52 + const open = marks[0]; 53 + if (open) { 54 + // Include the space after the opening hashes. 55 + const hideEnd = Math.min(open.to + 1, line.to); 52 56 decos.push({ 53 - from: headerMark.from, 57 + from: open.from, 54 58 to: hideEnd, 59 + deco: headingPrefixHiddenMark, 60 + }); 61 + } 62 + if (marks.length > 1) { 63 + const close = marks[marks.length - 1]; 64 + // Include the whitespace preceding the closing hashes. 65 + const before = state.doc.sliceString(line.from, close.from); 66 + let start = before.length; 67 + while (start > 0 && before[start - 1] === ' ') start--; 68 + decos.push({ 69 + from: line.from + start, 70 + to: close.to, 55 71 deco: headingPrefixHiddenMark, 56 72 }); 57 73 }
+2
src/editors/automerge/livePreview/index.ts
··· 18 18 import { blockquoteDecoration } from './blockquoteDecoration'; 19 19 import { hrDecoration } from './hrDecoration'; 20 20 import { listDecoration } from './listDecoration'; 21 + import { escapeDecoration } from './escapeDecoration'; 21 22 22 23 /** Open a URL in the system browser, via the Electron bridge when present. */ 23 24 function openExternal(url: string): void { ··· 55 56 blockquoteDecoration, 56 57 hrDecoration, 57 58 listDecoration, 59 + escapeDecoration, 58 60 linkClickHandler, 59 61 ]; 60 62 }
+27 -7
src/editors/automerge/livePreview/linkDecoration.ts
··· 1 1 /** 2 - * Inline links: `[label](url)`. 2 + * Inline and reference links. 3 + * 4 + * - Inline `[label](url)`: style the label `cm-link`, hide the brackets/parens, 5 + * URL and optional title unless the cursor is near. 6 + * - Reference `[label][ref]`: style the label, hide the brackets and the 7 + * `[ref]` reference label (which has no URL child). 8 + * - Definition lines `[ref]: url`: a muted `cm-link-def` line class. 3 9 * 4 - * Styles the label `cm-link` and hides the brackets, parens, URL and optional 5 - * title unless the cursor is near (same line). `linkUrlAt` resolves the URL of 6 - * the link enclosing a document offset — used by the cmd/ctrl-click handler in 7 - * `index.ts` to open links externally. Autolinks and images are left raw. 10 + * `linkUrlAt` resolves the URL of the inline link enclosing a document offset — 11 + * used by the cmd/ctrl-click handler in `index.ts`. Autolinks and images are 12 + * left raw. 8 13 */ 9 14 import { type EditorState } from '@codemirror/state'; 10 15 import { Decoration } from '@codemirror/view'; ··· 22 27 import { inlineFormattingHiddenMark } from './hiddenClasses'; 23 28 24 29 const linkLabelMark = Decoration.mark({ class: 'cm-link' }); 30 + const linkDefLine = Decoration.line({ class: 'cm-link-def' }); 25 31 26 32 /** Children of a Link node, grouped by role. */ 27 33 function linkParts(node: SyntaxNode): { 28 34 marks: SyntaxNode[]; 35 + labels: SyntaxNode[]; 29 36 url: SyntaxNode | null; 30 37 title: SyntaxNode | null; 31 38 } { 32 39 const marks: SyntaxNode[] = []; 40 + const labels: SyntaxNode[] = []; 33 41 let url: SyntaxNode | null = null; 34 42 let title: SyntaxNode | null = null; 35 43 for (let c = node.firstChild; c; c = c.nextSibling) { 36 44 if (c.name === 'LinkMark') marks.push(c); 45 + else if (c.name === 'LinkLabel') labels.push(c); 37 46 else if (c.name === 'URL') url = c; 38 47 else if (c.name === 'LinkTitle') title = c; 39 48 } 40 - return { marks, url, title }; 49 + return { marks, labels, url, title }; 41 50 } 42 51 43 52 export function buildLinkDecorations( ··· 51 60 from, 52 61 to, 53 62 enter(node) { 63 + // Definition line `[ref]: url` — mute it; it is plumbing, not prose. 64 + if (node.name === 'LinkReference') { 65 + const line = state.doc.lineAt(node.from); 66 + out.push(line.from, line.from, linkDefLine); 67 + return; 68 + } 69 + 54 70 if (node.name !== 'Link') return; 55 - const { marks, url, title } = linkParts(node.node); 71 + const { marks, labels, url, title } = linkParts(node.node); 56 72 57 73 // Style the label (between the first `[` and its matching `]`). 58 74 if (marks.length >= 2 && marks[1].from > marks[0].to) { ··· 65 81 66 82 for (const m of marks) { 67 83 out.push(m.from, m.to, inlineFormattingHiddenMark, true); 84 + } 85 + // Reference links carry a `[ref]` LinkLabel (no URL) that must hide too. 86 + for (const l of labels) { 87 + out.push(l.from, l.to, inlineFormattingHiddenMark, true); 68 88 } 69 89 if (url) out.push(url.from, url.to, inlineFormattingHiddenMark, true); 70 90 if (title) {
+72
src/editors/automerge/livePreview/livePreview.test.ts
··· 12 12 import { buildBlockquoteDecorations } from './blockquoteDecoration'; 13 13 import { buildHrDecorations } from './hrDecoration'; 14 14 import { buildListDecorations } from './listDecoration'; 15 + import { buildEscapeDecorations } from './escapeDecoration'; 15 16 import { type BuildResult } from './context'; 16 17 17 18 interface FlatDeco { ··· 80 81 const state = makeState('### Three\n\nx', 11); 81 82 const list = decos(state, buildHeadingDecorations(state)); 82 83 expect(withClass(list, 'cm-heading-3')).toHaveLength(1); 84 + }); 85 + 86 + it('hides the closing ## sequence too', () => { 87 + const state = makeState('## Section ##\n\nbody', 16); // in "body" 88 + const list = decos(state, buildHeadingDecorations(state)); 89 + const hidden = withClass(list, 'cm-formatting-hidden'); 90 + // Opening "## " and the trailing " ##". 91 + expect(hidden).toHaveLength(2); 92 + expect(hidden[0].text).toBe('## '); 93 + expect(hidden[hidden.length - 1].text).toBe(' ##'); 83 94 }); 84 95 }); 85 96 ··· 187 198 expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); 188 199 }); 189 200 201 + it('hides the language label when cursor is outside the block', () => { 202 + const state = makeState('```js\ncode\n```\n\nafter', 18); // in "after" 203 + const hidden = withClass( 204 + decos(state, buildCodeDecorations(state)), 205 + 'cm-inline-formatting-hidden', 206 + ).map((h) => h.text); 207 + expect(hidden).toContain('js'); // no stray language label floats above 208 + }); 209 + 190 210 it('paints the active empty line of an unterminated block (after Enter)', () => { 191 211 // "```js\n" — opening fence then Enter; the new line 2 is where the 192 212 // cursor sits and must be inside the block. ··· 229 249 expect(linkUrlAt(state, 5)).toBe('http://x'); // inside "a" 230 250 expect(linkUrlAt(state, 0)).toBeNull(); // in "see " 231 251 }); 252 + 253 + it('hides the [ref] of a reference link and mutes the definition', () => { 254 + const state = makeState('See [docs][ref] here.\n\n[ref]: https://x.com', 0); 255 + const list = decos(state, buildLinkDecorations(state)); 256 + const label = withClass(list, 'cm-link'); 257 + expect(label).toHaveLength(1); 258 + expect(label[0].text).toBe('docs'); 259 + const hidden = withClass(list, 'cm-inline-formatting-hidden').map( 260 + (h) => h.text, 261 + ); 262 + expect(hidden).toContain('[ref]'); 263 + // The definition line is muted rather than shown as body text. 264 + expect(withClass(list, 'cm-link-def')).toHaveLength(1); 265 + }); 232 266 }); 233 267 234 268 describe('blockquoteDecoration', () => { ··· 240 274 expect(hidden).toHaveLength(1); 241 275 expect(hidden[0].text).toBe('> '); 242 276 }); 277 + 278 + it('applies a deeper class to nested blockquotes', () => { 279 + const state = makeState('> outer\n> > inner\n\nbody', 20); // in "body" 280 + const list = decos(state, buildBlockquoteDecorations(state)); 281 + expect(withClass(list, 'cm-quote-1')).toHaveLength(1); 282 + expect(withClass(list, 'cm-quote-2')).toHaveLength(1); 283 + }); 243 284 }); 244 285 245 286 describe('hrDecoration', () => { ··· 274 315 expect(withClass(list, 'cm-list-2')).toHaveLength(1); 275 316 }); 276 317 }); 318 + 319 + describe('escapeDecoration', () => { 320 + it('hides the backslash of an escape, leaving the escaped char', () => { 321 + const state = makeState('a \\* b', 0); 322 + const hidden = withClass( 323 + decos(state, buildEscapeDecorations(state)), 324 + 'cm-inline-formatting-hidden', 325 + ); 326 + expect(hidden).toHaveLength(1); 327 + expect(hidden[0].text).toBe('\\'); 328 + }); 329 + 330 + it('reveals the backslash when the cursor is on it', () => { 331 + const state = makeState('a \\* b', 3); // between the \ and * 332 + const hidden = withClass( 333 + decos(state, buildEscapeDecorations(state)), 334 + 'cm-inline-formatting-hidden', 335 + ); 336 + expect(hidden).toHaveLength(0); 337 + }); 338 + 339 + it('hides the backslash of a hard line break', () => { 340 + const state = makeState('line\\\nnext', 8); // in "next" 341 + const hidden = withClass( 342 + decos(state, buildEscapeDecorations(state)), 343 + 'cm-inline-formatting-hidden', 344 + ); 345 + expect(hidden).toHaveLength(1); 346 + expect(hidden[0].text).toBe('\\'); 347 + }); 348 + });