@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,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { FormatKind } from './markdown-format.js';
3
+ import { type PreviewDeviceId } from './preview-doc.js';
3
4
  interface Props {
4
5
  /** Apply a markdown transform to the editor's current selection. */
5
6
  format: (kind: FormatKind) => void;
@@ -7,6 +8,11 @@ interface Props {
7
8
  mode: 'write' | 'preview';
8
9
  /** Ask the host to switch panes. */
9
10
  onMode: (m: 'write' | 'preview') => void;
11
+ /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
12
+ device?: PreviewDeviceId;
13
+ /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
14
+ * while Preview shows. */
15
+ onDevice?: (id: PreviewDeviceId) => void;
10
16
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
11
17
  insertControls?: Snippet;
12
18
  }
@@ -14,8 +20,10 @@ interface Props {
14
20
  * The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
15
21
  * More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
16
22
  * right. Format buttons ask the host to transform the editor's current selection; the host supplies the
17
- * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
18
- * are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
23
+ * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
24
+ * Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
25
+ * widths, reported to the host through `onDevice`. The glyphs are stroke SVG icons in the admin's
26
+ * house style (24x24 viewBox, `currentColor`, round caps).
19
27
  */
20
28
  declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
21
29
  type EditorToolbar = ReturnType<typeof EditorToolbar>;
@@ -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>
@@ -14,7 +14,7 @@ interface Props {
14
14
  } | null;
15
15
  }
16
16
  /**
17
- * The magic-link sign-in page. A plain form POST to the page's default action (the engine's
17
+ * The magic-link sign-in page. A plain form POST to the named `?/request` action (the engine's
18
18
  * `requestAction`); no client SDK. The success message is identical whether or not the email is on
19
19
  * the allowlist, so the page never leaks membership (spec §7.1).
20
20
  */
@@ -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>
@@ -15,7 +15,8 @@ interface Props {
15
15
  * The owner-gated editor management surface: a table of editors with role-flip and remove actions,
16
16
  * and an add-editor form. The acting owner's own row disables its destructive controls; the
17
17
  * last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
18
- * named `?/setRole`, `?/remove`, and `?/add` actions.
18
+ * named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
19
+ * dispatcher defines.
19
20
  */
20
21
  declare const ManageEditors: import("svelte").Component<Props, {}, "">;
21
22
  type ManageEditors = ReturnType<typeof ManageEditors>;
@@ -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
  },
@@ -176,6 +176,14 @@
176
176
  outline-offset: -1px;
177
177
  }
178
178
 
179
+ :where([data-theme="cairn-admin"], [data-theme="cairn-admin-dark"]) .menu li > button:not(.btn) {
180
+ font: inherit;
181
+ color: inherit;
182
+ text-align: start;
183
+ background-color: #0000;
184
+ border: 0 solid;
185
+ }
186
+
179
187
  :where([data-theme="cairn-admin"], [data-theme="cairn-admin-dark"]) :is(a, button, input, textarea, select, summary, [tabindex]) {
180
188
  scroll-margin-top: 8.5rem;
181
189
  }
@@ -3400,15 +3408,6 @@
3400
3408
  }
3401
3409
  }
3402
3410
 
3403
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .join-item:where(:not(:first-child, :disabled, [disabled], .btn-disabled)) {
3404
- margin-block-start: 0;
3405
- margin-inline-start: calc(var(--border, 1px) * -1);
3406
- }
3407
-
3408
- :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .join-item:where(:is(:disabled, [disabled], .btn-disabled)) {
3409
- border-width: var(--border, 1px) 0 var(--border, 1px) var(--border, 1px);
3410
- }
3411
-
3412
3411
  @layer daisyui.l1.l2.l3 {
3413
3412
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .modal-action {
3414
3413
  justify-content: flex-end;
@@ -3503,6 +3502,10 @@
3503
3502
  margin-bottom: calc(var(--spacing) * 7);
3504
3503
  }
3505
3504
 
3505
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .-ml-px {
3506
+ margin-left: -1px;
3507
+ }
3508
+
3506
3509
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ml-1 {
3507
3510
  margin-left: calc(var(--spacing) * 1);
3508
3511
  }
@@ -3919,6 +3922,10 @@
3919
3922
  height: calc(var(--spacing) * 16);
3920
3923
  }
3921
3924
 
3925
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-\[70vh\] {
3926
+ height: 70vh;
3927
+ }
3928
+
3922
3929
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-\[60vh\] {
3923
3930
  max-height: 60vh;
3924
3931
  }
