@glw907/cairn-cms 0.52.1 → 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 (45) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/components/AdminLayout.svelte +58 -23
  3. package/dist/components/EditPage.svelte +456 -124
  4. package/dist/components/EditPage.svelte.d.ts +4 -2
  5. package/dist/components/EditorToolbar.svelte +29 -53
  6. package/dist/components/EditorToolbar.svelte.d.ts +3 -11
  7. package/dist/components/MarkdownEditor.svelte +163 -24
  8. package/dist/components/MarkdownEditor.svelte.d.ts +3 -0
  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 +199 -99
  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/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
  21. package/dist/components/fonts/ibm-plex-sans.woff2 +0 -0
  22. package/dist/components/markdown-directives.d.ts +17 -0
  23. package/dist/components/markdown-directives.js +41 -0
  24. package/dist/components/topbar-context.d.ts +13 -0
  25. package/dist/components/topbar-context.js +17 -0
  26. package/dist/sveltekit/static-admin-page.js +2 -2
  27. package/package.json +1 -1
  28. package/src/lib/components/AdminLayout.svelte +58 -23
  29. package/src/lib/components/EditPage.svelte +456 -124
  30. package/src/lib/components/EditorToolbar.svelte +29 -53
  31. package/src/lib/components/MarkdownEditor.svelte +163 -24
  32. package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
  33. package/src/lib/components/ShortcutsDialog.svelte +37 -0
  34. package/src/lib/components/ShortcutsGrid.svelte +18 -0
  35. package/src/lib/components/cairn-admin.css +51 -14
  36. package/src/lib/components/editor-folding.ts +356 -0
  37. package/src/lib/components/editor-highlight.ts +54 -4
  38. package/src/lib/components/editor-shortcuts.ts +42 -0
  39. package/src/lib/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
  40. package/src/lib/components/fonts/ibm-plex-sans.woff2 +0 -0
  41. package/src/lib/components/markdown-directives.ts +42 -0
  42. package/src/lib/components/topbar-context.ts +30 -0
  43. package/src/lib/sveltekit/static-admin-page.ts +2 -2
  44. package/dist/components/fonts/figtree.woff2 +0 -0
  45. package/src/lib/components/fonts/figtree.woff2 +0 -0
@@ -1,4 +1,4 @@
1
- /* Cairn's own typefaces, self-hosted so the admin needs no external font request. Figtree carries
1
+ /* Cairn's own typefaces, self-hosted so the admin needs no external font request. IBM Plex Sans carries
2
2
  the body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and
3
3
  the page headings a distinct voice; iA Writer Mono is the editor writing surface. The first two are
4
4
  variable (one file spans the weight range), the editor face ships four static files, and all three
@@ -10,7 +10,7 @@
10
10
  of use, so this fully overrides the host's theme with no @plugin and no host build step. */
