@glw907/cairn-cms 0.41.0 → 0.51.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 (162) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +2 -2
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +107 -25
  12. package/dist/components/EditPage.svelte.d.ts +8 -10
  13. package/dist/components/EditorToolbar.svelte +79 -8
  14. package/dist/components/EditorToolbar.svelte.d.ts +10 -2
  15. package/dist/components/LoginPage.svelte +2 -2
  16. package/dist/components/LoginPage.svelte.d.ts +1 -1
  17. package/dist/components/ManageEditors.svelte +4 -3
  18. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  19. package/dist/components/MarkdownEditor.svelte +20 -2
  20. package/dist/components/cairn-admin.css +57 -9
  21. package/dist/components/editor-highlight.d.ts +1 -0
  22. package/dist/components/editor-highlight.js +31 -8
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/markdown-directives.d.ts +10 -0
  26. package/dist/components/markdown-directives.js +54 -1
  27. package/dist/components/markdown-format.d.ts +0 -8
  28. package/dist/components/markdown-format.js +0 -28
  29. package/dist/components/preview-doc.d.ts +27 -0
  30. package/dist/components/preview-doc.js +64 -0
  31. package/dist/content/compose.js +1 -0
  32. package/dist/content/links.d.ts +8 -0
  33. package/dist/content/links.js +28 -0
  34. package/dist/content/types.d.ts +35 -2
  35. package/dist/delivery/data.d.ts +3 -5
  36. package/dist/delivery/data.js +2 -3
  37. package/dist/delivery/feeds.js +1 -7
  38. package/dist/delivery/index.d.ts +2 -2
  39. package/dist/delivery/index.js +1 -1
  40. package/dist/delivery/manifest.d.ts +0 -5
  41. package/dist/delivery/manifest.js +5 -16
  42. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  43. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  44. package/dist/delivery/site-indexes.d.ts +3 -3
  45. package/dist/delivery/site-indexes.js +3 -3
  46. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  47. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  48. package/dist/delivery/sitemap.js +1 -3
  49. package/dist/delivery/xml.d.ts +2 -0
  50. package/dist/delivery/xml.js +11 -0
  51. package/dist/diagnostics/conditions.js +24 -0
  52. package/dist/doctor/bin.js +30 -12
  53. package/dist/doctor/check-floors.d.ts +15 -0
  54. package/dist/doctor/check-floors.js +107 -0
  55. package/dist/doctor/check-probe.d.ts +3 -0
  56. package/dist/doctor/check-probe.js +123 -0
  57. package/dist/doctor/checks-github.js +1 -1
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +28 -2
  60. package/dist/doctor/cloudflare-api.js +2 -2
  61. package/dist/doctor/index.d.ts +28 -3
  62. package/dist/doctor/index.js +47 -6
  63. package/dist/doctor/types.d.ts +2 -0
  64. package/dist/doctor/wrangler-config.d.ts +4 -0
  65. package/dist/doctor/wrangler-config.js +11 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +3 -2
  68. package/dist/env.js +12 -6
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/types.d.ts +2 -0
  74. package/dist/github/types.js +4 -0
  75. package/dist/index.d.ts +1 -1
  76. package/dist/log/events.d.ts +1 -1
  77. package/dist/nav/site-config.d.ts +2 -0
  78. package/dist/nav/site-config.js +2 -0
  79. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  80. package/dist/sveltekit/admin-dispatch.js +62 -0
  81. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  82. package/dist/sveltekit/cairn-admin.js +126 -0
  83. package/dist/sveltekit/condition-response.d.ts +1 -0
  84. package/dist/sveltekit/condition-response.js +25 -0
  85. package/dist/sveltekit/content-routes.d.ts +39 -15
  86. package/dist/sveltekit/content-routes.js +84 -50
  87. package/dist/sveltekit/guard.d.ts +8 -2
  88. package/dist/sveltekit/guard.js +18 -4
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +22 -19
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/dist/vite/index.d.ts +16 -0
  98. package/dist/vite/index.js +57 -13
  99. package/package.json +6 -2
  100. package/src/lib/ambient.ts +19 -0
  101. package/src/lib/components/AdminLayout.svelte +6 -8
  102. package/src/lib/components/CairnAdmin.svelte +67 -0
  103. package/src/lib/components/ConceptList.svelte +4 -5
  104. package/src/lib/components/ConfirmPage.svelte +1 -1
  105. package/src/lib/components/EditPage.svelte +107 -25
  106. package/src/lib/components/EditorToolbar.svelte +79 -8
  107. package/src/lib/components/LoginPage.svelte +2 -2
  108. package/src/lib/components/ManageEditors.svelte +4 -3
  109. package/src/lib/components/MarkdownEditor.svelte +20 -2
  110. package/src/lib/components/cairn-admin.css +59 -0
  111. package/src/lib/components/editor-highlight.ts +32 -7
  112. package/src/lib/components/index.ts +1 -0
  113. package/src/lib/components/markdown-directives.ts +51 -1
  114. package/src/lib/components/markdown-format.ts +0 -27
  115. package/src/lib/components/preview-doc.ts +82 -0
  116. package/src/lib/content/compose.ts +1 -0
  117. package/src/lib/content/links.ts +28 -0
  118. package/src/lib/content/types.ts +34 -2
  119. package/src/lib/delivery/data.ts +3 -5
  120. package/src/lib/delivery/feeds.ts +1 -8
  121. package/src/lib/delivery/index.ts +2 -2
  122. package/src/lib/delivery/manifest.ts +5 -18
  123. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  124. package/src/lib/delivery/site-indexes.ts +6 -6
  125. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  126. package/src/lib/delivery/sitemap.ts +1 -4
  127. package/src/lib/delivery/xml.ts +12 -0
  128. package/src/lib/diagnostics/conditions.ts +24 -0
  129. package/src/lib/doctor/bin.ts +35 -10
  130. package/src/lib/doctor/check-floors.ts +124 -0
  131. package/src/lib/doctor/check-probe.ts +138 -0
  132. package/src/lib/doctor/checks-github.ts +3 -1
  133. package/src/lib/doctor/checks-local.ts +28 -2
  134. package/src/lib/doctor/cloudflare-api.ts +4 -2
  135. package/src/lib/doctor/index.ts +67 -6
  136. package/src/lib/doctor/types.ts +2 -0
  137. package/src/lib/doctor/wrangler-config.ts +11 -0
  138. package/src/lib/email.ts +4 -11
  139. package/src/lib/env.ts +12 -6
  140. package/src/lib/escape.ts +12 -0
  141. package/src/lib/github/credentials.ts +6 -2
  142. package/src/lib/github/types.ts +5 -0
  143. package/src/lib/index.ts +2 -0
  144. package/src/lib/log/events.ts +1 -0
  145. package/src/lib/nav/site-config.ts +3 -0
  146. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  147. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  148. package/src/lib/sveltekit/condition-response.ts +27 -1
  149. package/src/lib/sveltekit/content-routes.ts +131 -62
  150. package/src/lib/sveltekit/guard.ts +20 -5
  151. package/src/lib/sveltekit/https-required-page.ts +2 -1
  152. package/src/lib/sveltekit/index.ts +6 -0
  153. package/src/lib/sveltekit/nav-routes.ts +24 -21
  154. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  155. package/src/lib/sveltekit/types.ts +16 -7
  156. package/src/lib/vite/index.ts +71 -17
  157. package/dist/delivery/paginate.d.ts +0 -12
  158. package/dist/delivery/paginate.js +0 -20
  159. package/dist/render/index.d.ts +0 -5
  160. package/dist/render/index.js +0 -8
  161. package/src/lib/delivery/paginate.ts +0 -32
  162. package/src/lib/render/index.ts +0 -8