@@ -4037,6 +4044,10 @@
4037
4044
  max-width: 30%;
4038
4045
  }
4039
4046
 
4047
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-full {
4048
+ max-width: 100%;
4049
+ }
4050
+
4040
4051
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-md {
4041
4052
  max-width: var(--container-md);
4042
4053
  }
@@ -4337,6 +4348,16 @@
4337
4348
  border-radius: var(--radius-xl);
4338
4349
  }
4339
4350
 
4351
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-l-none {
4352
+ border-top-left-radius: 0;
4353
+ border-bottom-left-radius: 0;
4354
+ }
4355
+
4356
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-r-none {
4357
+ border-top-right-radius: 0;
4358
+ border-bottom-right-radius: 0;
4359
+ }
4360
+
4340
4361
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border {
4341
4362
  border-style: var(--tw-border-style);
4342
4363
  border-width: 1px;
@@ -4996,6 +5017,12 @@
4996
5017
  transition-duration: var(--tw-duration, var(--default-transition-duration));
4997
5018
  }
4998
5019
 
5020
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .transition-\[width\] {
5021
+ transition-property: width;
5022
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
5023
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
5024
+ }
5025
+
4999
5026
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .transition-all {
5000
5027
  transition-property: all;
5001
5028
  transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -5418,6 +5445,14 @@
5418
5445
  --color-secondary-content: oklch(98% .004 75);
5419
5446
  --color-accent: oklch(54% .16 300);
5420
5447
  --color-accent-content: oklch(98% .012 300);
5448
+ --cairn-directive-band-1: 8%;
5449
+ --cairn-directive-band-2: 14%;
5450
+ --cairn-directive-band-3: 20%;
5451
+ --cairn-directive-ink-2: oklch(50% .16 300);
5452
+ --cairn-directive-ink-3: oklch(48% .16 300);
5453
+ --cairn-directive-rail-1: 75%;
5454
+ --cairn-directive-rail-2: 82%;
5455
+ --cairn-directive-rail-3: 90%;
5421
5456
  --color-neutral: oklch(32% .012 75);
5422
5457
  --color-neutral-content: oklch(96% .004 75);
5423
5458
  --color-info: oklch(52% .12 240);
@@ -5459,6 +5494,14 @@
5459
5494
  --color-secondary-content: oklch(20% .008 75);
5460
5495
  --color-accent: oklch(70% .14 300);
5461
5496
  --color-accent-content: oklch(20% .04 300);
5497
+ --cairn-directive-band-1: 10%;
5498
+ --cairn-directive-band-2: 16%;
5499
+ --cairn-directive-band-3: 22%;
5500
+ --cairn-directive-ink-2: oklch(74% .14 300);
5501
+ --cairn-directive-ink-3: oklch(78% .14 300);
5502
+ --cairn-directive-rail-1: 65%;
5503
+ --cairn-directive-rail-2: 75%;
5504
+ --cairn-directive-rail-3: 85%;
5462
5505
  --color-neutral: oklch(80% .01 75);
5463
5506
  --color-neutral-content: oklch(22% .008 75);
5464
5507
  --color-info: oklch(72% .12 240);
@@ -5487,6 +5530,11 @@
5487
5530
  box-sizing: border-box;
5488
5531
  }
5489
5532
 
