@glw907/cairn-cms 0.50.0 → 0.52.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 (80) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/components/EditPage.svelte +125 -16
  3. package/dist/components/EditPage.svelte.d.ts +4 -1
  4. package/dist/components/EditorToolbar.svelte +135 -10
  5. package/dist/components/EditorToolbar.svelte.d.ts +19 -2
  6. package/dist/components/MarkdownEditor.svelte +112 -6
  7. package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
  8. package/dist/components/cairn-admin.css +69 -9
  9. package/dist/components/editor-highlight.d.ts +2 -0
  10. package/dist/components/editor-highlight.js +79 -15
  11. package/dist/components/editor-modes.d.ts +26 -0
  12. package/dist/components/editor-modes.js +92 -0
  13. package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
  14. package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  15. package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  16. package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  17. package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  18. package/dist/components/markdown-directives.d.ts +51 -0
  19. package/dist/components/markdown-directives.js +130 -1
  20. package/dist/components/preview-doc.d.ts +27 -0
  21. package/dist/components/preview-doc.js +64 -0
  22. package/dist/content/compose.js +1 -0
  23. package/dist/content/types.d.ts +33 -0
  24. package/dist/diagnostics/conditions.js +24 -0
  25. package/dist/doctor/bin.js +30 -12
  26. package/dist/doctor/check-floors.d.ts +15 -0
  27. package/dist/doctor/check-floors.js +107 -0
  28. package/dist/doctor/check-probe.d.ts +3 -0
  29. package/dist/doctor/check-probe.js +123 -0
  30. package/dist/doctor/checks-github.js +1 -1
  31. package/dist/doctor/checks-local.d.ts +1 -0
  32. package/dist/doctor/checks-local.js +28 -2
  33. package/dist/doctor/cloudflare-api.js +2 -2
  34. package/dist/doctor/index.d.ts +28 -3
  35. package/dist/doctor/index.js +47 -6
  36. package/dist/doctor/types.d.ts +2 -0
  37. package/dist/doctor/wrangler-config.d.ts +4 -0
  38. package/dist/doctor/wrangler-config.js +11 -0
  39. package/dist/env.d.ts +2 -1
  40. package/dist/env.js +9 -4
  41. package/dist/index.d.ts +1 -1
  42. package/dist/sveltekit/content-routes.d.ts +5 -1
  43. package/dist/sveltekit/content-routes.js +25 -17
  44. package/dist/sveltekit/guard.d.ts +8 -2
  45. package/dist/sveltekit/guard.js +3 -1
  46. package/dist/sveltekit/nav-routes.js +3 -9
  47. package/dist/vite/index.d.ts +16 -0
  48. package/dist/vite/index.js +57 -13
  49. package/package.json +2 -2
  50. package/src/lib/components/EditPage.svelte +125 -16
  51. package/src/lib/components/EditorToolbar.svelte +135 -10
  52. package/src/lib/components/MarkdownEditor.svelte +112 -6
  53. package/src/lib/components/cairn-admin.css +95 -5
  54. package/src/lib/components/editor-highlight.ts +91 -14
  55. package/src/lib/components/editor-modes.ts +106 -0
  56. package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
  57. package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  58. package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  59. package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  60. package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  61. package/src/lib/components/markdown-directives.ts +151 -1
  62. package/src/lib/components/preview-doc.ts +82 -0
  63. package/src/lib/content/compose.ts +1 -0
  64. package/src/lib/content/types.ts +32 -0
  65. package/src/lib/diagnostics/conditions.ts +24 -0
  66. package/src/lib/doctor/bin.ts +35 -10
  67. package/src/lib/doctor/check-floors.ts +124 -0
  68. package/src/lib/doctor/check-probe.ts +138 -0
  69. package/src/lib/doctor/checks-github.ts +3 -1
  70. package/src/lib/doctor/checks-local.ts +28 -2
  71. package/src/lib/doctor/cloudflare-api.ts +4 -2
  72. package/src/lib/doctor/index.ts +67 -6
  73. package/src/lib/doctor/types.ts +2 -0
  74. package/src/lib/doctor/wrangler-config.ts +11 -0
  75. package/src/lib/env.ts +9 -4
  76. package/src/lib/index.ts +2 -0
  77. package/src/lib/sveltekit/content-routes.ts +29 -17
  78. package/src/lib/sveltekit/guard.ts +4 -2
  79. package/src/lib/sveltekit/nav-routes.ts +3 -10
  80. package/src/lib/vite/index.ts +71 -17