@@ -3,12 +3,15 @@
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 glyphs are stroke SVG icons in the admin's
9
+ house style (24x24 viewBox, `currentColor`, round caps).
8
10
  -->
9
11
  <script lang="ts">
10
12
  import type { Snippet } from 'svelte';
11
13
  import type { FormatKind } from './markdown-format.js';
14
+ import { deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
12
15
 
13
16
  interface Props {
14
17
  /** Apply a markdown transform to the editor's current selection. */
@@ -17,11 +20,16 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
17
20
  mode: 'write' | 'preview';
18
21
  /** Ask the host to switch panes. */
19
22
  onMode: (m: 'write' | 'preview') => void;
23
+ /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
24
+ device?: PreviewDeviceId;
25
+ /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
26
+ * while Preview shows. */
27
+ onDevice?: (id: PreviewDeviceId) => void;
20
28
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
21
29
  insertControls?: Snippet;
22
30
  }
23
31
 
24
- let { format, mode, onMode, insertControls }: Props = $props();
32
+ let { format, mode, onMode, device = 'desktop', onDevice, insertControls }: Props = $props();
25
33
 
26
34
  // Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
27
35
  // markup stays declarative (no per-icon raw html). Paths follow the house outline style.
@@ -89,6 +97,19 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
89
97
  if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
90
98
  }
91
99
 
