A local-first note taking app
0

Configure Feed

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

Render nested blockquotes as equal-width bars at any depth

The depth styling used a single border-left with `double` for levels 2-3,
so level 2 rendered as two hairlines thinner than the level-1 solid bar,
level 3 as thicker lines, and levels past 3 had no class at all (depth was
capped at 3). A single border can only ever be one bar.

Pass the nesting depth to CSS as an inline `--quote-depth` variable and
paint the bars with a repeating gradient clipped (via background-size) to
exactly that many 3px bars. Every level now adds one more equal-width bar,
at any depth, with a dark-theme bar color.

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

Ethan Graf (Jul 21, 2026, 9:29 PM EDT) 790f2ba2 abad6890

+61 -27
+21 -14
src/editors/automerge/automergeEditor.css
··· 225 225 padding-left: 3.5em; 226 226 } 227 227 228 - /* Blockquotes — the depth class carries the bar + indent so nested quotes 229 - * (`> >`) read as progressively deeper. */ 228 + /* 229 + * Blockquotes. Each nesting level draws one equal-width vertical bar. The 230 + * builder sets `--quote-depth` (the number of `>` on the line) as an inline 231 + * variable; a repeating gradient paints a 3px bar every `--quote-step`, and 232 + * `background-size` clips it to exactly `--quote-depth` bars — so any depth 233 + * renders with uniform bars (a single `border-left` can only be one bar). 234 + */ 230 235 .automerge-editor-container .cm-quote { 236 + --quote-bar: #d0d7de; 237 + --quote-bar-w: 3px; 238 + --quote-step: 0.9em; 231 239 color: #656d76; 232 - margin-left: 0; 240 + padding-left: calc(var(--quote-depth, 1) * var(--quote-step) + 0.5em); 241 + background-image: repeating-linear-gradient( 242 + to right, 243 + var(--quote-bar) 0 var(--quote-bar-w), 244 + transparent var(--quote-bar-w) var(--quote-step) 245 + ); 246 + background-repeat: no-repeat; 247 + background-size: calc(var(--quote-depth, 1) * var(--quote-step)) 100%; 233 248 } 234 - .automerge-editor-container .cm-quote-1 { 235 - border-left: 3px solid #d0d7de; 236 - padding-left: 1em; 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; 249 + :root[data-theme='dark'] .automerge-editor-container .cm-quote { 250 + --quote-bar: #3b4048; 251 + color: #9198a1; 245 252 } 246 253 247 254 /* Reference-link definition line (`[ref]: url`) — muted, it is plumbing. */
+20 -9
src/editors/automerge/livePreview/blockquoteDecoration.ts
··· 1 1 /** 2 2 * Blockquotes (`> …`). 3 3 * 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. 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. Nesting 6 + * depth is the number of `>` markers on the line (`> >` → 2); it is passed to 7 + * the CSS as an inline `--quote-depth` variable, which draws that many 8 + * equal-width bars via a repeating gradient. This renders any depth uniformly 9 + * (a single `border-left` could only ever be one bar). 8 10 */ 9 11 import { type EditorState } from '@codemirror/state'; 10 12 import { Decoration } from '@codemirror/view'; ··· 20 22 } from './context'; 21 23 import { blockquoteFormattingHiddenMark } from './hiddenClasses'; 22 24 23 - const MAX_DEPTH = 3; 24 - const quoteLines = [1, 2, 3].map((depth) => 25 - Decoration.line({ class: `cm-quote cm-quote-${depth}` }), 26 - ); 25 + // One line decoration per nesting depth, cached (CM6 reuses instances). 26 + const quoteLineByDepth = new Map<number, Decoration>(); 27 + function quoteLine(depth: number): Decoration { 28 + let deco = quoteLineByDepth.get(depth); 29 + if (!deco) { 30 + deco = Decoration.line({ 31 + class: 'cm-quote', 32 + attributes: { style: `--quote-depth:${depth}` }, 33 + }); 34 + quoteLineByDepth.set(depth, deco); 35 + } 36 + return deco; 37 + } 27 38 28 39 export function buildBlockquoteDecorations( 29 40 state: EditorState, ··· 52 63 53 64 for (const [lineNo, depth] of depthByLine) { 54 65 const line = state.doc.line(lineNo); 55 - out.push(line.from, line.from, quoteLines[Math.min(depth, MAX_DEPTH) - 1]); 66 + out.push(line.from, line.from, quoteLine(depth)); 56 67 } 57 68 58 69 return out.result();
+20 -4
src/editors/automerge/livePreview/livePreview.test.ts
··· 20 20 to: number; 21 21 cls: string; 22 22 text: string; 23 + style: string; 23 24 } 24 25 25 26 function makeState(doc: string, cursor = 0): EditorState { ··· 43 44 // spec is typed `any` upstream. 44 45 cls: (iter.value.spec as { class?: string }).class ?? '', 45 46 text: state.doc.sliceString(iter.from, iter.to), 47 + style: 48 + (iter.value.spec as { attributes?: { style?: string } }).attributes 49 + ?.style ?? '', 46 50 }); 47 51 iter.next(); 48 52 } ··· 275 279 expect(hidden[0].text).toBe('> '); 276 280 }); 277 281 278 - it('applies a deeper class to nested blockquotes', () => { 282 + it('encodes nesting depth for each quoted line', () => { 279 283 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); 284 + const styles = withClass( 285 + decos(state, buildBlockquoteDecorations(state)), 286 + 'cm-quote', 287 + ).map((q) => q.style); 288 + expect(styles).toContain('--quote-depth:1'); 289 + expect(styles).toContain('--quote-depth:2'); 290 + }); 291 + 292 + it('renders arbitrary nesting depth (beyond 3)', () => { 293 + const state = makeState('> > > > deep\n\nbody', 15); // in "body" 294 + const styles = withClass( 295 + decos(state, buildBlockquoteDecorations(state)), 296 + 'cm-quote', 297 + ).map((q) => q.style); 298 + expect(styles).toContain('--quote-depth:4'); 283 299 }); 284 300 }); 285 301