@@ -3,12 +3,16 @@
3
3
  The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
4
4
  More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
5
5
  right. Format buttons ask the host to transform the editor's current selection; the host supplies the
6
- Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
7
- are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
6
+ Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
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
11
  -->
9
12
  <script lang="ts">
10
13
  import type { Snippet } from 'svelte';
11
14
  import type { FormatKind } from './markdown-format.js';
15
+ import { deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
12
16
 
13
17
  interface Props {
14
18
  /** Apply a markdown transform to the editor's current selection. */
@@ -17,11 +21,35 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
17
21
  mode: 'write' | 'preview';
18
22
  /** Ask the host to switch panes. */
19
23
  onMode: (m: 'write' | 'preview') => void;
24
+ /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
25
+ device?: PreviewDeviceId;
26
+ /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
27
+ * while Preview shows. */
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;
20
37
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
21
38
  insertControls?: Snippet;
22
39
  }
23
40
 
24
- let { format, mode, onMode, insertControls }: Props = $props();
41
+ let {
42
+ format,
43
+ mode,
44
+ onMode,
45
+ device = 'desktop',
46
+ onDevice,
47
+ focusMode = false,
48
+ onFocusMode,
49
+ typewriter = false,
50
+ onTypewriter,
51
+ insertControls,
52
+ }: Props = $props();
25
53
 
26
54
  // Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
27
55
  // markup stays declarative (no per-icon raw html). Paths follow the house outline style.
@@ -68,6 +96,8 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
68
96
  ];
69
97
 
70
98
  const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
99
+ // The check glyph marking an active pick, shared by the More menu's toggles and the device list.
100
+ const checkPaths = ['M20 6 9 17l-5-5'];
71
101
 