100
+ // The device menu's popover element and its open state, mirrored from the toggle event into
101
+ // aria-expanded on the trigger (the More menu's pattern).
102
+ let deviceMenu = $state<HTMLUListElement | null>(null);
103
+ let deviceOpen = $state(false);
104
+ const activeDevice = $derived(previewDevice(device));
105
+ // Whether the device trigger renders as the capsule's third segment.
106
+ const showDeviceTrigger = $derived(mode === 'preview' && !!onDevice);
107
+
108
+ function pickDevice(id: PreviewDeviceId) {
109
+ onDevice?.(id);
110
+ if (deviceMenu?.matches(':popover-open')) deviceMenu.hidePopover();
111
+ }
112
+
92
113
  let toolbarEl = $state<HTMLDivElement | null>(null);
93
114
  // The roving tab stop's position among the strip's enabled top-level controls. The Write/Preview
94
115
  // tabs join the toolbar's roving order instead of managing their own arrow keys: the ARIA toolbar
@@ -156,13 +177,20 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
156
177
  {/snippet}
157
178
 
158
179
  {#snippet tab(m: 'write' | 'preview', label: string)}
180
+ <!-- The capsule look is manual rounding, not daisyUI's .join: join radii follow direct
181
+ children, and the device trigger must sit outside the tablist (ARIA required children),
182
+ so the segments square their shared edges themselves. Preview squares its right edge only
183
+ while the trigger extends the capsule. -->
159
184
  <button
160
185
  type="button"
161
186
  role="tab"
162
187
  id={`cairn-tab-${m}`}
163
188
  aria-selected={mode === m}
164
189
  aria-controls={`cairn-pane-${m}`}
165
- class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
190
+ class="btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
191
+ class:rounded-r-none={m === 'write' || showDeviceTrigger}
192
+ class:rounded-l-none={m === 'preview'}
193
+ class:-ml-px={m === 'preview'}
166
194
  onclick={() => onMode(m)}
167
195
  >
168
196
  {label}
@@ -230,9 +258,52 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
230
258
  {/if}
231
259
 
232
260
  <!-- 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')}
261
+ the strip inside the same editor card. The tablist wrapper holds ONLY the two tabs (ARIA
262
+ required children: anything else in a tablist makes assistive tech miscount the tabs).
263
+ While Preview shows, the device trigger reads as the capsule's third segment from the
264
+ flex row right after the wrapper; it is a plain button, not a tab. -->
265
+ <div class="ml-auto flex items-center">
266
+ <div role="tablist" aria-label="Editor view" class="flex items-center">
267
+ {@render tab('write', 'Write')}
268
+ {@render tab('preview', 'Preview')}
269
+ </div>
270
+ {#if showDeviceTrigger}
271
+ <button
272
+ type="button"
273
+ class="btn btn-sm btn-ghost gap-1 rounded-l-none -ml-px"
274
+ title="Preview width"
275
+ aria-expanded={deviceOpen}
276
+ popovertarget="cairn-preview-device-menu"
277
+ style="anchor-name:--cairn-preview-device"
278
+ >
279
+ <span class="sr-only">Preview width:</span>
280
+ {activeDevice.label}
281
+ {@render strokeIcon(['m6 9 6 6 6-6'])}
282
+ </button>
283
+ {/if}
237
284
  </div>
285
+ {#if showDeviceTrigger}
286
+ <!-- The device list mirrors the More menu exactly: a DaisyUI v5 popover dropdown of plain
287
+ buttons, with the active pick carried by aria-pressed and the check glyph. Deliberately
288
+ NOT the ARIA menu pattern: menu roles promise interactions this list does not have. -->
289
+ <ul
290
+ bind:this={deviceMenu}
291
+ popover="auto"
292
+ id="cairn-preview-device-menu"
293
+ style="position-anchor:--cairn-preview-device"
294
+ ontoggle={(e) => (deviceOpen = e.newState === 'open')}
295
+ 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)]"
296
+ >
297
+ {#each previewDevices as d (d.id)}
298
+ <li>
299
+ <button type="button" aria-pressed={device === d.id} onclick={() => pickDevice(d.id)}>
300
+ <span class="grow">{deviceLabel(d)}</span>
301
+ {#if device === d.id}
302
+ {@render strokeIcon(['M20 6 9 17l-5-5'])}
303
+ {/if}
304
+ </button>
305
+ </li>
306
+ {/each}
307
+ </ul>
308
+ {/if}
238
309
  </div>
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  @component
3
- The magic-link sign-in page. A plain form POST to the page's default action (the engine's
3
+ The magic-link sign-in page. A plain form POST to the named `?/request` action (the engine's
4
4
  `requestAction`); no client SDK. The success message is identical whether or not the email is on
5
5
  the allowlist, so the page never leaks membership (spec §7.1).
6
6
  -->
@@ -101,7 +101,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
101
101
  {#if data.error && !form?.status}
102
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
103
103
  {/if}
104
- <form method="POST" class="flex flex-col gap-3">
104
+ <form method="POST" action="?/request" class="flex flex-col gap-3">
105
105
  <CsrfField token={data.csrf} />
106
106
  <label class="flex flex-col gap-1">
107
107
  <span class="text-sm font-medium">Email</span>
@@ -3,7 +3,8 @@
3
3
  The owner-gated editor management surface: a table of editors with role-flip and remove actions,
4
4
  and an add-editor form. The acting owner's own row disables its destructive controls; the
5
5
  last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
6
- named `?/setRole`, `?/remove`, and `?/add` actions.
6
+ named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
7
+ dispatcher defines.
7
8
  -->
8
9
  <script lang="ts">
9
10
  import CsrfField from './CsrfField.svelte';
@@ -53,7 +54,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
53
54
  {editor.role === 'owner' ? 'Make editor' : 'Make owner'}
54
55
  </button>
55
56
  </form>
56
- <form method="POST" action="?/remove">
57
+ <form method="POST" action="?/removeEditor">
57
58
  <CsrfField />
58
59
  <input type="hidden" name="email" value={editor.email} />
59
60
  <button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
@@ -67,7 +68,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
67
68
  </table>
68
69
  </div>
69
70
 
70
- <form method="POST" action="?/add" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
71
+ <form method="POST" action="?/addEditor" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
71
72
  <CsrfField />
72
73
  <label class="flex flex-col gap-1">
73
74
  <span class="text-sm font-medium">Name</span>
@@ -61,9 +61,16 @@ through the adapter's render. Swapping the editor stays a one-file change.
61
61
  // Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
62
62
  // tooltip above all) renders dark-on-dark instead of light-on-dark.
63
63
  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.
64
+ // The directive machinery treatment. The fence bands and content rails step their alpha by
65
+ // nesting depth through the per-theme vars in cairn-admin.css; the fallbacks are the light
66
+ // values, so the editor still renders sensibly outside an admin theme wrapper. The deeper
67
+ // bands swap in a darker ink (--cairn-directive-ink-N) to hold AA on their own tint.
68
+ const band = (depth: number, fallback: string) =>
69
+ `color-mix(in oklab, var(--color-accent) var(--cairn-directive-band-${depth}, ${fallback}), transparent)`;
70
+ const rail = (depth: number, fallback: string) =>
71
+ `inset 2px 0 0 0 color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${depth}, ${fallback}), transparent)`;
65
72
  const directiveInk = {
66
- backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
73
+ backgroundColor: band(1, '8%'),
67
74
  color: 'var(--color-accent)',
68
75
  };
69
76
  const theme = EditorView.theme(
@@ -90,6 +97,17 @@ through the adapter's render. Swapping the editor stays a one-file change.
90
97
  },
91
98
  '.cm-line': { padding: '0' },
92
99
  '.cm-cairn-directive-fence': directiveInk,
100
+ '.cm-cairn-directive-fence.cm-cairn-depth-2': {
101
+ backgroundColor: band(2, '14%'),
102
+ color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
103
+ },
104
+ '.cm-cairn-directive-fence.cm-cairn-depth-3': {
105
+ backgroundColor: band(3, '20%'),
106
+ color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))',
107
+ },
108
+ '.cm-cairn-directive-content.cm-cairn-depth-1': { boxShadow: rail(1, '75%') },
109
+ '.cm-cairn-directive-content.cm-cairn-depth-2': { boxShadow: rail(2, '82%') },
110
+ '.cm-cairn-directive-content.cm-cairn-depth-3': { boxShadow: rail(3, '90%') },
93
111
  '.cm-cairn-directive-leaf': directiveInk,
94
112
  '.cm-cairn-directive-inline': directiveInk,
95
113
  },
@@ -28,6 +28,23 @@
28
28
  tint (4.75:1); 58% failed the tint at 4.04:1. A locked margin, like the dark nav pair. */
29
29
  --color-accent: oklch(54% 0.16 300);
30
30
  --color-accent-content: oklch(98% 0.012 300);
31
+
32
+ /* The editor's nested-directive depth scale: machinery bands step the accent tint by nesting
33
+ depth, content lines carry an accent rail at the same steps, and the deeper bands darken
34
+ their ink so it keeps AA on its own tint. Locked pairs (ink on band, computed against
35
+ base-100): depth 1 = 54% on 8% (4.75:1), depth 2 = 50% on 14% (5.20:1), depth 3 = 48% on
36
+ 20% (5.21:1). Do not raise a band or lighten its ink without re-checking. */
37
+ --cairn-directive-band-1: 8%;
38
+ --cairn-directive-band-2: 14%;
39
+ --cairn-directive-band-3: 20%;
40
+ --cairn-directive-ink-2: oklch(50% 0.16 300);
41
+ --cairn-directive-ink-3: oklch(48% 0.16 300);
42
+ /* The content rail is a 2px non-text cue, so its composited color must clear the 3:1 floor
43
+ against base-100 (WCAG 1.4.11). Locked margins (rail vs base-100): depth 1 = 75% (3.25:1),
44
+ depth 2 = 82% (3.71:1), depth 3 = 90% (4.34:1). Do not lower an alpha without re-checking. */
45
+ --cairn-directive-rail-1: 75%;
46
+ --cairn-directive-rail-2: 82%;
47
+ --cairn-directive-rail-3: 90%;
31
48
  --color-neutral: oklch(32% 0.012 75);
32
49
  --color-neutral-content: oklch(96% 0.004 75);
33
50
 
@@ -89,6 +106,22 @@
89
106
  --color-secondary-content: oklch(20% 0.008 75);
90
107
  --color-accent: oklch(70% 0.14 300);
91
108
  --color-accent-content: oklch(20% 0.04 300);
109
+
110
+ /* The nested-directive depth scale on dark: slightly stronger bands (a tint reads quieter on a
111
+ dark base), with the deeper inks lightened to keep AA on their own tint. Locked pairs (ink on
112
+ band, computed against base-100): depth 1 = 70% on 10% (5.03:1), depth 2 = 74% on 16%
113
+ (5.27:1), depth 3 = 78% on 22% (5.42:1). Do not raise a band or darken its ink without
114
+ re-checking. */
115
+ --cairn-directive-band-1: 10%;
116
+ --cairn-directive-band-2: 16%;
117
+ --cairn-directive-band-3: 22%;
118
+ --cairn-directive-ink-2: oklch(74% 0.14 300);
119
+ --cairn-directive-ink-3: oklch(78% 0.14 300);
120
+ /* The content rails hold the same 3:1 non-text floor on dark. Locked margins (rail vs
121
+ base-100): depth 1 = 65% (3.27:1), depth 2 = 75% (3.90:1), depth 3 = 85% (4.62:1). */
122
+ --cairn-directive-rail-1: 65%;
123
+ --cairn-directive-rail-2: 75%;
124
+ --cairn-directive-rail-3: 85%;
92
125
  --color-neutral: oklch(80% 0.01 75);
93
126
  --color-neutral-content: oklch(22% 0.008 75);
94
127
 
@@ -196,6 +229,20 @@
196
229
  outline-offset: -1px;
197
230
  }