5533
+ :where([data-theme="cairn-admin"], [data-theme="cairn-admin-dark"]) .menu li > :is(button, a):focus-visible {
5534
+ outline: 2px solid var(--color-primary);
5535
+ outline-offset: -2px;
5536
+ }
5537
+
5490
5538
  @media (prefers-reduced-motion: reduce) {
5491
5539
  [data-theme="cairn-admin"] *, [data-theme="cairn-admin-dark"] * {
5492
5540
  scroll-behavior: auto !important;
@@ -5,5 +5,6 @@ export declare function cairnHighlightStyle(): HighlightStyle;
5
5
  /** Line and mark decorations flagging remark-directive machinery. */
6
6
  export declare function cairnDirectivePlugin(): ViewPlugin<{
7
7
  decorations: DecorationSet;
8
+ depths: (number | null)[];
8
9
  update(update: ViewUpdate): void;
9
10
  }, undefined>;
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
5
5
  import { tags } from '@lezer/highlight';
6
6
  import { Decoration, ViewPlugin } from '@codemirror/view';
7
7
  import { RangeSetBuilder } from '@codemirror/state';
8
- import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
8
+ import { directiveLineKind, fenceDepths, findInlineDirectives } from './markdown-directives.js';
9
9
  /** Markdown token colors over the admin theme variables. */
10
10
  export function cairnHighlightStyle() {
11
11
  return HighlightStyle.define([
@@ -24,20 +24,39 @@ export function cairnHighlightStyle() {
24
24
  // The machinery lines explain themselves on hover, so an editor who has never seen ::: syntax
25
25
  // learns what the line is without leaving the page.
26
26
  const MACHINERY_HINT = 'Layout marker. Edit the text between these lines and leave this line as it is.';
27
- const fenceLine = Decoration.line({ class: 'cm-cairn-directive-fence', attributes: { title: MACHINERY_HINT } });
27
+ // Nesting deeper than three steps shares the third visual step; the depth model itself is unbounded.
28
+ const DEPTH_STEPS = [1, 2, 3];
29
+ const fenceLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }));
30
+ const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
28
31
  const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
29
32
  const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
30
- function buildDirectiveDecorations(view) {
33
+ // Depth needs the whole document, since a visible line's containers can open above the viewport.
34
+ // One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
35
+ // is well under a millisecond. The plugin caches the result, so the scan reruns only when the
36
+ // document changes and a scroll rebuilds the viewport decorations from the cached array.
37
+ function docDepths(view) {
38
+ const doc = view.state.doc;
39
+ const lines = [];
40
+ for (let n = 1; n <= doc.lines; n++)
41
+ lines.push(doc.line(n).text);
42
+ return fenceDepths(lines);
43
+ }
44
+ function buildDirectiveDecorations(view, depths) {
31
45
  const builder = new RangeSetBuilder();
32
46
  for (const { from, to } of view.visibleRanges) {
33
47
  for (let pos = from; pos <= to;) {
34
48
  const line = view.state.doc.lineAt(pos);
35
49
  const kind = directiveLineKind(line.text);
36
- if (kind === 'fence')
37
- builder.add(line.from, line.from, fenceLine);
50
+ const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
51
+ // A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
52
+ // inside a code block, outside any container); it gets no machinery treatment.
53
+ if (kind === 'fence' && depth > 0)
54
+ builder.add(line.from, line.from, fenceLines[depth - 1]);
38
55
  else if (kind === 'leaf')
39
56
  builder.add(line.from, line.from, leafLine);
40
- else {
57
+ else if (kind === null) {
58
+ if (depth > 0)
59
+ builder.add(line.from, line.from, contentLines[depth - 1]);
41
60
  for (const r of findInlineDirectives(line.text)) {
42
61
  builder.add(line.from + r.from, line.from + r.to, inlineMark);
43
62
  }
@@ -51,12 +70,16 @@ function buildDirectiveDecorations(view) {
51
70
  export function cairnDirectivePlugin() {
52
71
  return ViewPlugin.fromClass(class {
53
72
  decorations;
73
+ depths;
54
74
  constructor(view) {
55
- this.decorations = buildDirectiveDecorations(view);
75
+ this.depths = docDepths(view);
76
+ this.decorations = buildDirectiveDecorations(view, this.depths);
56
77
  }
57
78
  update(update) {
79
+ if (update.docChanged)
80
+ this.depths = docDepths(update.view);
58
81
  if (update.docChanged || update.viewportChanged)
59
- this.decorations = buildDirectiveDecorations(update.view);
82
+ this.decorations = buildDirectiveDecorations(update.view, this.depths);
60
83
  }
61
84
  }, { decorations: (v) => v.decorations });
62
85
  }
@@ -1,3 +1,4 @@
1
+ export { default as CairnAdmin } from './CairnAdmin.svelte';
1
2
  export { default as AdminLayout } from './AdminLayout.svelte';
2
3
  export { default as LoginPage } from './LoginPage.svelte';
3
4
  export { default as ConfirmPage } from './ConfirmPage.svelte';
@@ -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';
@@ -1,5 +1,15 @@
1
1
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
2
2
  export declare function directiveLineKind(line: string): 'fence' | 'leaf' | null;
3
+ /**
4
+ * The 1-based container depth each line sits at, or null outside any container. A named fence
5
+ * opens a container; a bare fence closes the most recent one (colon counts are not trusted for
6
+ * pairing, since authors vary them). An opener and its closer share the opener's depth, and a
7
+ * line between them carries the depth of its innermost container. Lines inside a fenced code
8
+ * block are plain content, so a documented ::: example cannot open a phantom container running
9
+ * to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
10
+ * count never goes below zero.
11
+ */
12
+ export declare function fenceDepths(lines: string[]): (number | null)[];
3
13
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
4
14
  export declare function findInlineDirectives(text: string): {
5
15
  from: number;
@@ -1,9 +1,17 @@
1
1
  // Remark-directive detection for the editor's machinery highlighting (spec: directive syntax is
2
2
  // styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
3
3
  // CodeMirror decoration plugin wraps them.
4
- const FENCE = /^\s{0,3}:::+\s*[\w-]*\s*(\{[^}]*\})?\s*$/;
4
+ // A container fence: three or more colons, then an optional name, an optional [label], and
5
+ // optional {attrs}, in remark-directive order. The name is captured so the depth scan below can
6
+ // tell an opener (named) from a closer (bare colons). Matching is tolerant of stray whitespace,
7
+ // the same posture as the leaf form: a slightly off fence should still read as machinery.
8
+ const FENCE = /^\s{0,3}:{3,}\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/;
5
9
  const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
6
10
  const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
11
+ // A fenced code block's delimiter: three or more backticks or tildes, indent-tolerant like the
12
+ // directive forms. The depth scan tracks these so a documented ::: example inside a code block
13
+ // never opens a real container.
14
+ const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
7
15
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
8
16
  export function directiveLineKind(line) {
9
17
  if (FENCE.test(line))
@@ -12,6 +20,51 @@ export function directiveLineKind(line) {
12
20
  return 'leaf';
13
21
  return null;
14
22
  }
23
+ /**
24
+ * The 1-based container depth each line sits at, or null outside any container. A named fence
25
+ * opens a container; a bare fence closes the most recent one (colon counts are not trusted for
26
+ * pairing, since authors vary them). An opener and its closer share the opener's depth, and a
27
+ * line between them carries the depth of its innermost container. Lines inside a fenced code
28
+ * block are plain content, so a documented ::: example cannot open a phantom container running
29
+ * to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
30
+ * count never goes below zero.
31
+ */
32
+ export function fenceDepths(lines) {
33
+ const depths = [];
34
+ let open = 0;
35
+ // The marker character that opened the current code block, or null outside one. Only a line
36
+ // opening with the same character closes it, so tildes inside a backtick block stay literal.
37
+ let codeMarker = null;
38
+ for (const line of lines) {
39
+ const code = CODE_FENCE.exec(line);
40
+ if (code) {
41
+ if (codeMarker === null)
42
+ codeMarker = code[1][0];
43
+ else if (code[1][0] === codeMarker)
44
+ 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
+ }
56
+ else if (fence[1]) {
57
+ open += 1;
58
+ depths.push(open);
59
+ }
60
+ else {
61
+ depths.push(Math.max(open, 1));
62
+ if (open > 0)
63
+ open -= 1;
64
+ }
65
+ }
66
+ return depths;
67
+ }
15
68
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
16
69
  export function findInlineDirectives(text) {
17
70
  const out = [];
@@ -22,11 +22,3 @@ export declare function insertInlineLink(doc: string, from: number, to: number,
22
22
  * is left in place.
23
23
  */
24
24
  export declare function unwrapCairnLink(doc: string, href: string): string;
25
- /**
26
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
27
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
28
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
29
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
30
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
31
- */
32
- export declare function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string;
@@ -157,31 +157,3 @@ export function unwrapCairnLink(doc, href) {
157
157
  }
158
158
  return out;
159
159
  }
160
- /**
161
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
162
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
163
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
164
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
165
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
166
- */
167
- export function rewriteCairnLink(doc, oldHref, newHref) {
168
- const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
169
- const spans = [];
170
- visit(tree, 'link', (node) => {
171
- if (node.url !== oldHref)
172
- return;
173
- const start = node.position?.start?.offset;
174
- const end = node.position?.end?.offset;
175
- if (start == null || end == null)
176
- return;
177
- spans.push({ start, end });
178
- });
179
- spans.sort((a, b) => b.start - a.start);
180
- let out = doc;
181
- for (const span of spans) {
182
- const src = out.slice(span.start, span.end);
183
- const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
184
- out = out.slice(0, span.start) + rewritten + out.slice(span.end);
185
- }
186
- return out;
187
- }