72
102
  const moreItems: { kind: FormatKind; label: string }[] = [
73
103
  { kind: 'strike', label: 'Strikethrough' },
@@ -83,10 +113,27 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
83
113
  let moreMenu = $state<HTMLUListElement | null>(null);
84
114
  let moreOpen = $state(false);
85
115
 
116
+ // Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
117
+ function hideMenu(menu: HTMLUListElement | null) {
118
+ if (menu?.matches(':popover-open')) menu.hidePopover();
119
+ }
120
+
86
121
  function pickMore(kind: FormatKind) {
87
122
  format(kind);
88
- // Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
89
- if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
123
+ hideMenu(moreMenu);
124
+ }
125
+
126
+ // The device menu's popover element and its open state, mirrored from the toggle event into
127
+ // aria-expanded on the trigger (the More menu's pattern).
128
+ let deviceMenu = $state<HTMLUListElement | null>(null);
129
+ let deviceOpen = $state(false);
130
+ const activeDevice = $derived(previewDevice(device));
131
+ // Whether the device trigger renders as the capsule's third segment.
132
+ const showDeviceTrigger = $derived(mode === 'preview' && !!onDevice);
133
+
134
+ function pickDevice(id: PreviewDeviceId) {
135
+ onDevice?.(id);
136
+ hideMenu(deviceMenu);
90
137
  }
91
138
 
92
139
  let toolbarEl = $state<HTMLDivElement | null>(null);
@@ -156,13 +203,20 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
156
203
  {/snippet}
157
204
 
158
205
  {#snippet tab(m: 'write' | 'preview', label: string)}
206
+ <!-- The capsule look is manual rounding, not daisyUI's .join: join radii follow direct
207
+ children, and the device trigger must sit outside the tablist (ARIA required children),
208
+ so the segments square their shared edges themselves. Preview squares its right edge only
209
+ while the trigger extends the capsule. -->
159
210
  <button
160
211
  type="button"
161
212
  role="tab"
162
213
  id={`cairn-tab-${m}`}
163
214
  aria-selected={mode === m}
164
215
  aria-controls={`cairn-pane-${m}`}
165
- class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
216
+ class="btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
217
+ class:rounded-r-none={m === 'write' || showDeviceTrigger}
218
+ class:rounded-l-none={m === 'preview'}
219
+ class:-ml-px={m === 'preview'}
166
220
  onclick={() => onMode(m)}
167
221
  >
168
222
  {label}
@@ -211,6 +265,34 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
211
265
  ontoggle={(e) => (moreOpen = e.newState === 'open')}
212
266
  class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
213
267
  >
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}
214
296
  {#each moreItems as item (item.kind)}
215
297
  <li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
216
298
  {/each}
@@ -230,9 +312,52 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
230
312
  {/if}
231
313
 
232
314
  <!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
233
- the strip inside the same editor card. -->
234
- <div class="join ml-auto" role="tablist" aria-label="Editor view">
235
- {@render tab('write', 'Write')}
236
- {@render tab('preview', 'Preview')}
315
+ the strip inside the same editor card. The tablist wrapper holds ONLY the two tabs (ARIA
316
+ required children: anything else in a tablist makes assistive tech miscount the tabs).
317
+ While Preview shows, the device trigger reads as the capsule's third segment from the
318
+ flex row right after the wrapper; it is a plain button, not a tab. -->
319
+ <div class="ml-auto flex items-center">
320
+ <div role="tablist" aria-label="Editor view" class="flex items-center">
321
+ {@render tab('write', 'Write')}
322
+ {@render tab('preview', 'Preview')}
323
+ </div>
324
+ {#if showDeviceTrigger}
325
+ <button
326
+ type="button"
327
+ class="btn btn-sm btn-ghost gap-1 rounded-l-none -ml-px"
328
+ title="Preview width"
329
+ aria-expanded={deviceOpen}
330
+ popovertarget="cairn-preview-device-menu"
331
+ style="anchor-name:--cairn-preview-device"
332
+ >
333
+ <span class="sr-only">Preview width:</span>
334
+ {activeDevice.label}
335
+ {@render strokeIcon(['m6 9 6 6 6-6'])}
336
+ </button>
337
+ {/if}
237
338
  </div>
339
+ {#if showDeviceTrigger}
340
+ <!-- The device list mirrors the More menu exactly: a DaisyUI v5 popover dropdown of plain
341
+ buttons, with the active pick carried by aria-pressed and the check glyph. Deliberately
342
+ NOT the ARIA menu pattern: menu roles promise interactions this list does not have. -->
343
+ <ul
344
+ bind:this={deviceMenu}
345
+ popover="auto"
346
+ id="cairn-preview-device-menu"
347
+ style="position-anchor:--cairn-preview-device"
348
+ ontoggle={(e) => (deviceOpen = e.newState === 'open')}
349
+ class="dropdown dropdown-end menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
350
+ >
351
+ {#each previewDevices as d (d.id)}
352
+ <li>
353
+ <button type="button" aria-pressed={device === d.id} onclick={() => pickDevice(d.id)}>
354
+ <span class="grow">{deviceLabel(d)}</span>
355
+ {#if device === d.id}
356
+ {@render strokeIcon(checkPaths)}
357
+ {/if}
358
+ </button>
359
+ </li>
360
+ {/each}
361
+ </ul>
362
+ {/if}
238
363
  </div>
@@ -27,6 +27,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
27
27
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
28
28
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
29
29
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
30
+ /** Focus mode: dim every line outside the caret's paragraph. Off by default. */
31
+ focusMode?: boolean;
32
+ /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
33
+ typewriter?: boolean;
30
34
  }
31
35
 
32
36
  let {
@@ -37,6 +41,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
37
41
  registerGetSelection,
38
42
  registerFormat,
39
43
  completionSources = [],
44
+ focusMode = false,
45
+ typewriter = false,
40
46
  }: Props = $props();
41
47
 
42
48
  let host = $state<HTMLDivElement | null>(null);
@@ -45,6 +51,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
45
51
  // browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
46
52
  // so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
47
53
  let view: import('@codemirror/view').EditorView | null = null;
54
+ // The writing-mode extensions live in their own compartments so the toolbar toggles swap them
55
+ // in and out of the mounted editor without rebuilding it. Assigned in onMount with the rest of
56
+ // the dynamic editor modules.
57
+ let modes: typeof import('./editor-modes.js') | null = null;
58
+ let focusCompartment: import('@codemirror/state').Compartment | null = null;
59
+ let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
48
60
 
49
61
  onMount(async () => {
50
62
  const viewMod = await import('@codemirror/view');
@@ -54,6 +66,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
54
66
  const languageMod = await import('@codemirror/language');
55
67
  const autocompleteMod = await import('@codemirror/autocomplete');
56
68
  const highlightMod = await import('./editor-highlight.js');
69
+ const modesMod = await import('./editor-modes.js');
57
70
 
58
71
  if (!host) return;
59
72
 
@@ -61,22 +74,65 @@ through the adapter's render. Swapping the editor stays a one-file change.
61
74
  // Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
62
75
  // tooltip above all) renders dark-on-dark instead of light-on-dark.
63
76
  const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
64
- // The directive machinery ink, one rule for the fence, leaf, and inline decorations.
77
+ // 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, 4-6, and 8-10 with 2px
79
+ // of surface between them, stacked as inset box shadows (top layer first, so each bar sits
80
+ // over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
81
+ // cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
82
+ // outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
83
+ // braces dim to the marker tone while the name and label keep a depth-stepped ink. Leaf and
84
+ // inline directives keep a fixed 8% accent chip; the accent ink holds AA on it (4.75:1
85
+ // light, 5.20:1 dark).
86
+ const railFallbacks = ['72%', '82%', '92%'];
87
+ const railColor = (step: number | 'active', fallback: string) =>
88
+ `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
89
+ // With `active`, the row's own (deepest) bar takes the full-strength -active mix and widens
90
+ // 1px, so the caret's container reads at a glance; a bar-width change shifts no text.
91
+ const rails = (depth: number, active = false): string => {
92
+ const layers: string[] = [];
93
+ for (let d = 1; d <= depth; d++) {
94
+ const edge = 4 * d - 2;
95
+ if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
96
+ const own = active && d === depth;
97
+ layers.push(
98
+ own
99
+ ? `inset ${edge + 1}px 0 0 0 ${railColor('active', '100%')}`
100
+ : `inset ${edge}px 0 0 0 ${railColor(d, railFallbacks[d - 1] ?? '92%')}`,
101
+ );
102
+ }
103
+ return layers.join(', ');
104
+ };
65
105
  const directiveInk = {
66
106
  backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
67
107
  color: 'var(--color-accent)',
68
108
  };
109
+ // The rail rules, one quiet and one caret-active pair per visual depth step (deeper nesting
110
+ // shares the third step). Fence and content rows at a depth share a rule, so a fence and its
111
+ // body rail identically. The caret-active selector adds the caret-block class, so it outranks
112
+ // its quiet twin on any contested row and the caret's container reads one step stronger.
113
+ const railRules: Record<string, { boxShadow: string }> = {};
114
+ for (const depth of [1, 2, 3]) {
115
+ const row = (prefix: string) =>
116
+ `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
117
+ railRules[row('')] = { boxShadow: rails(depth) };
118
+ railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
119
+ }
69
120
  const theme = EditorView.theme(
70
121
  {
71
- '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.9375rem' },
122
+ '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
72
123
  // The 50vh floor keeps a short entry reading as a writing surface, and because the
73
124
  // contenteditable content area carries the height, a click in the empty space below the
74
- // text still lands in the editor and focuses it.
125
+ // text still lands in the editor and focuses it. The 70ch cap with auto margins holds
126
+ // the manuscript to a readable measure, centered in whatever width the card gives it.
75
127
  '.cm-content': {
76
- fontFamily: 'ui-monospace, monospace',
128
+ // The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
129
+ // fallback keeps the surface monospace outside an admin theme wrapper.
130
+ fontFamily: "var(--font-editor, ui-monospace, monospace)",
77
131
  padding: '0.875rem 1.25rem',
78
132
  lineHeight: '1.8',
79
133
  minHeight: '50vh',
134
+ maxWidth: '70ch',
135
+ margin: '0 auto',
80
136
  },
81
137
  '.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
82
138
  // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
@@ -89,21 +145,56 @@ through the adapter's render. Swapping the editor stays a one-file change.
89
145
  outlineOffset: '-1px',
90
146
  },
91
147
  '.cm-line': { padding: '0' },
92
- '.cm-cairn-directive-fence': directiveInk,
148
+ // The gutter: directive rows pad left so the text clears the deepest rail stack. It is
149
+ // static structure (caret-independent), so caret movement shifts no layout.
150
+ '.cm-cairn-directive-fence, .cm-cairn-directive-content': { paddingLeft: '1.25rem' },
151
+ ...railRules,
152
+ '.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
153
+ '.cm-cairn-directive-label': { color: 'var(--color-accent)' },
154
+ '.cm-cairn-directive-label.cm-cairn-depth-2': { color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))' },
155
+ '.cm-cairn-directive-label.cm-cairn-depth-3': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
156
+ // Cursor-aware emphasis for the label ink: the caret's container takes the strongest
157
+ // ink, through the -active variable in cairn-admin.css. This selector TIES the depth
158
+ // rules above at two classes, so its place after them breaks the tie in its favor.
159
+ '.cm-cairn-caret-block .cm-cairn-directive-label': {
160
+ color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))',
161
+ },
93
162
  '.cm-cairn-directive-leaf': directiveInk,
94
163
  '.cm-cairn-directive-inline': directiveInk,
164
+ // Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
165
+ // Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
166
+ // the dim tone, and spec order breaks the specificity ties with the label rules above.
167
+ // The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
168
+ // along with the ink: the dim tone on the code chip or an 8% accent chip measures under
169
+ // the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
170
+ // span arm outranks the chip rules on specificity (the highlight style's generated
171
+ // class, the inline-directive mark); the line arm covers the leaf chip, where spec
172
+ // order breaks the tie.
173
+ '.cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label': {
174
+ color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
175
+ backgroundColor: 'transparent',
176
+ },
95
177
  },
96
178
  { dark: isDark },
97
179
  );
98
180
 
181
+ modes = modesMod;
182
+ focusCompartment = new stateMod.Compartment();
183
+ typewriterCompartment = new stateMod.Compartment();
184
+
99
185
  view = new EditorView({
100
186
  parent: host,
101
187
  state: stateMod.EditorState.create({
102
188
  doc: value,
103
189
  extensions: [
190
+ focusCompartment.of(focusMode ? modesMod.focusMode() : []),
191
+ typewriterCompartment.of(typewriter ? modesMod.typewriterScroll() : []),
104
192
  commandsMod.history(),
105
193
  keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
106
- markdownMod.markdown(),
194
+ // The GFM base (strikethrough, tables, task lists, autolink) over the commonmark
195
+ // default. markdown() also wires markdownKeymap (Enter continues a list, Backspace
196
+ // removes an empty marker) at high precedence through its addKeymap default.
197
+ markdownMod.markdown({ base: markdownMod.markdownLanguage }),
107
198
  ...(completionSources.length
108
199
  ? // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
109
200
  // accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
@@ -140,6 +231,21 @@ through the adapter's render. Swapping the editor stays a one-file change.
140
231
  view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
141
232
  });
142
233
 
234
+ // Reconfigure the writing-mode compartments when their props change. Reading `mounted` re-runs
235
+ // the effect once the editor exists, so a preference arriving between render and mount still
236
+ // applies; the reconfigure is idempotent, so the extra pass after mount costs nothing.
237
+ $effect(() => {
238
+ const focus = focusMode;
239
+ const typing = typewriter;
240
+ if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment) return;
241
+ view.dispatch({
242
+ effects: [
243
+ focusCompartment.reconfigure(focus ? modes.focusMode() : []),
244
+ typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
245
+ ],
246
+ });
247
+ });
248
+
143
249
  function insertAtCursor(text: string) {
144
250
  if (!view) {
145
251
  value = value ? `${value}\n\n${text}` : text;
@@ -1,8 +1,10 @@
1
- /* Cairn's own typeface, self-hosted so the admin needs no external font request. Figtree carries the
2
- body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and the
3
- page headings a distinct voice. Both are variable (one file spans the weight range) and licensed
4
- under the SIL Open Font License. The `@font-face` rules are added by scripts/build-admin-css.mjs
5
- after compile (so the woff2 url stays relative to the shipped sheet, not the source tree). */
1
+ /* Cairn's own typefaces, self-hosted so the admin needs no external font request. Figtree carries
2
+ the body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and
3
+ the page headings a distinct voice; iA Writer Mono is the editor writing surface. The first two are
4
+ variable (one file spans the weight range), the editor face ships four static files, and all three
5
+ are licensed under the SIL Open Font License. The `@font-face` rules are added by
6
+ scripts/build-admin-css.mjs after compile (so the woff2 url stays relative to the shipped sheet,
7
+ not the source tree). */
6
8
 
7
9
  /* Warm Stone: the cairn admin theme. Self-contained, since DaisyUI v5 reads these vars at point
8
10
  of use, so this fully overrides the host's theme with no @plugin and no host build step. */
@@ -10,6 +12,7 @@
10
12
  color-scheme: light;
11
13
  --font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
12
14
  --font-display: 'Bricolage Grotesque Variable', var(--font-body);
15
+ --font-editor: 'iA Writer Mono', ui-monospace, monospace;
13
16
  font-family: var(--font-body);
14
17
  /* Crisper, lighter glyph rendering for the variable fonts. */
15
18
  -webkit-font-smoothing: antialiased;
@@ -28,6 +31,38 @@
28
31
  tint (4.75:1); 58% failed the tint at 4.04:1. A locked margin, like the dark nav pair. */
29
32
  --color-accent: oklch(54% 0.16 300);
30
33
  --color-accent-content: oklch(98% 0.012 300);
34
+
35
+ /* The editor's nested-directive depth scale: fence and content rows share a 2px inset accent
36
+ rail stepped by nesting depth, and a fence line's name and label take a depth-stepped ink
37
+ on plain base-100 (the tinted bands are gone). Locked pairs (label ink on base-100):
38
+ depth 1 = accent 54% (5.28:1), depth 2 = 50% (6.28:1), depth 3 = 48% (6.85:1). Do not
39
+ lighten an ink without re-checking. */
40
+ --cairn-directive-ink-2: oklch(50% 0.16 300);
41
+ --cairn-directive-ink-3: oklch(48% 0.16 300);
42
+ /* Each rail bar is a 2px non-text cue, so its composited color must clear the 3:1 floor
43
+ against base-100 (WCAG 1.4.11); 71% is the measured floor, which caps how far the ramp can
44
+ spread below the active 100%. Locked margins (rail vs base-100): depth 1 = 72% (3.08:1),
45
+ depth 2 = 82% (3.71:1), depth 3 = 92% (4.51:1). Do not lower an alpha without re-checking. */
46
+ --cairn-directive-rail-1: 72%;
47
+ --cairn-directive-rail-2: 82%;
48
+ --cairn-directive-rail-3: 92%;
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. */
52
+ --cairn-directive-rail-active: 100%;
53
+ --cairn-directive-ink-active: oklch(46% 0.16 300);
54
+ /* The editor's inline-code chip, a quiet tint beside base-200. Locked pair: base-content ink
55
+ on the chip measures 13.2:1 (AA). Do not darken the chip without re-checking. */
56
+ --cairn-code-chip: oklch(94.5% 0.008 75);
57
+ /* Focus mode's dim ink for the lines outside the caret's paragraph. A color, not opacity, so
58
+ the contrast is checkable. Deliberately sub-AA: the dim is a transient writing state the
59
+ editor opts into, and an AA-grade dim (the old 54%, 4.92:1) sat so close to muted that the
60
+ mode read as nothing. Locked pair on base-100: 3.03:1, the transient-state floor. The
61
+ editor's dim rule also flattens chip backgrounds (the code chip and the 8% accent
62
+ directive chips) to transparent on dimmed lines, so base-100 is the only surface this ink
63
+ ever sits on; on the chips it measured as low as 2.63:1. Do not lighten without
64
+ re-checking. */
65
+ --cairn-focus-dim-ink: oklch(66% 0.01 75);
31
66
  --color-neutral: oklch(32% 0.012 75);
32
67
  --color-neutral-content: oklch(96% 0.004 75);
33
68
 
@@ -70,6 +105,7 @@
70
105
  color-scheme: dark;
71
106
  --font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
72
107
  --font-display: 'Bricolage Grotesque Variable', var(--font-body);
108
+ --font-editor: 'iA Writer Mono', ui-monospace, monospace;
73
109
  font-family: var(--font-body);
74
110
  /* Crisper, lighter glyph rendering for the variable fonts. */
75
111
  -webkit-font-smoothing: antialiased;
@@ -89,6 +125,34 @@
89
125
  --color-secondary-content: oklch(20% 0.008 75);
90
126
  --color-accent: oklch(70% 0.14 300);
91
127
  --color-accent-content: oklch(20% 0.04 300);
128
+
129
+ /* The nested-directive label inks on dark lighten with depth so a deeper fence's name reads
130
+ stronger on base-100 (the tinted bands are gone). Locked pairs (label ink on base-100):
131
+ depth 1 = accent 70% (5.86:1), depth 2 = 74% (6.81:1), depth 3 = 78% (7.82:1). Do not
132
+ darken an ink without re-checking. */
133
+ --cairn-directive-ink-2: oklch(74% 0.14 300);
134
+ --cairn-directive-ink-3: oklch(78% 0.14 300);
135
+ /* The rail bars hold the same 3:1 non-text floor on dark; 61% is the measured floor, which
136
+ caps the ramp's spread. Locked margins (rail vs base-100): depth 1 = 62% (3.09:1),
137
+ depth 2 = 74% (3.83:1), depth 3 = 86% (4.69:1). */
138
+ --cairn-directive-rail-1: 62%;
139
+ --cairn-directive-rail-2: 74%;
140
+ --cairn-directive-rail-3: 86%;
141
+ /* 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. */
146
+ --cairn-directive-rail-active: 100%;
147
+ --cairn-directive-ink-active: oklch(82% 0.14 300);
148
+ /* The inline-code chip on dark sits one step above base-100 so it reads raised. Locked pair:
149
+ base-content ink on the chip measures 11.3:1 (AA). Do not lighten the chip without
150
+ re-checking. */
151
+ --cairn-code-chip: oklch(29.5% 0.012 75);
152
+ /* Focus mode's dim ink on dark, the same deliberate sub-AA transient-state call as light;
153
+ the proposed 50% measured 2.74:1, under the 3:1 floor, so it sits at 53%. Locked pair on
154
+ base-100: 3.12:1. Do not darken without re-checking. */
155
+ --cairn-focus-dim-ink: oklch(53% 0.01 75);
92
156
  --color-neutral: oklch(80% 0.01 75);
93
157
  --color-neutral-content: oklch(22% 0.008 75);
94
158
 
@@ -196,6 +260,20 @@
196
260
  outline-offset: -1px;
197
261
  }
198
262
 
263
+ /* Menu items come as anchors or buttons, and the omitted Preflight is what made them match:
264
+ without it a button keeps the UA chrome (outset border, gray fill, centered system-font
265
+ text) while its anchor siblings render flat. This scoped substitute levels the buttons to
266
+ the anchor baseline. The components layer still beats the UA stylesheet, so daisyUI's
267
+ utilities-layer menu rules (the hover tint, the menu-sm sizing) and any text utility such
268
+ as text-error keep winning. A .btn inside a menu keeps its full DaisyUI chrome. */
269
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > button:not(.btn) {
270
+ border: 0 solid;
271
+ background-color: transparent;
272
+ font: inherit;
273
+ color: inherit;
274
+ text-align: start;
275
+ }
276
+
199
277
  /* The admin topbar plus the edit page's sticky action header stack about 120px of veil over the
200
278
  top of the content column, and a control the browser scrolls into view could land hidden
201
279
  beneath them (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or fragment
@@ -205,6 +283,18 @@
205
283
  }
206
284
  }
207
285
 
286
+ /* DaisyUI v5's .menu quiets keyboard focus on its items (`outline-style: none` on
287
+ :focus-visible), and the compiled sheet carries that rule in the utilities layer, where it
288
+ beats the components-layer focus ring above: cascade layers resolve before specificity, and
289
+ utilities is the last layer. This override is deliberately UNLAYERED (the same mechanism that
290
+ lets the theme blocks win), because no layered rule can outrank a later layer; it restores a
291
+ visible focus indicator on the popover and nav menu items. The negative offset draws the ring
292
+ inside the item, clear of the menu panel's clipped corners. */
293
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > :is(button, a):focus-visible {
294
+ outline: 2px solid var(--color-primary);
295
+ outline-offset: -2px;
296
+ }
297
+
208
298
  /* Respect a reduced-motion preference inside the admin. DaisyUI's modal, drawer, and the admin's
209
299
  own hover transitions otherwise animate regardless. Scoped to the admin roots, so it never
210
300
  reaches the host's pages. */