198
231
 
232
+ /* Menu items come as anchors or buttons, and the omitted Preflight is what made them match:
233
+ without it a button keeps the UA chrome (outset border, gray fill, centered system-font
234
+ text) while its anchor siblings render flat. This scoped substitute levels the buttons to
235
+ the anchor baseline. The components layer still beats the UA stylesheet, so daisyUI's
236
+ utilities-layer menu rules (the hover tint, the menu-sm sizing) and any text utility such
237
+ as text-error keep winning. A .btn inside a menu keeps its full DaisyUI chrome. */
238
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > button:not(.btn) {
239
+ border: 0 solid;
240
+ background-color: transparent;
241
+ font: inherit;
242
+ color: inherit;
243
+ text-align: start;
244
+ }
245
+
199
246
  /* The admin topbar plus the edit page's sticky action header stack about 120px of veil over the
200
247
  top of the content column, and a control the browser scrolls into view could land hidden
201
248
  beneath them (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or fragment
@@ -205,6 +252,18 @@
205
252
  }
206
253
  }
207
254
 
255
+ /* DaisyUI v5's .menu quiets keyboard focus on its items (`outline-style: none` on
256
+ :focus-visible), and the compiled sheet carries that rule in the utilities layer, where it
257
+ beats the components-layer focus ring above: cascade layers resolve before specificity, and
258
+ utilities is the last layer. This override is deliberately UNLAYERED (the same mechanism that
259
+ lets the theme blocks win), because no layered rule can outrank a later layer; it restores a
260
+ visible focus indicator on the popover and nav menu items. The negative offset draws the ring
261
+ inside the item, clear of the menu panel's clipped corners. */
262
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > :is(button, a):focus-visible {
263
+ outline: 2px solid var(--color-primary);
264
+ outline-offset: -2px;
265
+ }
266
+
208
267
  /* Respect a reduced-motion preference inside the admin. DaisyUI's modal, drawer, and the admin's
209
268
  own hover transitions otherwise animate regardless. Scoped to the admin roots, so it never
210
269
  reaches the host's pages. */
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
5
5
  import { tags } from '@lezer/highlight';
