@glw907/cairn-cms 0.53.0 → 0.55.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/components/AdminLayout.svelte +52 -19
  3. package/dist/components/ConceptList.svelte +210 -73
  4. package/dist/components/ConceptList.svelte.d.ts +6 -4
  5. package/dist/components/EditPage.svelte +372 -110
  6. package/dist/components/EditPage.svelte.d.ts +2 -1
  7. package/dist/components/EditorToolbar.svelte +26 -10
  8. package/dist/components/MarkdownEditor.svelte +108 -14
  9. package/dist/components/MarkdownHelpDialog.svelte +5 -0
  10. package/dist/components/ShortcutsDialog.svelte +37 -0
  11. package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
  12. package/dist/components/ShortcutsGrid.svelte +18 -0
  13. package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
  14. package/dist/components/cairn-admin.css +184 -104
  15. package/dist/components/editor-folding.d.ts +7 -0
  16. package/dist/components/editor-folding.js +331 -0
  17. package/dist/components/editor-highlight.js +55 -6
  18. package/dist/components/editor-shortcuts.d.ts +16 -0
  19. package/dist/components/editor-shortcuts.js +36 -0
  20. package/dist/components/markdown-directives.d.ts +17 -0
  21. package/dist/components/markdown-directives.js +41 -0
  22. package/dist/components/topbar-context.d.ts +13 -0
  23. package/dist/components/topbar-context.js +17 -0
  24. package/dist/content/manifest.d.ts +1 -0
  25. package/dist/content/manifest.js +6 -0
  26. package/dist/delivery/content-index.js +1 -1
  27. package/dist/delivery/data.d.ts +1 -1
  28. package/dist/delivery/data.js +1 -1
  29. package/dist/sveltekit/content-routes.d.ts +3 -0
  30. package/dist/sveltekit/content-routes.js +10 -5
  31. package/package.json +1 -1
  32. package/src/lib/components/AdminLayout.svelte +52 -19
  33. package/src/lib/components/ConceptList.svelte +210 -73
  34. package/src/lib/components/EditPage.svelte +372 -110
  35. package/src/lib/components/EditorToolbar.svelte +26 -10
  36. package/src/lib/components/MarkdownEditor.svelte +108 -14
  37. package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
  38. package/src/lib/components/ShortcutsDialog.svelte +37 -0
  39. package/src/lib/components/ShortcutsGrid.svelte +18 -0
  40. package/src/lib/components/cairn-admin.css +24 -11
  41. package/src/lib/components/editor-folding.ts +356 -0
  42. package/src/lib/components/editor-highlight.ts +54 -4
  43. package/src/lib/components/editor-shortcuts.ts +42 -0
  44. package/src/lib/components/markdown-directives.ts +42 -0
  45. package/src/lib/components/topbar-context.ts +30 -0
  46. package/src/lib/content/manifest.ts +7 -0
  47. package/src/lib/delivery/content-index.ts +1 -1
  48. package/src/lib/delivery/data.ts +1 -1
  49. package/src/lib/sveltekit/content-routes.ts +13 -5
  50. /package/dist/{delivery → content}/excerpt.d.ts +0 -0
  51. /package/dist/{delivery → content}/excerpt.js +0 -0
  52. /package/src/lib/{delivery → content}/excerpt.ts +0 -0
