@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
@@ -24,7 +24,8 @@ interface Props {
24
24
  * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
25
25
  * markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
26
26
  * `?/save` action. The title field is hoisted above the editor card as the document title; the
27
- * remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
27
+ * remaining fields group behind the Details slide-over (a fixed panel below the band, toggled from
28
+ * the band's Details trigger or Ctrl+.) under Details, Visibility (the draft boolean as the Hidden
28
29
  * toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
29
30
  * swap the editing surface for the rendered preview inside the same card; every visit lands on
30
31
  * Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
@@ -33,7 +34,8 @@ interface Props {
33
34
  * carries the breadcrumb, the status badges, the save-state indicator,
34
35
  * and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
35
36
  * pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
36
- * transient flashes, and the editor card's footer holds the word count and the Markdown help.
37
+ * transient flashes, and the editor card's footer is the writing-environment strip: the word
38
+ * count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
37
39
  */
38
40
  declare const EditPage: import("svelte").Component<Props, {}, "">;
39
41
  type EditPage = ReturnType<typeof EditPage>;
@@ -5,9 +5,9 @@ More overflow menu, then the host's Insert controls) and the Write/Preview segme
5
5
  right. Format buttons ask the host to transform the editor's current selection; the host supplies the
6
6
  Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
7
7
  Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
8
- widths, reported to the host through `onDevice`. The More menu also carries the host's persisted
9
- writing-mode toggles (focus mode, typewriter scrolling) as pressed-state buttons above the format picks. The glyphs are stroke SVG icons in the admin's
10
- house style (24x24 viewBox, `currentColor`, round caps).
8
+ widths, reported to the host through `onDevice`. The writing-mode toggles live in the host's card
9
+ footer (the bottom strip carries the writing environment; this strip acts on the text). The glyphs
10
+ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
11
11
  -->
12
12
  <script lang="ts">
13
13
  import type { Snippet } from 'svelte';
@@ -26,14 +26,6 @@ house style (24x24 viewBox, `currentColor`, round caps).
26
26
  /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
27
27
  * while Preview shows. */
28
28
  onDevice?: (id: PreviewDeviceId) => void;
29
- /** Whether focus mode is on; the More menu's toggle reflects it. */
30
- focusMode?: boolean;
31
- /** Flip focus mode. When set, the toggle joins the More menu. */
32
- onFocusMode?: (on: boolean) => void;
33
- /** Whether typewriter scrolling is on; the More menu's toggle reflects it. */
34
- typewriter?: boolean;
35
- /** Flip typewriter scrolling. When set, the toggle joins the More menu. */
36
- onTypewriter?: (on: boolean) => void;
37
29
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
38
30
  insertControls?: Snippet;
39
31
  }
@@ -44,10 +36,6 @@ house style (24x24 viewBox, `currentColor`, round caps).
44
36
  onMode,
45
37
  device = 'desktop',
46
38
  onDevice,
47
- focusMode = false,
48
- onFocusMode,
49
- typewriter = false,
50
- onTypewriter,
51
39
  insertControls,
52
40
  }: Props = $props();
53
41
 
@@ -65,12 +53,12 @@ house style (24x24 viewBox, `currentColor`, round caps).
65
53
  const structureButtons: ToolButton[] = [
66
54
  {
67
55
  kind: 'h2',
68
- label: 'Heading',
56
+ label: 'Heading (Ctrl+Alt+2)',
69
57
  paths: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1'],
70
58
  },
71
59
  {
72
60
  kind: 'h3',
73
- label: 'Smaller heading',
61
+ label: 'Smaller heading (Ctrl+Alt+3)',
74
62
  paths: [
75
63
  'M4 12h8',
76
64
  'M4 18V6',
@@ -79,32 +67,45 @@ house style (24x24 viewBox, `currentColor`, round caps).
79
67
  'M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2',
80
68
  ],
81
69
  },
82
- { kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
70
+ { kind: 'ul', label: 'Bulleted list (Ctrl+Shift+8)', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
83
71
  {
84
72
  kind: 'ol',
85
- label: 'Numbered list',
73
+ label: 'Numbered list (Ctrl+Shift+7)',
86
74
  paths: ['M10 12h11', 'M10 18h11', 'M10 6h11', 'M4 10h2', 'M4 6h1v4', 'M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'],
87
75
  },
88
76
  {
89
77
  kind: 'quote',
90
- label: 'Quote',
78
+ label: 'Quote (Ctrl+Shift+9)',
91
79
  paths: [
92
80
  'M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
93
81
  'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
94
82
  ],
95
83
  },
84
+ // The everyday formats promoted out of the More menu onto the strip (after Quote, before
85
+ // More), per mockup screen 1. They keep the strip's glyph grammar.
86
+ { kind: 'code', label: 'Inline code (Ctrl+E)', paths: ['m9 8-4 4 4 4', 'm15 8 4 4-4 4'] },
87
+ {
88
+ kind: 'strike',
89
+ label: 'Strikethrough',
90
+ paths: ['M14 12a4 4 0 0 1 0 8H8', 'M16 4H9.5a3.5 3.5 0 0 0-1.4 6.7', 'M4 12h16'],
91
+ },
92
+ {
93
+ kind: 'table',
94
+ label: 'Table',
95
+ paths: ['M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', 'M3 10h18', 'M10 3v18'],
96
+ },
96
97
  ];
97
98
 
98
99
  const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
99
100
  // The check glyph marking an active pick, shared by the More menu's toggles and the device list.
100
101
  const checkPaths = ['M20 6 9 17l-5-5'];
101
102
 
102
- const moreItems: { kind: FormatKind; label: string }[] = [
103
- { kind: 'strike', label: 'Strikethrough' },
104
- { kind: 'code', label: 'Inline code' },
103
+ // The trimmed overflow: the block formats that stay rare. A divider splits the code block from
104
+ // the rest (the spec keeps "code block and the rest" behind the ellipsis once inline code,
105
+ // strikethrough, and table promote into the strip).
106
+ const moreItems: { kind: FormatKind; label: string; divideBefore?: boolean }[] = [
105
107
  { kind: 'codeblock', label: 'Code block' },
106
- { kind: 'table', label: 'Table' },
107
- { kind: 'hr', label: 'Horizontal rule' },
108
+ { kind: 'hr', label: 'Horizontal rule', divideBefore: true },
108
109
  { kind: 'task', label: 'Task list' },
109
110
  ];
110
111
 
@@ -265,35 +266,10 @@ house style (24x24 viewBox, `currentColor`, round caps).
265
266
  ontoggle={(e) => (moreOpen = e.newState === 'open')}
266
267
  class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
267
268
  >
268
- <!-- The writing modes sit above the format items behind a hairline, persisted by the host.
269
- The device list's idiom: plain buttons with aria-pressed carrying the on/off state (this
270
- popover list is not an ARIA menu, so a menuitemcheckbox would sit in an invalid context);
271
- the check glyph mirrors the state visually. A flip leaves the menu open so the new
272
- pressed state is perceivable in place; only a format pick dismisses it. -->
273
- {#if onFocusMode}
274
- <li>
275
- <button type="button" aria-pressed={focusMode} onclick={() => onFocusMode(!focusMode)}>
276
- <span class="grow">Focus mode</span>
277
- {#if focusMode}
278
- {@render strokeIcon(checkPaths)}
279
- {/if}
280
- </button>
281
- </li>
282
- {/if}
283
- {#if onTypewriter}
284
- <li>
285
- <button type="button" aria-pressed={typewriter} onclick={() => onTypewriter(!typewriter)}>
286
- <span class="grow">Typewriter scrolling</span>
287
- {#if typewriter}
288
- {@render strokeIcon(checkPaths)}
289
- {/if}
290
- </button>
291
- </li>
292
- {/if}
293
- {#if onFocusMode || onTypewriter}
294
- <li class="my-1 border-t border-[var(--cairn-card-border)]" role="separator"></li>
295
- {/if}
296
269
  {#each moreItems as item (item.kind)}
270
+ {#if item.divideBefore}
271
+ <li class="menu-divider my-1 h-px bg-[var(--cairn-card-border)]" role="separator" aria-hidden="true"></li>
272
+ {/if}
297
273
  <li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
298
274
  {/each}
299
275
  </ul>
@@ -13,14 +13,6 @@ interface Props {
13
13
  /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
14
14
  * while Preview shows. */
15
15
  onDevice?: (id: PreviewDeviceId) => void;
16
- /** Whether focus mode is on; the More menu's toggle reflects it. */
17
- focusMode?: boolean;
18
- /** Flip focus mode. When set, the toggle joins the More menu. */
19
- onFocusMode?: (on: boolean) => void;
20
- /** Whether typewriter scrolling is on; the More menu's toggle reflects it. */
21
- typewriter?: boolean;
22
- /** Flip typewriter scrolling. When set, the toggle joins the More menu. */
23
- onTypewriter?: (on: boolean) => void;
24
16
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
25
17
  insertControls?: Snippet;
26
18
  }
@@ -30,9 +22,9 @@ interface Props {
30
22
  * right. Format buttons ask the host to transform the editor's current selection; the host supplies the
31
23
  * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
32
24
  * Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
33
- * widths, reported to the host through `onDevice`. The More menu also carries the host's persisted
34
- * writing-mode toggles (focus mode, typewriter scrolling) as pressed-state buttons above the format picks. The glyphs are stroke SVG icons in the admin's
35
- * house style (24x24 viewBox, `currentColor`, round caps).
25
+ * widths, reported to the host through `onDevice`. The writing-mode toggles live in the host's card
26
+ * footer (the bottom strip carries the writing environment; this strip acts on the text). The glyphs
27
+ * are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
36
28
  */
37
29
  declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
38
30
  type EditorToolbar = ReturnType<typeof EditorToolbar>;
@@ -31,6 +31,9 @@ through the adapter's render. Swapping the editor stays a one-file change.
31
31
  focusMode?: boolean;
32
32
  /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
33
33
  typewriter?: boolean;
34
+ /** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
35
+ * leading); markup is the working surface (fills the card, denser). Prose by default. */
36
+ surface?: 'prose' | 'markup';
34
37
  }
35
38
 
36
39
  let {
@@ -43,6 +46,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
43
46
  completionSources = [],
44
47
  focusMode = false,
45
48
  typewriter = false,
49
+ surface = 'prose',
46
50
  }: Props = $props();
47
51
 
48
52
  let host = $state<HTMLDivElement | null>(null);
@@ -57,6 +61,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
57
61
  let modes: typeof import('./editor-modes.js') | null = null;
58
62
  let focusCompartment: import('@codemirror/state').Compartment | null = null;
59
63
  let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
64
+ let surfaceCompartment: import('@codemirror/state').Compartment | null = null;
65
+ // The posture themes, swapped through the surface compartment. Each owns its type step and
66
+ // leading (the base theme deliberately sets neither on the content node, so the postures never
67
+ // contest it on adoption order). Built in onMount beside the base theme.
68
+ let proseTheme: import('@codemirror/state').Extension | null = null;
69
+ let markupTheme: import('@codemirror/state').Extension | null = null;
60
70
 
61
71
  onMount(async () => {
62
72
  const viewMod = await import('@codemirror/view');
@@ -67,6 +77,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
67
77
  const autocompleteMod = await import('@codemirror/autocomplete');
68
78
  const highlightMod = await import('./editor-highlight.js');
69
79
  const modesMod = await import('./editor-modes.js');
80
+ const foldingMod = await import('./editor-folding.js');
70
81
 
71
82
  if (!host) return;
72
83
 
@@ -75,9 +86,9 @@ through the adapter's render. Swapping the editor stays a one-file change.
75
86
  // tooltip above all) renders dark-on-dark instead of light-on-dark.
76
87
  const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
77
88
  // The directive machinery treatment: rails, not bands. A row at depth N draws every rail
78
- // 1..N as literal nested brackets: 2px accent bars at x offsets 0-2, 6-8, and 12-14 with 4px
79
- // of surface between them (a gap of twice the bar weight, the floor for two parallel rules
80
- // to read as separate lines rather than one thick one), stacked as inset box shadows (top
89
+ // 1..N as literal nested brackets: 2px accent bars on an 8px pitch (x offsets 0-2, 8-10,
90
+ // and 16-18) with 6px of surface between them (three times the bar weight, so nested bars
91
+ // separate cleanly instead of reading as one thick rule), stacked as inset box shadows (top
81
92
  // layer first, so each bar sits over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
82
93
  // cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
83
94
  // outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
@@ -87,21 +98,25 @@ through the adapter's render. Swapping the editor stays a one-file change.
87
98
  const railFallbacks = ['72%', '82%', '92%'];
88
99
  const railColor = (step: number | 'active', fallback: string) =>
89
100
  `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
90
- // With `active`, the row's own (deepest) bar takes the full-strength -active mix and widens
91
- // 1px, so the caret's container reads at a glance; a bar-width change shifts no text.
92
- const rails = (depth: number, active = false): string => {
101
+ // With `active`, the row's own (deepest) bar takes the full-strength -active mix at the same
102
+ // 2px width. The emphasis is strength only: a rail column carrying both an active and a
103
+ // quiet segment (two sibling containers at one depth) keeps one weight top to bottom.
104
+ // With `dropInnermost`, the row's own deepest bar is omitted: a fold chevron replaces it on a
105
+ // paired opener row, so the bar would double the chevron's positional cue. The outer bars and
106
+ // their spacers stay, so the nesting still reads.
107
+ const rails = (depth: number, active = false, dropInnermost = false): string => {
93
108
  const layers: string[] = [];
94
109
  for (let d = 1; d <= depth; d++) {
95
- const edge = 6 * d - 4;
110
+ const edge = 8 * d - 6;
96
111
  if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
112
+ if (dropInnermost && d === depth) continue;
97
113
  const own = active && d === depth;
98
114
  layers.push(
99
- own
100
- ? `inset ${edge + 1}px 0 0 0 ${railColor('active', '100%')}`
101
- : `inset ${edge}px 0 0 0 ${railColor(d, railFallbacks[d - 1] ?? '92%')}`,
115
+ `inset ${edge}px 0 0 0 ${own ? railColor('active', '100%') : railColor(d, railFallbacks[d - 1] ?? '92%')}`,
102
116
  );
103
117
  }
104
- return layers.join(', ');
118
+ // A depth-1 opener drops its only bar, so the row paints no rail at all.
119
+ return layers.length ? layers.join(', ') : 'none';
105
120
  };
106
121
  const directiveInk = {
107
122
  backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
@@ -117,23 +132,35 @@ through the adapter's render. Swapping the editor stays a one-file change.
117
132
  `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
118
133
  railRules[row('')] = { boxShadow: rails(depth) };
119
134
  railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
135
+ // A paired opener row drops its own innermost bar (the fold chevron stands in its place),
136
+ // both quiet and caret-active. The extra opener class outranks the base fence rule above.
137
+ railRules[`.cm-cairn-directive-fence.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
138
+ boxShadow: rails(depth, false, true),
139
+ };
140
+ railRules[`.cm-cairn-caret-block.cm-cairn-directive-opener.cm-cairn-depth-${depth}`] = {
141
+ boxShadow: rails(depth, true, true),
142
+ };
120
143
  }
121
144
  const theme = EditorView.theme(
122
145
  {
123
146
  '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
124
- // The 50vh floor keeps a short entry reading as a writing surface, and because the
125
- // contenteditable content area carries the height, a click in the empty space below the
126
- // text still lands in the editor and focuses it. The 70ch cap with auto margins holds
127
- // the manuscript to a readable measure, centered in whatever width the card gives it.
147
+ // The 60vh floor keeps the surface reading as the page's center stage even when the
148
+ // entry is short, and because the contenteditable content area carries the height, a
149
+ // click in the empty space below the text still lands in the editor and focuses it.
150
+ // No inner measure cap: the surface fills the card the way a code editor fills its
151
+ // pane, and the card's own width (the host caps it near 89ch of this face) is the one
152
+ // constraint. The surface carries tables, attributed directives, and long URLs, so the
153
+ // ceiling leans toward the code-editor end of the ergonomic band rather than the
154
+ // long-form ideal; paragraphs wrap comfortably below it.
128
155
  '.cm-content': {
129
156
  // The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
130
157
  // fallback keeps the surface monospace outside an admin theme wrapper.
131
158
  fontFamily: "var(--font-editor, ui-monospace, monospace)",
132
- padding: '0.875rem 1.25rem',
133
- lineHeight: '1.8',
134
- minHeight: '50vh',
135
- maxWidth: '70ch',
136
- margin: '0 auto',
159
+ // Vertical padding holds at least one line-height of the body (1.8 x 1rem), with a
160
+ // touch more below than above (the optical center sits high); the sides then read as
161
+ // gutters rather than letterboxing.
162
+ padding: '2rem 1.25rem 2.5rem',
163
+ minHeight: '60vh',
137
164
  },
138
165
  '.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
139
166
  // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
@@ -146,10 +173,19 @@ through the adapter's render. Swapping the editor stays a one-file change.
146
173
  outlineOffset: '-1px',
147
174
  },
148
175
  '.cm-line': { padding: '0' },
176
+ // A quote or list line hangs its wrapped continuation under the content: padding-left
177
+ // holds the marker width (the --cairn-hang the decoration sets) and the line's own
178
+ // negative text-indent (set inline) pulls the first line back, so the marker sits in the
179
+ // indent. This rule sits before the gutter rule so a container content line, which
180
+ // carries both classes, takes the gutter-plus-hang rule below.
181
+ '.cm-cairn-hang': { paddingLeft: 'var(--cairn-hang, 0ch)' },
149
182
  // The gutter: directive rows pad left so the text clears the deepest rail stack (the
150
- // depth-3 bar ends at 14px, the active one at 15px; 1.5rem keeps ~10px of air beyond
151
- // it). Static structure (caret-independent), so caret movement shifts no layout.
152
- '.cm-cairn-directive-fence, .cm-cairn-directive-content': { paddingLeft: '1.5rem' },
183
+ // depth-3 bar ends at 18px; 1.75rem keeps 10px of air beyond it). Static structure
184
+ // (caret-independent), so caret movement shifts no layout. The --cairn-hang term composes
185
+ // a quote/list marker's hang on top of the gutter; it defaults to 0 on rows without one.
186
+ '.cm-cairn-directive-fence, .cm-cairn-directive-content': {
187
+ paddingLeft: 'calc(1.75rem + var(--cairn-hang, 0ch))',
188
+ },
153
189
  ...railRules,
154
190
  '.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
155
191
  '.cm-cairn-directive-label': { color: 'var(--color-accent)' },
@@ -163,6 +199,68 @@ through the adapter's render. Swapping the editor stays a one-file change.
163
199
  },
164
200
  '.cm-cairn-directive-leaf': directiveInk,
165
201
  '.cm-cairn-directive-inline': directiveInk,
202
+ // Container folding. The fold band is the 28px gutter click target on an opener row; the
203
+ // line is the positioning context so the chevron sits over the container's own bar x. The
204
+ // band is laid over the gutter (a zero-width inline widget at line start, expanded by the
205
+ // absolute children), so only the gutter shows the pointer cursor, never the opener text.
206
+ '.cm-line:has(.cm-cairn-fold-band)': { position: 'relative' },
207
+ '.cm-cairn-fold-band': {
208
+ position: 'absolute',
209
+ left: '0',
210
+ top: '0',
211
+ width: '28px',
212
+ height: '100%',
213
+ cursor: 'pointer',
214
+ zIndex: '1',
215
+ },
216
+ '.cm-cairn-fold-band svg': {
217
+ position: 'absolute',
218
+ top: '50%',
219
+ transform: 'translateY(-50%)',
220
+ width: '11px',
221
+ height: '11px',
222
+ // The chevron fades in on rail-band hover; folded and caret-inside states force it on.
223
+ opacity: '0',
224
+ transition: 'opacity 120ms ease',
225
+ color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
226
+ },
227
+ '.cm-cairn-fold-band:hover svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg': {
228
+ opacity: '1',
229
+ },
230
+ // The chevron steps its ink with the container's depth, matching the label inks; the
231
+ // caret-inside state takes the strongest ink.
232
+ '.cm-cairn-fold-depth-1 svg': { color: 'var(--color-accent)' },
233
+ '.cm-cairn-fold-depth-3 svg': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
234
+ '.cm-cairn-fold-active svg': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
235
+ // The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
236
+ // so folded spots read in a scan. The rails are inset box-shadows on the same line element
237
+ // and render above this background, so the rail column runs through the wash unbroken.
238
+ '.cm-cairn-folded-row': {
239
+ backgroundColor: 'color-mix(in oklab, var(--color-accent) 7%, transparent)',
240
+ },
241
+ // The fold pill: the placeholder widget and the screen-reader story, a real focusable
242
+ // button counting the hidden lines in accent ink. The 30% accent border lifts on hover.
243
+ '.cm-cairn-fold-pill': {
244
+ fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
245
+ fontSize: '0.6875rem',
246
+ color: 'var(--color-accent)',
247
+ border: '1px solid color-mix(in oklab, var(--color-accent) 30%, transparent)',
248
+ borderRadius: '0.375rem',
249
+ padding: '1px 7px',
250
+ marginLeft: '10px',
251
+ verticalAlign: '1px',
252
+ backgroundColor: 'var(--color-base-100)',
253
+ cursor: 'pointer',
254
+ },
255
+ '.cm-cairn-fold-pill:hover': {
256
+ borderColor: 'color-mix(in oklab, var(--color-accent) 60%, transparent)',
257
+ },
258
+ // The one-time unfold flash: a low-alpha accent background on the revealed lines, removed
259
+ // after the animation. The transition runs as the field clears the class.
260
+ '.cm-cairn-fold-flash': {
261
+ backgroundColor: 'color-mix(in oklab, var(--color-accent) 12%, transparent)',
262
+ transition: 'background-color 400ms ease',
263
+ },
166
264
  // Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
167
265
  // Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
168
266
  // the dim tone, and spec order breaks the specificity ties with the label rules above.
@@ -176,13 +274,47 @@ through the adapter's render. Swapping the editor stays a one-file change.
176
274
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
177
275
  backgroundColor: 'transparent',
178
276
  },
277
+ // The fold machinery dims with its row: a folded opener row under focus mode drops its
278
+ // chevron, pill, and wash to the dim tone like any other machinery line.
279
+ '.cm-cairn-focus-dim .cm-cairn-fold-band svg, .cm-cairn-focus-dim .cm-cairn-fold-pill': {
280
+ color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
281
+ },
282
+ '.cm-cairn-focus-dim.cm-cairn-folded-row': { backgroundColor: 'transparent' },
283
+ // The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
284
+ // element, so overriding the percentages on dimmed lines re-resolves every bar in place.
285
+ // Without this the directive block keeps full-strength bars and becomes the one
286
+ // chromatic object in the dimmed field. The active step needs the override too: focus
287
+ // mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
288
+ // container, so a container holding a blank line has dimmed rows that still carry the
289
+ // active rail.
290
+ '.cm-cairn-focus-dim': {
291
+ '--cairn-directive-rail-1': 'var(--cairn-focus-dim-rail-1, 24%)',
292
+ '--cairn-directive-rail-2': 'var(--cairn-focus-dim-rail-2, 28%)',
293
+ '--cairn-directive-rail-3': 'var(--cairn-focus-dim-rail-3, 32%)',
294
+ '--cairn-directive-rail-active': 'var(--cairn-focus-dim-rail-active, 36%)',
295
+ },
296
+ },
297
+ { dark: isDark },
298
+ );
299
+
300
+ // The prose posture: the writing instrument. A 72ch measure centered in the card, one type
301
+ // step up, looser leading. Markup posture (the base theme) keeps the dense fill for tables,
302
+ // directives, and long URLs. Placed after the base theme in the extension list, so its keys
303
+ // win the spec-order ties.
304
+ proseTheme = EditorView.theme(
305
+ {
306
+ // Scoped to the content node (not the editor root) so the base theme's root font-size
307
+ // never contests it, and so the 72ch measure resolves against the prose type step.
308
+ '.cm-content': { fontSize: '1.0625rem', lineHeight: '1.9', maxWidth: '72ch', margin: '0 auto' },
179
309
  },
180
310
  { dark: isDark },
181
311
  );
312
+ markupTheme = EditorView.theme({ '.cm-content': { lineHeight: '1.8' } }, { dark: isDark });
182
313
 
183
314
  modes = modesMod;
184
315
  focusCompartment = new stateMod.Compartment();
185
316
  typewriterCompartment = new stateMod.Compartment();
317
+ surfaceCompartment = new stateMod.Compartment();
186
318
 
187
319
  view = new EditorView({
188
320
  parent: host,
@@ -205,8 +337,13 @@ through the adapter's render. Swapping the editor stays a one-file change.
205
337
  EditorView.lineWrapping,
206
338
  languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
207
339
  highlightMod.cairnDirectivePlugin(),
340
+ // Container folding: the fold system, the chevron and wash affordance, and the safety
341
+ // invariant. Placed after the directive plugin so its chevron widget on an opener row
342
+ // composes with the row's rail and gutter; its keymap is internal to the extension.
343
+ foldingMod.cairnFolding(),
208
344
  EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
209
345
  theme,
346
+ surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
210
347
  EditorView.updateListener.of((update) => {
211
348
  if (update.docChanged) value = update.state.doc.toString();
212
349
  }),
@@ -239,11 +376,13 @@ through the adapter's render. Swapping the editor stays a one-file change.
239
376
  $effect(() => {
240
377
  const focus = focusMode;
241
378
  const typing = typewriter;
242
- if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment) return;
379
+ const posture = surface;
380
+ if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
243
381
  view.dispatch({
244
382
  effects: [
245
383
  focusCompartment.reconfigure(focus ? modes.focusMode() : []),
246
384
  typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
385
+ surfaceCompartment.reconfigure((posture === 'prose' ? proseTheme : markupTheme) ?? []),
247
386
  ],
248
387
  });
249
388
  });
@@ -19,6 +19,9 @@ interface Props {
19
19
  focusMode?: boolean;
20
20
  /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
21
21
  typewriter?: boolean;
22
+ /** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
23
+ * leading); markup is the working surface (fills the card, denser). Prose by default. */
24
+ surface?: 'prose' | 'markup';
22
25
  }
23
26
  /**
24
27
  * The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
@@ -6,6 +6,8 @@ Built on a native <dialog>, the DeleteDialog recipe; the host drives it through
6
6
  open(), so the component renders no trigger of its own.
7
7
  -->
8
8
  <script lang="ts">
9
+ import ShortcutsGrid from './ShortcutsGrid.svelte';
10
+
9
11
  let dialog = $state<HTMLDialogElement | null>(null);
10
12
 
11
13
  /** Open the cheat sheet. The trigger lives in the host (the edit page's editor footer). */
@@ -33,6 +35,7 @@ open(), so the component renders no trigger of its own.
33
35
  <tbody>
34
36
  <tr><td><code>## Heading</code></td><td>A heading</td></tr>
35
37
  <tr><td><code>### Heading</code></td><td>A smaller heading</td></tr>
38
+ <tr><td><code>#### Heading</code></td><td>A fourth-level heading</td></tr>
36
39
  <tr><td><code>**bold**</code></td><td>Bold text</td></tr>
37
40
  <tr><td><code>*italic*</code></td><td>Italic text</td></tr>
38
41
  <tr><td><code>~~text~~</code></td><td>Crossed-out text</td></tr>
@@ -47,6 +50,8 @@ open(), so the component renders no trigger of its own.
47
50
  <tr><td><code>---</code></td><td>A horizontal rule</td></tr>
48
51
  </tbody>
49
52
  </table>
53
+ <h3 class="mt-4 mb-2 text-sm font-semibold">Keyboard shortcuts</h3>
54
+ <ShortcutsGrid />
50
55
  <p class="mt-3 text-sm">
51
56
  Lines starting with <code>:::</code> are layout blocks. Edit the text inside them and leave
52
57
  the <code>:::</code> lines alone.
@@ -0,0 +1,37 @@
1
+ <!--
2
+ @component
3
+ The keyboard shortcuts sheet, the third discoverability surface (the toolbar tooltips and the
4
+ Markdown help dialog are the other two). A two-column grid pairs each label with its chord, with a
5
+ closing line that the keys are always conveniences. Built on a native <dialog>, the
6
+ MarkdownHelpDialog recipe; the host (the edit page) drives it through the exported open() and opens
7
+ it on Ctrl+/, so the component renders no trigger of its own. Esc dismisses through the dialog's
8
+ native behavior.
9
+ -->
10
+ <script lang="ts">
11
+ import { shortcutsClosingLine } from './editor-shortcuts.js';
12
+ import ShortcutsGrid from './ShortcutsGrid.svelte';
13
+
14
+ let dialog = $state<HTMLDialogElement | null>(null);
15
+
16
+ /** Open the shortcuts sheet. The trigger lives in the host (the edit page's Ctrl+/ handler). */
17
+ export function open() {
18
+ dialog?.showModal();
19
+ }
20
+ function close() {
21
+ dialog?.close();
22
+ }
23
+ </script>
24
+
25
+ <dialog class="modal" aria-labelledby="cairn-shortcuts-title" bind:this={dialog}>
26
+ <div class="modal-box">
27
+ <div class="mb-3 flex items-center justify-between">
28
+ <h2 id="cairn-shortcuts-title" class="text-base font-semibold">Keyboard shortcuts</h2>
29
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
30
+ </div>
31
+ <ShortcutsGrid />
32
+ <p class="mt-3 text-xs text-[var(--color-muted)]">{shortcutsClosingLine}</p>
33
+ </div>
34
+ <form method="dialog" class="modal-backdrop">
35
+ <button tabindex="-1" aria-label="Close">close</button>
36
+ </form>
37
+ </dialog>
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The keyboard shortcuts sheet, the third discoverability surface (the toolbar tooltips and the
3
+ * Markdown help dialog are the other two). A two-column grid pairs each label with its chord, with a
4
+ * closing line that the keys are always conveniences. Built on a native <dialog>, the
5
+ * MarkdownHelpDialog recipe; the host (the edit page) drives it through the exported open() and opens
6
+ * it on Ctrl+/, so the component renders no trigger of its own. Esc dismisses through the dialog's
7
+ * native behavior.
8
+ */
9
+ declare const ShortcutsDialog: import("svelte").Component<Record<string, never>, {
10
+ open: () => void;
11
+ }, "">;
12
+ type ShortcutsDialog = ReturnType<typeof ShortcutsDialog>;
13
+ export default ShortcutsDialog;
@@ -0,0 +1,18 @@
1
+ <!--
2
+ @component
3
+ The two-column shortcut grid, the shared body of both discoverability sheets (ShortcutsDialog and
4
+ the Markdown help dialog). Each row pairs a label with its chord, read from the single
5
+ editor-shortcuts source. The host wraps it with its own heading and any closing line.
6
+ -->
7
+ <script lang="ts">
8
+ import { editorShortcuts } from './editor-shortcuts.js';
9
+ </script>
10
+
11
+ <div class="grid grid-cols-1 gap-x-8 gap-y-1 text-sm sm:grid-cols-2">
12
+ {#each editorShortcuts as row (row.label)}
13
+ <div class="flex items-baseline justify-between gap-4">
14
+ <span>{row.label}</span>
15
+ <span class="font-mono text-[0.75rem] text-[var(--color-muted)]">{row.keys}</span>
16
+ </div>
17
+ {/each}
18
+ </div>
@@ -0,0 +1,23 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ /**
15
+ * The two-column shortcut grid, the shared body of both discoverability sheets (ShortcutsDialog and
16
+ * the Markdown help dialog). Each row pairs a label with its chord, read from the single
17
+ * editor-shortcuts source. The host wraps it with its own heading and any closing line.
18
+ */
19
+ declare const ShortcutsGrid: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
20
+ [evt: string]: CustomEvent<any>;
21
+ }, {}, {}, string>;
22
+ type ShortcutsGrid = InstanceType<typeof ShortcutsGrid>;
23
+ export default ShortcutsGrid;