11
11
  [data-theme='cairn-admin'] {
12
12
  color-scheme: light;
13
- --font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
13
+ --font-body: 'IBM Plex Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
14
14
  --font-display: 'Bricolage Grotesque Variable', var(--font-body);
15
15
  --font-editor: 'iA Writer Mono', ui-monospace, monospace;
16
16
  font-family: var(--font-body);
@@ -47,8 +47,9 @@
47
47
  --cairn-directive-rail-2: 82%;
48
48
  --cairn-directive-rail-3: 92%;
49
49
  /* Cursor-aware emphasis: the caret container's own bar steps to a full-strength accent rail
50
- (also 1px wider) and an ink one notch past depth 3. Locked pairs on base-100: rail = solid
51
- accent (5.28:1, non-text floor 3:1), ink 46% (7.47:1). Do not lighten without re-checking. */
50
+ at the same 2px width (strength only) and an ink one notch past depth 3. Locked pairs on
51
+ base-100: rail = solid accent (5.28:1, non-text floor 3:1), ink 46% (7.47:1). Do not
52
+ lighten without re-checking. */
52
53
  --cairn-directive-rail-active: 100%;
53
54
  --cairn-directive-ink-active: oklch(46% 0.16 300);
54
55
  /* The editor's inline-code chip, a quiet tint beside base-200. Locked pair: base-content ink
@@ -63,6 +64,14 @@
63
64
  ever sits on; on the chips it measured as low as 2.63:1. Do not lighten without
64
65
  re-checking. */
65
66
  --cairn-focus-dim-ink: oklch(66% 0.01 75);
67
+ /* Focus mode's rail percentages: the directive bars dim with their text (about a third of the
68
+ quiet 72/82/92 ramp), so a dimmed block keeps its structure without staying the one
69
+ chromatic object in the field. Deliberately sub-3:1, the same transient-state call as the
70
+ dim ink; the quiet ramp is one toggle away. */
71
+ --cairn-focus-dim-rail-1: 24%;
72
+ --cairn-focus-dim-rail-2: 28%;
73
+ --cairn-focus-dim-rail-3: 32%;
74
+ --cairn-focus-dim-rail-active: 36%;
66
75
  --color-neutral: oklch(32% 0.012 75);
67
76
  --color-neutral-content: oklch(96% 0.004 75);
68
77
 
@@ -103,7 +112,7 @@
103
112
  >= 4.5:1 contrast on the dark bases. The polish pass tunes these values against the reference. */
104
113
  [data-theme='cairn-admin-dark'] {
105
114
  color-scheme: dark;
106
- --font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
115
+ --font-body: 'IBM Plex Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
107
116
  --font-display: 'Bricolage Grotesque Variable', var(--font-body);
108
117
  --font-editor: 'iA Writer Mono', ui-monospace, monospace;
109
118
  font-family: var(--font-body);
@@ -119,6 +128,9 @@
119
128
  --color-base-300: oklch(30% 0.014 75);
120
129
  --color-base-content: oklch(93% 0.006 75);
121
130
 
131
+ /* Locked pair: pressed footer-toggle text (text-primary on the bg-primary/10 plate over
132
+ dark base-100) measures 4.66:1, only 0.16 above the AA floor. Do not darken dark
133
+ --color-primary without re-checking that pair. */
122
134
  --color-primary: oklch(68% 0.18 293);
123
135
  --color-primary-content: oklch(20% 0.04 293);
124
136
  --color-secondary: oklch(72% 0.02 75);
@@ -139,10 +151,10 @@
139
151
  --cairn-directive-rail-2: 74%;
140
152
  --cairn-directive-rail-3: 86%;
141
153
  /* Cursor-aware emphasis on dark: the caret container's own bar steps to a solid accent rail
142
- (also 1px wider) and an ink one notch past depth 3. Locked pairs on base-100: rail = solid
143
- accent (5.86:1, non-text floor 3:1), ink 82% (8.84:1, priced on the gamut-clipped sRGB
144
- render; the 0.14 chroma sits outside sRGB at this lightness). Do not darken without
145
- re-checking. */
154
+ at the same 2px width (strength only) and an ink one notch past depth 3. Locked pairs on
155
+ base-100: rail = solid accent (5.86:1, non-text floor 3:1), ink 82% (8.84:1, priced on the
156
+ gamut-clipped sRGB render; the 0.14 chroma sits outside sRGB at this lightness). Do not
157
+ darken without re-checking. */
146
158
  --cairn-directive-rail-active: 100%;
147
159
  --cairn-directive-ink-active: oklch(82% 0.14 300);
148
160
  /* The inline-code chip on dark sits one step above base-100 so it reads raised. Locked pair:
@@ -153,6 +165,12 @@
153
165
  the proposed 50% measured 2.74:1, under the 3:1 floor, so it sits at 53%. Locked pair on
154
166
  base-100: 3.12:1. Do not darken without re-checking. */
155
167
  --cairn-focus-dim-ink: oklch(53% 0.01 75);
168
+ /* Focus mode's rail percentages on dark, a third of the quiet 62/74/86 ramp; the same
169
+ deliberate sub-3:1 transient-state call as the dim ink. */
170
+ --cairn-focus-dim-rail-1: 21%;
171
+ --cairn-focus-dim-rail-2: 25%;
172
+ --cairn-focus-dim-rail-3: 29%;
173
+ --cairn-focus-dim-rail-active: 33%;
156
174
  --color-neutral: oklch(80% 0.01 75);
157
175
  --color-neutral-content: oklch(22% 0.008 75);
158
176
 
@@ -260,6 +278,13 @@
260
278
  outline-offset: -1px;
261
279
  }
262
280
 
281
+ /* Under focus mode the document title eases back with the rest of the context: at 30px bold
282
+ it would otherwise be the strongest ink on the page and pull the eye off the lit
283
+ paragraph. Its own focus restores full ink, so editing the title is never dimmed. */
284
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .cairn-doc-title-dim:not(:focus) {
285
+ color: var(--cairn-focus-dim-ink);
286
+ }
287
+
263
288
  /* Menu items come as anchors or buttons, and the omitted Preflight is what made them match:
264
289
  without it a button keeps the UA chrome (outset border, gray fill, centered system-font
265
290
  text) while its anchor siblings render flat. This scoped substitute levels the buttons to
@@ -274,12 +299,24 @@
274
299
  text-align: start;
275
300
  }
276
301
 
277
- /* The admin topbar plus the edit page's sticky action header stack about 120px of veil over the
278
- top of the content column, and a control the browser scrolls into view could land hidden
279
- beneath them (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or fragment
280
- scroll clear of the whole sticky stack. */
302
+ /* The same leveling for every bare admin button, not only menu items: a button styled with
303
+ utilities (the footer's posture segments and mode toggles, a list's sort-column headers, the
304
+ zen chip's exit) otherwise keeps the UA outset border and gray fill, since the admin omits
305
+ global Preflight. A .btn keeps its full DaisyUI chrome, and a utility (a bg-*, a border-l for
306
+ a segment divider) still wins from the utilities layer, so a control opts back into a border
307
+ or fill by naming it. */
308
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) button:not(.btn) {
309
+ border: 0 solid;
310
+ background-color: transparent;
311
+ font: inherit;
312
+ color: inherit;
313
+ }
314
+
315
+ /* The one header band is the 4rem topbar, and a control the browser scrolls into view could land
316
+ hidden beneath it (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or
317
+ fragment scroll clear of the sticky band, with a little slack. */
281
318
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :is(a, button, input, textarea, select, summary, [tabindex]) {
282
- scroll-margin-top: 8.5rem;
319
+ scroll-margin-top: 5.5rem;
283
320
  }
284
321
  }
285
322
 
@@ -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.';
@@ -1,4 +1,4 @@
1
- Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
1
+ Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved.
2
2
 
3
3
  This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
4
  This license is copied below, and is also available with a FAQ at:
@@ -18,7 +18,7 @@ with others.
18
18
 
19
19
  The OFL allows the licensed fonts to be used, studied, modified and
20
20
  redistributed freely as long as they are not sold by themselves. The
21
- fonts, including any derivative works, can be bundled, embedded,
21
+ fonts, including any derivative works, can be bundled, embedded,
22
22
  redistributed and/or sold with any software provided that any reserved
23
23
  names are not used by derivative works. The fonts and derivatives,
24
24
  however, cannot be released under any other type of license. The