6
6
  import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
7
7
  import { RangeSetBuilder } from '@codemirror/state';
8
- import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
8
+ import { directiveLineKind, fenceDepths, findInlineDirectives } from './markdown-directives.js';
9
9
 
10
10
  /** Markdown token colors over the admin theme variables. */
11
11
  export function cairnHighlightStyle(): HighlightStyle {
@@ -27,19 +27,40 @@ export function cairnHighlightStyle(): HighlightStyle {
27
27
  // learns what the line is without leaving the page.
28
28
  const MACHINERY_HINT = 'Layout marker. Edit the text between these lines and leave this line as it is.';
29
29
 
30
- const fenceLine = Decoration.line({ class: 'cm-cairn-directive-fence', attributes: { title: MACHINERY_HINT } });
30
+ // Nesting deeper than three steps shares the third visual step; the depth model itself is unbounded.
31
+ const DEPTH_STEPS = [1, 2, 3];
32
+
33
+ const fenceLines = DEPTH_STEPS.map((d) =>
34
+ Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }),
35
+ );
36
+ const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
31
37
  const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
32
38
  const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
33
39
 
34
- function buildDirectiveDecorations(view: EditorView): DecorationSet {
40
+ // Depth needs the whole document, since a visible line's containers can open above the viewport.
41
+ // One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
42
+ // is well under a millisecond. The plugin caches the result, so the scan reruns only when the
43
+ // document changes and a scroll rebuilds the viewport decorations from the cached array.
44
+ function docDepths(view: EditorView): (number | null)[] {
45
+ const doc = view.state.doc;
46
+ const lines: string[] = [];
47
+ for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
48
+ return fenceDepths(lines);
49
+ }
50
+
51
+ function buildDirectiveDecorations(view: EditorView, depths: (number | null)[]): DecorationSet {
35
52
  const builder = new RangeSetBuilder<Decoration>();
36
53
  for (const { from, to } of view.visibleRanges) {
37
54
  for (let pos = from; pos <= to; ) {
38
55
  const line = view.state.doc.lineAt(pos);
39
56
  const kind = directiveLineKind(line.text);
40
- if (kind === 'fence') builder.add(line.from, line.from, fenceLine);
57
+ const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
58
+ // A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
59
+ // inside a code block, outside any container); it gets no machinery treatment.
60
+ if (kind === 'fence' && depth > 0) builder.add(line.from, line.from, fenceLines[depth - 1]);
41
61
  else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
42
- else {
62
+ else if (kind === null) {
63
+ if (depth > 0) builder.add(line.from, line.from, contentLines[depth - 1]);
43
64
  for (const r of findInlineDirectives(line.text)) {
44
65
  builder.add(line.from + r.from, line.from + r.to, inlineMark);
45
66
  }
@@ -55,11 +76,15 @@ export function cairnDirectivePlugin() {
55
76
  return ViewPlugin.fromClass(
56
77
  class {
57
78
  decorations: DecorationSet;
79
+ depths: (number | null)[];
58
80
  constructor(view: EditorView) {
59
- this.decorations = buildDirectiveDecorations(view);
81
+ this.depths = docDepths(view);
82
+ this.decorations = buildDirectiveDecorations(view, this.depths);
60
83
  }
61
84
  update(update: ViewUpdate) {
62
- if (update.docChanged || update.viewportChanged) this.decorations = buildDirectiveDecorations(update.view);
85
+ if (update.docChanged) this.depths = docDepths(update.view);
86
+ if (update.docChanged || update.viewportChanged)
87
+ this.decorations = buildDirectiveDecorations(update.view, this.depths);
63
88
  }
64
89
  },
65
90
  { decorations: (v) => v.decorations },
@@ -1,5 +1,6 @@
1
1
  // Admin Svelte components (Plan 05). The Warm Stone theme ships as a CSS side effect imported
2
2
  // by the components that set `data-theme="cairn-admin"`.
3
+ export { default as CairnAdmin } from './CairnAdmin.svelte';
3
4
  export { default as AdminLayout } from './AdminLayout.svelte';
4
5
  export { default as LoginPage } from './LoginPage.svelte';
5
6
  export { default as ConfirmPage } from './ConfirmPage.svelte';
@@ -2,10 +2,19 @@
2
2
  // styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
3
3
  // CodeMirror decoration plugin wraps them.
4
4
 
5
- const FENCE = /^\s{0,3}:::+\s*[\w-]*\s*(\{[^}]*\})?\s*$/;
5
+ // A container fence: three or more colons, then an optional name, an optional [label], and
6
+ // optional {attrs}, in remark-directive order. The name is captured so the depth scan below can
7
+ // tell an opener (named) from a closer (bare colons). Matching is tolerant of stray whitespace,
8
+ // the same posture as the leaf form: a slightly off fence should still read as machinery.
9
+ const FENCE = /^\s{0,3}:{3,}\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/;
6
10
  const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
7
11
  const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
8
12
 
13
+ // A fenced code block's delimiter: three or more backticks or tildes, indent-tolerant like the
14
+ // directive forms. The depth scan tracks these so a documented ::: example inside a code block
15
+ // never opens a real container.
16
+ const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
17
+
9
18
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
10
19
  export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
11
20
  if (FENCE.test(line)) return 'fence';
@@ -13,6 +22,47 @@ export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
13
22
  return null;
14
23
  }
15
24
 
25
+ /**
26
+ * The 1-based container depth each line sits at, or null outside any container. A named fence
27
+ * opens a container; a bare fence closes the most recent one (colon counts are not trusted for
28
+ * pairing, since authors vary them). An opener and its closer share the opener's depth, and a
29
+ * line between them carries the depth of its innermost container. Lines inside a fenced code
30
+ * block are plain content, so a documented ::: example cannot open a phantom container running
31
+ * to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
32
+ * count never goes below zero.
33
+ */
34
+ export function fenceDepths(lines: string[]): (number | null)[] {
35
+ const depths: (number | null)[] = [];
36
+ let open = 0;
37
+ // The marker character that opened the current code block, or null outside one. Only a line
38
+ // opening with the same character closes it, so tildes inside a backtick block stay literal.
39
+ let codeMarker: string | null = null;
40
+ for (const line of lines) {
41
+ const code = CODE_FENCE.exec(line);
42
+ if (code) {
43
+ if (codeMarker === null) codeMarker = code[1][0];
44
+ else if (code[1][0] === codeMarker) codeMarker = null;
45
+ depths.push(open > 0 ? open : null);
46
+ continue;
47
+ }
48
+ if (codeMarker !== null) {
49
+ depths.push(open > 0 ? open : null);
50
+ continue;
51
+ }
52
+ const fence = FENCE.exec(line);
53
+ if (!fence) {
54
+ depths.push(open > 0 ? open : null);
55
+ } else if (fence[1]) {
56
+ open += 1;
57
+ depths.push(open);
58
+ } else {
59
+ depths.push(Math.max(open, 1));
60
+ if (open > 0) open -= 1;
61
+ }
62
+ }
63
+ return depths;
64
+ }
65
+
16
66
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
17
67
  export function findInlineDirectives(text: string): { from: number; to: number }[] {
18
68
  const out: { from: number; to: number }[] = [];
@@ -197,30 +197,3 @@ export function unwrapCairnLink(doc: string, href: string): string {
197
197
  }
198
198
  return out;
199
199
  }
200
-
201
- /**
202
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
203
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
204
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
205
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
206
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
207
- */
208
- export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
209
- const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
210
- const spans: { start: number; end: number }[] = [];
211
- visit(tree, 'link', (node: Link) => {
212
- if (node.url !== oldHref) return;
213
- const start = node.position?.start?.offset;
214
- const end = node.position?.end?.offset;
215
- if (start == null || end == null) return;
216
- spans.push({ start, end });
217
- });
218
- spans.sort((a, b) => b.start - a.start);
219
- let out = doc;
220
- for (const span of spans) {
221
- const src = out.slice(span.start, span.end);
222
- const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
223
- out = out.slice(0, span.start) + rewritten + out.slice(span.end);
224
- }
225
- return out;
226
- }
@@ -0,0 +1,82 @@
1
+ // cairn-cms: the edit page's preview-frame document. The admin's chrome isolation keeps the
2
+ // site's CSS out of the admin document, so EditPage renders the preview inside a sandboxed
3
+ // iframe whose document links the site's own stylesheets from the adapter's preview knob. This
4
+ // module builds that iframe's srcdoc as one pure string, so its shape is unit-testable, and it
5
+ // carries the device table the frame's width control offers.
6
+
7
+ import { escapeHtml } from '../escape.js';
8
+ import type { ResolvedPreview } from '../content/types.js';
9
+
10
+ /** One width the preview frame can take. */
11
+ export interface PreviewDevice {
12
+ id: 'desktop' | 'tablet' | 'phone' | 'small';
13
+ /** The device menu label, also the frame caption's first half. */
14
+ label: string;
15
+ /** Frame width in CSS pixels; null fills the pane (Desktop). */
16
+ width: number | null;
17
+ }
18
+
19
+ /** A preview device's id, the value the page persists. */
20
+ export type PreviewDeviceId = PreviewDevice['id'];
21
+
22
+ /** The four widths the device menu offers, in menu order. Desktop leads as the default. */
23
+ export const previewDevices: PreviewDevice[] = [
24
+ { id: 'desktop', label: 'Desktop', width: null },
25
+ { id: 'tablet', label: 'Tablet', width: 768 },
26
+ { id: 'phone', label: 'Phone', width: 390 },
27
+ { id: 'small', label: 'Small phone', width: 320 },
28
+ ];
29
+
30
+ /** The table row for a device id. The id type makes a miss impossible; the fallback satisfies find. */
31
+ export function previewDevice(id: PreviewDeviceId): PreviewDevice {
32
+ return previewDevices.find((d) => d.id === id) ?? previewDevices[0];
33
+ }
34
+
35
+ /** A device's user-facing text, shared by the toolbar's menu items and the frame caption: the
36
+ * label with its width when one is fixed, so the value reaches assistive tech at pick time. */
37
+ export function deviceLabel(d: PreviewDevice): string {
38
+ return d.width === null ? d.label : `${d.label} · ${d.width} px`;
39
+ }
40
+
41
+ /**
42
+ * Build the preview iframe's srcdoc: a complete document linking the site's stylesheets around
43
+ * the rendered entry html. The html comes from the site's floored render pipeline, which already
44
+ * stripped scripts and event handlers, so it embeds unescaped; the frame's empty `sandbox` is
45
+ * belt and braces over that floor. The parameter is the flat `ResolvedPreview` shape `editLoad`
46
+ * ships, so the per-concept map can never reach the frame document by construction.
47
+ * `preview` null (a site without the adapter knob) yields a styleless but complete document.
48
+ */
49
+ export function buildPreviewDoc(html: string, preview: ResolvedPreview | null): string {
50
+ const links = (preview?.stylesheets ?? [])
51
+ .map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
52
+ .join('\n');
53
+ const bodyAttrs = preview?.bodyClass ? ` class="${escapeHtml(preview.bodyClass)}"` : '';
54
+ const content = preview?.containerClass
55
+ ? `<div class="${escapeHtml(preview.containerClass)}">${html}</div>`
56
+ : html;
57
+ // The reset sits BEFORE the site links so the site's CSS wins every collision: it only clears
58
+ // the default body margin and pins a white ground for sheets that assume one.
59
+ //
60
+ // The base tag is what makes links inert. The empty sandbox alone does not: a sandboxed
61
+ // context may still navigate itself, and a srcdoc document resolves relative hrefs against the
62
+ // parent's base URL, so a clicked fragment or root link could render the admin login inside
63
+ // the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
64
+ // (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
65
+ return [
66
+ '<!doctype html>',
67
+ '<html>',
68
+ '<head>',
69
+ '<meta charset="utf-8">',
70
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
71
+ '<base target="_blank">',
72
+ '<style>body{margin:0;background:#fff}</style>',
73
+ links,
74
+ '</head>',
75
+ `<body${bodyAttrs}>`,
76
+ content,
77
+ '</body>',
78
+ '</html>',
79
+ ]
80
+ .filter((line) => line !== '')
81
+ .join('\n');
82
+ }
@@ -44,6 +44,7 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }: Compose
44
44
  registry: adapter.registry,
45
45
  icons: adapter.icons,
46
46
  navMenu: adapter.navMenu,
47
+ preview: adapter.preview,
47
48
  assets: adapter.assets,
48
49
  adminPanels,
49
50
  fieldTypes,
@@ -6,6 +6,7 @@ import { unified } from 'unified';
6
6
  import remarkParse from 'remark-parse';
7
7
  import remarkGfm from 'remark-gfm';
8
8
  import { visit } from 'unist-util-visit';
9
+ import type { Link } from 'mdast';
9
10
  import { isValidId } from './ids.js';
10
11
 
11
12
  /** A resolved reference to a content entry by its concept and permanent id. */
@@ -59,3 +60,30 @@ export function extractCairnLinks(body: string): CairnRef[] {
59
60
  });
60
61
  return refs;
61
62
  }
63
+
64
+ /**
65
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
66
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
67
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
68
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
69
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
70
+ */
71
+ export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
72
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
73
+ const spans: { start: number; end: number }[] = [];
74
+ visit(tree, 'link', (node: Link) => {
75
+ if (node.url !== oldHref) return;
76
+ const start = node.position?.start?.offset;
77
+ const end = node.position?.end?.offset;
78
+ if (start == null || end == null) return;
79
+ spans.push({ start, end });
80
+ });
81
+ spans.sort((a, b) => b.start - a.start);
82
+ let out = doc;
83
+ for (const span of spans) {
84
+ const src = out.slice(span.start, span.end);
85
+ const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
86
+ out = out.slice(0, span.start) + rewritten + out.slice(span.end);
87
+ }
88
+ return out;
89
+ }