@@ -0,0 +1,356 @@
1
+ // Container folding: CodeMirror's fold system driven by cairn's directive grammar, with the rail
2
+ // band as the affordance. Client-only like editor-highlight and editor-modes; MarkdownEditor
3
+ // reaches this module through a dynamic import, so the static @codemirror imports here never enter
4
+ // a server bundle (guarded by the editor-boundary test).
5
+ //
6
+ // The architecture (plan decision 4): @codemirror/language's codeFolding plus foldEffect/
7
+ // unfoldEffect, never a custom fold store. Fold ranges come only from containerRanges, the pure
8
+ // pairing helper beside fenceScan. The safety invariant lives in one transactionExtender that
9
+ // appends unfold effects when a change or selection touches a folded range. The chevrons are
10
+ // widget decorations from this module's own ViewPlugin (the rails are box-shadows, there is no CM
11
+ // gutter element to hang a foldGutter on).
12
+ //
13
+ // The safety invariant: an author never edits, deletes, or fails to see hidden text.
14
+ import {
15
+ Decoration,
16
+ EditorView,
17
+ ViewPlugin,
18
+ WidgetType,
19
+ keymap,
20
+ type DecorationSet,
21
+ type ViewUpdate,
22
+ } from '@codemirror/view';
23
+ import { EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension } from '@codemirror/state';
24
+ import { codeFolding, foldEffect, foldedRanges, unfoldEffect } from '@codemirror/language';
25
+ import { caretContainerRange, containerRanges, fenceScan, type ContainerRange } from './markdown-directives.js';
26
+
27
+ // Deeper nesting shares the third visual step, matching the rail stepping in editor-highlight.
28
+ const DEPTH_STEPS = 3;
29
+ // The chevron sits over the container's own innermost bar: depth 1 at x0, depth 2 at x8, depth 3
30
+ // at x16, so indentation telegraphs the nesting and depth 3 never collides with depth 2.
31
+ const chevronX = (depth: number) => (Math.min(depth, DEPTH_STEPS) - 1) * 8;
32
+
33
+ // The two chevron glyphs from the gold-standard mockup: down (caret inside) and right (folded).
34
+ const CHEVRON_DOWN = 'm6 9 6 6 6-6';
35
+ const CHEVRON_RIGHT = 'm9 6 6 6-6 6';
36
+
37
+ function chevronSvg(direction: 'down' | 'right'): SVGSVGElement {
38
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
39
+ svg.setAttribute('viewBox', '0 0 24 24');
40
+ svg.setAttribute('fill', 'none');
41
+ svg.setAttribute('stroke', 'currentColor');
42
+ svg.setAttribute('stroke-width', '2.5');
43
+ svg.setAttribute('stroke-linecap', 'round');
44
+ svg.setAttribute('stroke-linejoin', 'round');
45
+ svg.setAttribute('aria-hidden', 'true');
46
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
47
+ path.setAttribute('d', direction === 'down' ? CHEVRON_DOWN : CHEVRON_RIGHT);
48
+ svg.appendChild(path);
49
+ return svg;
50
+ }
51
+
52
+ // The opener-row band: the whole 28px gutter is the click target (cursor pointer over the band
53
+ // only, the opener text never folds), and the chevron lives inside it at the container's bar x.
54
+ // The widget knows its own container and whether it is folded, so the click toggles the right
55
+ // range; the depth class lets the theme step the chevron ink.
56
+ class FoldBandWidget extends WidgetType {
57
+ constructor(
58
+ readonly range: ContainerRange,
59
+ readonly folded: boolean,
60
+ readonly caretInside: boolean,
61
+ ) {
62
+ super();
63
+ }
64
+ eq(other: FoldBandWidget) {
65
+ return (
66
+ other.range.fromLine === this.range.fromLine &&
67
+ other.range.toLine === this.range.toLine &&
68
+ other.range.depth === this.range.depth &&
69
+ other.folded === this.folded &&
70
+ other.caretInside === this.caretInside
71
+ );
72
+ }
73
+ toDOM(view: EditorView): HTMLElement {
74
+ const band = document.createElement('span');
75
+ const depth = Math.min(this.range.depth, DEPTH_STEPS);
76
+ // Folded rows always show the chevron (right); an open container shows it down while the caret
77
+ // is inside; otherwise it fades in on rail-band hover (the band's own :hover, in the theme).
78
+ // The depth class carries the stepped ink.
79
+ const state = this.folded ? ' cm-cairn-fold-folded' : this.caretInside ? ' cm-cairn-fold-active' : '';
80
+ band.className = `cm-cairn-fold-band cm-cairn-fold-depth-${depth}${state}`;
81
+ const chevron = chevronSvg(this.folded ? 'right' : 'down');
82
+ chevron.style.left = `${chevronX(this.range.depth)}px`;
83
+ band.appendChild(chevron);
84
+ band.addEventListener('mousedown', (e) => {
85
+ // mousedown, not click: a click would first move the caret into the line. preventDefault
86
+ // keeps the caret where it is and stops the band from stealing focus from the editor.
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+ toggleFold(view, this.range);
90
+ });
91
+ return band;
92
+ }
93
+ ignoreEvent() {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // The pill placeholder: a real focusable button counting the hidden lines, the screen-reader story
99
+ // for a fold. preparePlaceholder computes the count off the folded char range so placeholderDOM
100
+ // renders it without re-deriving. Clicking unfolds through CodeMirror's own onclick handler.
101
+ function preparePlaceholder(state: EditorState, range: { from: number; to: number }): number {
102
+ return state.doc.lineAt(range.to).number - state.doc.lineAt(range.from).number;
103
+ }
104
+
105
+ function placeholderDOM(view: EditorView, onclick: (event: Event) => void, lines: number): HTMLElement {
106
+ const pill = document.createElement('button');
107
+ pill.type = 'button';
108
+ pill.className = 'cm-cairn-fold-pill';
109
+ pill.textContent = `${lines} lines`;
110
+ pill.setAttribute('aria-label', `Show ${lines} hidden lines`);
111
+ pill.addEventListener('click', onclick);
112
+ return pill;
113
+ }
114
+
115
+ // The char range a container folds: end-of-opener-line to end-of-closer-line, so the bare closer
116
+ // never dangles. Null when the opener and closer share a line (nothing to hide). The one place
117
+ // that turns a line range into the fold range, shared by the toggle, the keymap, and the
118
+ // decoration build.
119
+ function foldCharRange(state: EditorState, range: ContainerRange): { from: number; to: number } | null {
120
+ const opener = state.doc.line(range.fromLine + 1);
121
+ const closer = state.doc.line(range.toLine + 1);
122
+ if (closer.to <= opener.to) return null;
123
+ return { from: opener.to, to: closer.to };
124
+ }
125
+
126
+ // Fold one container, or unfold it if already folded. The range comes from containerRanges, so a
127
+ // half-typed fence can never fold. A no-op when the container has nothing to hide.
128
+ function toggleFold(view: EditorView, range: ContainerRange): void {
129
+ const span = foldCharRange(view.state, range);
130
+ if (!span) return;
131
+ const effect = foldExists(view.state, span.from, span.to) ? unfoldEffect : foldEffect;
132
+ view.dispatch({ effects: effect.of(span) });
133
+ }
134
+
135
+ function foldExists(state: EditorState, from: number, to: number): boolean {
136
+ let found = false;
137
+ foldedRanges(state).between(from, from, (a, b) => {
138
+ if (a === from && b === to) found = true;
139
+ });
140
+ return found;
141
+ }
142
+
143
+ // The innermost container at the caret, the unit the keymap folds and unfolds. caretContainerRange
144
+ // returns the nearest enclosing container, but a container that never closes (an unbalanced
145
+ // opener) must not fold, so the result is only honored when containerRanges actually pairs it.
146
+ function caretFoldRange(view: EditorView): ContainerRange | null {
147
+ const scan = fenceScan(docLines(view));
148
+ const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
149
+ const inner = caretContainerRange(scan, caretLine);
150
+ if (!inner) return null;
151
+ return (
152
+ containerRanges(scan).find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null
153
+ );
154
+ }
155
+
156
+ function docLines(view: EditorView): string[] {
157
+ const doc = view.state.doc;
158
+ const lines: string[] = [];
159
+ for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
160
+ return lines;
161
+ }
162
+
163
+ // The keymap: Ctrl+Shift+[ folds, Ctrl+Shift+] unfolds, the innermost container at the caret. No
164
+ // fold-all, no chords. (CodeMirror's own foldKeymap binds the same keys to its tree-folding
165
+ // commands; this module replaces them with the container-aware versions and never adds foldKeymap.)
166
+ // At Prec.high so it resolves the shifted bracket ahead of the default keymap's Ctrl-[ indentLess,
167
+ // which the same keystroke also matches on the non-shift lookup.
168
+ const foldKeymap = Prec.high(
169
+ keymap.of([
170
+ {
171
+ key: 'Mod-Shift-[',
172
+ run: (view) => {
173
+ const range = caretFoldRange(view);
174
+ const span = range && foldCharRange(view.state, range);
175
+ if (!span || foldExists(view.state, span.from, span.to)) return false;
176
+ view.dispatch({ effects: foldEffect.of(span) });
177
+ return true;
178
+ },
179
+ },
180
+ {
181
+ key: 'Mod-Shift-]',
182
+ run: (view) => {
183
+ const range = caretFoldRange(view);
184
+ const span = range && foldCharRange(view.state, range);
185
+ if (!span || !foldExists(view.state, span.from, span.to)) return false;
186
+ view.dispatch({ effects: unfoldEffect.of(span) });
187
+ return true;
188
+ },
189
+ },
190
+ ]),
191
+ );
192
+
193
+ // The unfold flash: a one-time low-alpha accent line decoration on the revealed lines, faded out
194
+ // over ~400ms. A StateEffect carries the revealed char range; the field decorates those lines
195
+ // until a follow-up effect clears it. Driven by the plugin, which schedules the clear.
196
+ const flashEffect = StateEffect.define<{ from: number; to: number } | null>();
197
+ const flashLine = Decoration.line({ class: 'cm-cairn-fold-flash' });
198
+
199
+ const flashField = StateField.define<DecorationSet>({
200
+ create() {
201
+ return Decoration.none;
202
+ },
203
+ update(deco, tr) {
204
+ deco = deco.map(tr.changes);
205
+ for (const e of tr.effects) {
206
+ if (e.is(flashEffect)) {
207
+ if (!e.value) {
208
+ deco = Decoration.none;
209
+ } else {
210
+ const builder = new RangeSetBuilder<Decoration>();
211
+ const first = tr.state.doc.lineAt(e.value.from).number;
212
+ const last = tr.state.doc.lineAt(e.value.to).number;
213
+ for (let n = first; n <= last; n++) {
214
+ const line = tr.state.doc.line(n);
215
+ builder.add(line.from, line.from, flashLine);
216
+ }
217
+ deco = builder.finish();
218
+ }
219
+ }
220
+ }
221
+ return deco;
222
+ },
223
+ provide: (f) => EditorView.decorations.from(f),
224
+ });
225
+
226
+ const FLASH_MS = 400;
227
+
228
+ // The safety invariant, in one transactionExtender. CodeMirror's own fold field already clears a
229
+ // fold the selection head sits inside and a fold a delete touches; this covers the rest: an insert
230
+ // touching a fold boundary, a paste across it, an undo/redo landing inside, and a selection range
231
+ // (not just its head) extending into hidden text. It reads the start state's folds, maps them
232
+ // forward, and appends an unfold effect for any the change or new selection touches. A replace
233
+ // inside a fold leaves it open afterward, which falls out of the same rule.
234
+ function safetyExtender(): Extension {
235
+ return EditorState.transactionExtender.of((tr) => {
236
+ if (!tr.docChanged && !tr.selection) return null;
237
+ const startFolds = foldedRanges(tr.startState);
238
+ if (startFolds.size === 0) return null;
239
+ const effects: StateEffect<unknown>[] = [];
240
+ startFolds.between(0, tr.startState.doc.length, (from, to) => {
241
+ // The fold's position after the change, for the selection test.
242
+ const mappedFrom = tr.changes.mapPos(from, 1);
243
+ const mappedTo = tr.changes.mapPos(to, -1);
244
+ let touched = false;
245
+ if (tr.docChanged) {
246
+ tr.changes.iterChangedRanges((fromA, toA) => {
247
+ if (fromA <= to && toA >= from) touched = true;
248
+ });
249
+ }
250
+ if (!touched && tr.selection) {
251
+ for (const range of tr.selection.ranges) {
252
+ if (range.from < mappedTo && range.to > mappedFrom) touched = true;
253
+ }
254
+ }
255
+ if (touched) effects.push(unfoldEffect.of({ from, to }));
256
+ });
257
+ return effects.length ? { effects } : null;
258
+ });
259
+ }
260
+
261
+ // The chevron-and-wash plugin: a widget on each opener row of a paired container (the band with
262
+ // the chevron) and the folded-row wash line decoration. Rebuilds on doc change (the container set
263
+ // moves), selection change (the caret-inside chevron state), viewport change, and any fold change
264
+ // (a fold flips a chevron and adds a wash). The hover reveal is the band's own CSS :hover in the
265
+ // theme, so it costs no rebuild.
266
+ function foldDecorations(view: EditorView, scan: ReturnType<typeof fenceScan>, ranges: ContainerRange[]): DecorationSet {
267
+ const builder = new RangeSetBuilder<Decoration>();
268
+ const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
269
+ const inner = caretContainerRange(scan, caretLine);
270
+ // One opener row may host several enclosing containers' bars, but only its OWN container opens
271
+ // there, so a single band per opener row. Sort by opener line so the builder receives ascending
272
+ // positions; equal openers cannot happen (one open per line).
273
+ const byOpener = [...ranges].sort((a, b) => a.fromLine - b.fromLine);
274
+ for (const range of byOpener) {
275
+ const span = foldCharRange(view.state, range);
276
+ if (!span) continue;
277
+ const opener = view.state.doc.line(range.fromLine + 1);
278
+ const folded = foldExists(view.state, span.from, span.to);
279
+ // The folded opener row carries the wash; a line decoration so the rails (box-shadows on the
280
+ // same element) run through it unbroken.
281
+ if (folded) builder.add(opener.from, opener.from, washLine);
282
+ const caretInside = !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
283
+ builder.add(
284
+ opener.from,
285
+ opener.from,
286
+ Decoration.widget({ widget: new FoldBandWidget(range, folded, caretInside), side: -1 }),
287
+ );
288
+ }
289
+ return builder.finish();
290
+ }
291
+
292
+ const washLine = Decoration.line({ class: 'cm-cairn-folded-row' });
293
+
294
+ function foldPlugin() {
295
+ return ViewPlugin.fromClass(
296
+ class {
297
+ decorations: DecorationSet;
298
+ scan: ReturnType<typeof fenceScan>;
299
+ ranges: ContainerRange[];
300
+ constructor(view: EditorView) {
301
+ this.scan = fenceScan(docLines(view));
302
+ this.ranges = containerRanges(this.scan);
303
+ this.decorations = foldDecorations(view, this.scan, this.ranges);
304
+ }
305
+ update(update: ViewUpdate) {
306
+ if (update.docChanged) {
307
+ this.scan = fenceScan(docLines(update.view));
308
+ this.ranges = containerRanges(this.scan);
309
+ }
310
+ const foldChanged = update.transactions.some((tr) =>
311
+ tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)),
312
+ );
313
+ if (update.docChanged || update.viewportChanged || update.selectionSet || foldChanged) {
314
+ this.decorations = foldDecorations(update.view, this.scan, this.ranges);
315
+ }
316
+ // Flash the revealed lines on an unfold, then schedule the clear. Folding adds no flash.
317
+ for (const tr of update.transactions) {
318
+ for (const e of tr.effects) {
319
+ if (e.is(unfoldEffect)) {
320
+ const { from, to } = e.value;
321
+ const view = update.view;
322
+ queueMicrotask(() => {
323
+ if (!view.dom.isConnected) return;
324
+ view.dispatch({ effects: flashEffect.of({ from, to }) });
325
+ setTimeout(() => {
326
+ if (view.dom.isConnected) view.dispatch({ effects: flashEffect.of(null) });
327
+ }, FLASH_MS);
328
+ });
329
+ }
330
+ }
331
+ }
332
+ }
333
+ },
334
+ { decorations: (v) => v.decorations },
335
+ );
336
+ }
337
+
338
+ // The design notes' diagnostics rules (a deliberately refolded erroring container keeps its fold
339
+ // and tints the pill warning ink instead of re-springing on every lint) have no trigger today: the
340
+ // editor carries no lint source, so there is nothing to refold around or to tint. Deliberately not
341
+ // built; do not invent a lint system to satisfy a rule with no input.
342
+
343
+ /**
344
+ * The cairn fold extension: the CodeMirror fold system with the pill placeholder, the chevron and
345
+ * wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap. Session-local and never
346
+ * persisted: the fold state lives in CodeMirror's foldState field, which this never serializes.
347
+ */
348
+ export function cairnFolding(): Extension {
349
+ return [
350
+ codeFolding({ preparePlaceholder, placeholderDOM }),
351
+ flashField,
352
+ safetyExtender(),
353
+ foldPlugin(),
354
+ foldKeymap,
355
+ ];
356
+ }
@@ -7,10 +7,12 @@ import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewU
7
7
  import { RangeSetBuilder } from '@codemirror/state';
