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