8
8
  import {
9
9
  caretContainerRange,
10
+ containerRanges,
10
11
  directiveLineKind,
11
12
  fenceScan,
12
13
  fenceTokens,
13
14
  findInlineDirectives,
15
+ markerPrefix,
14
16
  type FenceScan,
15
17
  } from './markdown-directives.js';
16
18
 
@@ -25,7 +27,9 @@ export function cairnHighlightStyle(): HighlightStyle {
25
27
  { tag: tags.heading1, fontSize: '1.5em', fontWeight: '700', color: 'var(--color-base-content)' },
26
28
  { tag: tags.heading2, fontSize: '1.3em', fontWeight: '700', color: 'var(--color-base-content)' },
27
29
  { tag: tags.heading3, fontSize: '1.17em', fontWeight: '700', color: 'var(--color-base-content)' },
28
- // h4 and deeper share the weight only; body size keeps the low levels from outranking h3.
30
+ // A real step for h4, between h3 and body, so a hand-typed #### reads as a heading.
31
+ { tag: tags.heading4, fontSize: '1.05em', fontWeight: '700', color: 'var(--color-base-content)' },
32
+ // h5 and deeper share the weight only; body size keeps the low levels from outranking h4.
29
33
  { tag: tags.heading, fontWeight: '700', color: 'var(--color-base-content)' },
30
34
  { tag: tags.strong, fontWeight: '700' },
31
35
  { tag: tags.emphasis, fontStyle: 'italic' },
@@ -59,6 +63,11 @@ const fenceLines = DEPTH_STEPS.map((d) =>
59
63
  );
60
64
  const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
61
65
  const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
66
+ // A paired container's opener row, where the fold chevron replaces the container's own innermost
67
+ // rail bar. The class is additive over the fence depth class, so the theme drops only the deepest
68
+ // bar on this row while the outer bars stay; an unbalanced opener never gets it (no chevron, bar
69
+ // intact). Added just after the fence depth line decoration so the row carries both classes.
70
+ const openerLine = Decoration.line({ class: 'cm-cairn-directive-opener' });
62
71
  const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
63
72
  // Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
64
73
  // directive name and label keep a depth-stepped ink: meaning over machinery.
@@ -69,6 +78,26 @@ const fenceLabels = DEPTH_STEPS.map((d) => Decoration.mark({ class: `cm-cairn-di
69
78
  // label ink up one notch while the other containers sit quieter.
70
79
  const caretBlockLine = Decoration.line({ class: 'cm-cairn-caret-block' });
71
80
 
81
+ // The hanging-indent line decoration for a quote or list line, keyed by the marker's character
82
+ // width. The width rides a --cairn-hang custom property so the theme's padding-left rule adds it
83
+ // to the directive gutter rather than replacing it (an inline padding-left would win the cascade
84
+ // and erase the gutter). The equal negative text-indent pulls the first line back by the marker
85
+ // width, so the marker sits in the indent and a wrapped continuation resumes under the content
86
+ // (the Obsidian/HyperMD idiom). The surface is iA Writer Mono, fixed pitch, so n chars is exactly
87
+ // n ch. Built lazily and memoized; marker widths repeat.
88
+ const hangLines = new Map<number, Decoration>();
89
+ function hangLine(width: number): Decoration {
90
+ let deco = hangLines.get(width);
91
+ if (!deco) {
92
+ deco = Decoration.line({
93
+ class: 'cm-cairn-hang',
94
+ attributes: { style: `--cairn-hang:${width}ch;text-indent:-${width}ch` },
95
+ });
96
+ hangLines.set(width, deco);
97
+ }
98
+ return deco;
99
+ }
100
+
72
101
  // Depth needs the whole document, since a visible line's containers can open above the viewport.
73
102
  // One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
74
103
  // is well under a millisecond. The plugin caches the fence scan, so it reruns only when the
@@ -89,6 +118,9 @@ function buildDirectiveDecorations(view: EditorView, scan: FenceScan): Decoratio
89
118
  // as its own line decoration just ahead of the row's depth decoration.
90
119
  const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
91
120
  const caret = caretContainerRange(scan, caretLine);
121
+ // The opener rows that carry a fold chevron, so the rail rule drops their innermost bar. Only
122
+ // paired containers fold, so an unbalanced opener is absent here and keeps its full rail.
123
+ const openerLines = new Set(containerRanges(scan).map((r) => r.fromLine));
92
124
  for (const { from, to } of view.visibleRanges) {
93
125
  for (let pos = from; pos <= to; ) {
94
126
  const line = view.state.doc.lineAt(pos);
@@ -101,6 +133,26 @@ function buildDirectiveDecorations(view: EditorView, scan: FenceScan): Decoratio
101
133
  // inside a code block, outside any container); it gets no machinery treatment.
102
134
  if (kind === 'fence' && depth > 0) {
103
135
  builder.add(line.from, line.from, fenceLines[depth - 1]);
136
+ // A paired opener row gets the opener class just after its fence depth class, so the rail
137
+ // rule below drops the innermost bar where the chevron renders.
138
+ if (openerLines.has(line.number - 1)) builder.add(line.from, line.from, openerLine);
139
+ } else if (kind === 'leaf') {
140
+ builder.add(line.from, line.from, leafLine);
141
+ } else if (kind === null && depth > 0) {
142
+ builder.add(line.from, line.from, contentLines[depth - 1]);
143
+ }
144
+ // A quote or list line hangs its wrapped continuation under the content. The decoration is
145
+ // a line decoration too, so it enters at line.from after the depth and caret-block lines
146
+ // and before any mark decoration on the same row; inside a container it composes with the
147
+ // gutter padding. A fence or leaf machinery line is never a quote or list, so this only
148
+ // fires on prose and content rows.
149
+ if (kind === null) {
150
+ const prefix = markerPrefix(line.text);
151
+ if (prefix) builder.add(line.from, line.from, hangLine(prefix.length));
152
+ }
153
+ // Mark decorations start at offsets past line.from, so they enter after every line
154
+ // decoration on the row.
155
+ if (kind === 'fence' && depth > 0) {
104
156
  for (const token of fenceTokens(line.text)) {
105
157
  builder.add(
106
158
  line.from + token.from,
@@ -108,9 +160,7 @@ function buildDirectiveDecorations(view: EditorView, scan: FenceScan): Decoratio
108
160
  token.kind === 'mark' ? fenceMark : fenceLabels[depth - 1],
109
161
  );
110
162
  }
111
- } else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
112
- else if (kind === null) {
113
- if (depth > 0) builder.add(line.from, line.from, contentLines[depth - 1]);
163
+ } else if (kind === null) {
114
164
  for (const r of findInlineDirectives(line.text)) {
115
165
  builder.add(line.from + r.from, line.from + r.to, inlineMark);
116
166
  }
@@ -0,0 +1,42 @@
1
+ // The one keyboard-shortcut table, the single source the shortcuts sheet (ShortcutsDialog) and the
2
+ // Markdown help dialog both render. Order and content follow the gold-standard mockup's screen 5
3
+ // (docs/internal/design/2026-06-12-editor-shell-gold-standard.html), one change: Write / Preview
4
+ // shows Ctrl Alt P, the binding actually wired in EditPage (Firefox reserves Ctrl Shift P for
5
+ // private browsing at the browser level, below preventDefault), and the fold pair the editor wires
6
+ // in editor-folding.ts joins the editor-structure rows. The keys read with literal "Ctrl" to match
7
+ // the toolbar tooltips, which never localize to Cmd.
8
+
9
+ /** One shortcut row: a human label and the chord that triggers it. */
10
+ export type ShortcutRow = { label: string; keys: string };
11
+
12
+ /**
13
+ * The shortcut vocabulary, in the mockup's reading order. Each entry is verified against the
14
+ * handler that implements it: Save / Publish / Details panel / Zen / Write-Preview / Focus mode and
15
+ * This sheet ride EditPage's window keydown; Bold / Italic / Inline code / Web link / the heading
16
+ * pair / Quote / the list pair ride EditPage's card keydown; Fold / unfold ride editor-folding's
17
+ * CodeMirror keymap; the command palette rides AdminLayout's Ctrl K; Continue list / quote is the
18
+ * built-in markdown keymap on Enter.
19
+ */
20
+ export const editorShortcuts: ShortcutRow[] = [
21
+ { label: 'Save', keys: 'Ctrl S' },
22
+ { label: 'Bold', keys: 'Ctrl B' },
23
+ { label: 'Publish', keys: 'Ctrl Shift S' },
24
+ { label: 'Italic', keys: 'Ctrl I' },
25
+ { label: 'Details panel', keys: 'Ctrl .' },
26
+ { label: 'Web link', keys: 'Ctrl K' },
27
+ { label: 'Zen', keys: 'Ctrl Shift .' },
28
+ { label: 'Inline code', keys: 'Ctrl E' },
29
+ { label: 'Write / Preview', keys: 'Ctrl Alt P' },
30
+ { label: 'Heading / smaller', keys: 'Ctrl Alt 2 / 3' },
31
+ { label: 'Focus mode', keys: 'Ctrl Shift F' },
32
+ { label: 'Quote', keys: 'Ctrl Shift 9' },
33
+ { label: 'Command palette', keys: 'Ctrl K (global)' },
34
+ { label: 'Bulleted / numbered list', keys: 'Ctrl Shift 8 / 7' },
35
+ { label: 'Fold / unfold', keys: 'Ctrl Shift [ / ]' },
36
+ { label: 'This sheet', keys: 'Ctrl /' },
37
+ { label: 'Continue list / quote', keys: 'Enter' },
38
+ ];
39
+
40
+ /** The closing reassurance under the grid: the keys never gate the markdown that authors type. */
41
+ export const shortcutsClosingLine =
42
+ 'Typing markdown always works; the keys are conveniences, never requirements.';
@@ -124,6 +124,32 @@ export function caretContainerRange(scan: FenceScan, caretLine: number): Contain
124
124
  return { fromLine, toLine, depth };
125
125
  }
126
126
 
127
+ /**
128
+ * Every paired directive container in the document, as inclusive line ranges. Walks the scan's
129
+ * roles with a stack: an opener pushes its line, a closer pops the nearest open one and emits the
130
+ * pair at the opener's depth. The ranges come back in close order, so an inner container precedes
131
+ * the outer that holds it, which is exactly the order a fold consumer wants (folding the innermost
132
+ * first). An unbalanced opener is left on the stack and never emitted (a half-typed fence earns no
133
+ * fold range, the safety invariant), and a stray closer with nothing open is dropped. The scan
134
+ * already disowns a fence-shaped line inside a code block (its role is null), so a documented
135
+ * example can neither open nor close a range. This is the sole source of fold ranges.
136
+ */
137
+ export function containerRanges(scan: FenceScan): ContainerRange[] {
138
+ const { depths, roles } = scan;
139
+ const out: ContainerRange[] = [];
140
+ const stack: number[] = [];
141
+ for (let i = 0; i < roles.length; i++) {
142
+ if (roles[i] === 'opener') {
143
+ stack.push(i);
144
+ } else if (roles[i] === 'closer') {
145
+ const fromLine = stack.pop();
146
+ if (fromLine === undefined) continue;
147
+ out.push({ fromLine, toLine: i, depth: depths[fromLine] ?? 1 });
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
127
153
  /** One span of a fence line, in line-local offsets: machinery (`mark`) or meaning (`label`). */
128
154
  export interface FenceToken {
129
155
  from: number;
@@ -163,6 +189,22 @@ export function fenceTokens(line: string): FenceToken[] {
163
189
  return out;
164
190
  }
165
191
 
192
+ // The marker prefix of a quote or list line: leading indentation, the marker itself, and the one
193
+ // space after it. A task checkbox (`[ ]`/`[x]`) extends a bullet's marker. Ordered markers vary in
194
+ // width (a two-digit number is wider than a bullet), so the width is read from the match, never
195
+ // assumed. The anchored alternatives mirror the markers the highlight pass styles.
196
+ const MARKER = /^(\s*)(?:[-*+](?: \[[ xX]\])?|\d+[.)]|>) /;
197
+
198
+ /**
199
+ * The marker prefix of a line (indentation plus marker plus its trailing space), or null when the
200
+ * line carries no quote or list marker. The hanging-indent decoration uses the prefix length, in
201
+ * fixed-pitch chars, to wrap continuation lines under the content rather than under the marker.
202
+ */
203
+ export function markerPrefix(line: string): string | null {
204
+ const m = MARKER.exec(line);
205
+ return m ? m[0] : null;
206
+ }
207
+
166
208
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
167
209
  export function findInlineDirectives(text: string): { from: number; to: number }[] {
168
210
  const out: { from: number; to: number }[] = [];
@@ -0,0 +1,30 @@
1
+ import { getContext, setContext, type Snippet } from 'svelte';
2
+
3
+ // The topbar context portal. On an edit route the document owns the band's live state (status,
4
+ // save-state, the lifecycle actions), so the desk's controls have to render up in AdminLayout's
5
+ // one topbar rather than in a second header of their own. AdminLayout owns a holder and provides
6
+ // it; EditPage, a descendant through the children snippet, registers its desk snippet into the
7
+ // holder and clears it on teardown. CairnAdmin's view switch unmounts EditPage, which nulls the
8
+ // holder, so the band reverts to the office layout with no route plumbing.
9
+
10
+ const TOPBAR_CONTEXT_KEY = Symbol('cairn-topbar');
11
+
12
+ /** The shared holder: the desk snippet a document registers, or null on the office routes. */
13
+ export interface TopbarHolder {
14
+ desk: Snippet | null;
15
+ /** True while the document is in zen: AdminLayout drops the whole topbar element so the band
16
+ * slides away (the desk's three clusters include AdminLayout-owned chrome, the drawer toggle and
17
+ * breadcrumb, that must vanish with it). EditPage sets this; the office routes leave it false. */
18
+ zen: boolean;
19
+ }
20
+
21
+ /** Called by AdminLayout once: creates the holder, provides it on context, returns it to render. */
22
+ export function provideTopbar(holder: TopbarHolder): TopbarHolder {
23
+ setContext(TOPBAR_CONTEXT_KEY, holder);
24
+ return holder;
25
+ }
26
+
27
+ /** Called by a descendant document (EditPage) to reach the holder it registers its desk into. */
28
+ export function useTopbar(): TopbarHolder | undefined {
29
+ return getContext<TopbarHolder | undefined>(TOPBAR_CONTEXT_KEY);
30
+ }
@@ -4,6 +4,7 @@
4
4
  // it; the save path patches one entry and commits it with the content in one commit. Each entry
5
5
  // carries its identity and its outbound cairn: edges, so the manifest is the link graph.
6
6
  import { parseMarkdown } from './frontmatter.js';
7
+ import { deriveExcerpt } from './excerpt.js';
7
8
  import { entryIdentity, asString } from './identity.js';
8
9
  import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
9
10
  import type { ConceptDescriptor } from './types.js';
@@ -15,6 +16,7 @@ export interface ManifestEntry {
15
16
  title: string;
16
17
  date?: string;
17
18
  permalink: string;
19
+ summary?: string;
18
20
  draft: boolean;
19
21
  links: CairnRef[];
20
22
  }
@@ -47,6 +49,9 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
47
49
  title: asString(frontmatter.title) ?? id,
48
50
  date,
49
51
  permalink,
52
+ // Coalesce an empty excerpt to undefined, so an empty-body entry carries no summary key at all
53
+ // (matching serialize's optional-spread) and the in-memory and serialized shapes agree.
54
+ summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
50
55
  draft: frontmatter.draft === true,
51
56
  links: extractCairnLinks(body),
52
57
  };
@@ -70,6 +75,7 @@ export function serializeManifest(manifest: Manifest): string {
70
75
  title: e.title,
71
76
  ...(e.date ? { date: e.date } : {}),
72
77
  permalink: e.permalink,
78
+ ...(e.summary ? { summary: e.summary } : {}),
73
79
  draft: e.draft,
74
80
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
75
81
  }));
@@ -102,6 +108,7 @@ export function parseManifest(raw: string): Manifest {
102
108
  typeof e.permalink === 'string' &&
103
109
  typeof e.draft === 'boolean' &&
104
110
  (e.date === undefined || typeof e.date === 'string') &&
111
+ (e.summary === undefined || typeof e.summary === 'string') &&
105
112
  Array.isArray(e.links);
106
113
  if (!ok) {
107
114
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
@@ -4,7 +4,7 @@
4
4
  // every operation reads the descriptor and its routing rule, never a hardcoded concept id.
5
5
  import { parseMarkdown } from '../content/frontmatter.js';
6
6
  import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
7
- import { deriveExcerpt, wordCount } from './excerpt.js';
7
+ import { deriveExcerpt, wordCount } from '../content/excerpt.js';
8
8
  import type { ConceptDescriptor } from '../content/types.js';
9
9
 
10
10
  /** A raw content file before parsing: the glob key and the file's full markdown text. */
@@ -8,7 +8,7 @@ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
8
8
  export { createSiteIndexes } from './site-indexes.js';
9
9
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
10
10
  export { siteDescriptors } from './site-descriptors.js';
11
- export { deriveExcerpt, wordCount } from './excerpt.js';
11
+ export { deriveExcerpt, wordCount } from '../content/excerpt.js';
12
12
  export { buildRssFeed, buildJsonFeed } from './feeds.js';
13
13
  export type { FeedChannel, FeedItem } from './feeds.js';
14
14
  export { buildSitemap } from './sitemap.js';