@glw907/cairn-cms 0.37.1 → 0.40.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 (69) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +6 -5
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +13 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +575 -70
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +202 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/LoginPage.svelte +16 -4
  16. package/dist/components/LoginPage.svelte.d.ts +3 -1
  17. package/dist/components/MarkdownEditor.svelte +80 -34
  18. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  19. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  20. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  21. package/dist/components/RenameDialog.svelte +13 -4
  22. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  23. package/dist/components/WebLinkDialog.svelte +89 -0
  24. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  25. package/dist/components/cairn-admin.css +353 -4
  26. package/dist/components/editor-highlight.d.ts +9 -0
  27. package/dist/components/editor-highlight.js +62 -0
  28. package/dist/components/markdown-directives.d.ts +7 -0
  29. package/dist/components/markdown-directives.js +22 -0
  30. package/dist/components/markdown-format.d.ts +1 -1
  31. package/dist/components/markdown-format.js +91 -12
  32. package/dist/content/pending.d.ts +9 -0
  33. package/dist/content/pending.js +24 -0
  34. package/dist/diagnostics/conditions.js +16 -0
  35. package/dist/email.d.ts +20 -1
  36. package/dist/email.js +25 -0
  37. package/dist/github/branches.d.ts +11 -0
  38. package/dist/github/branches.js +75 -0
  39. package/dist/log/events.d.ts +1 -1
  40. package/dist/sveltekit/auth-routes.d.ts +16 -3
  41. package/dist/sveltekit/auth-routes.js +47 -28
  42. package/dist/sveltekit/content-routes.d.ts +22 -1
  43. package/dist/sveltekit/content-routes.js +312 -72
  44. package/dist/sveltekit/index.d.ts +1 -1
  45. package/package.json +3 -2
  46. package/src/lib/components/AdminLayout.svelte +53 -0
  47. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  48. package/src/lib/components/ConceptList.svelte +13 -3
  49. package/src/lib/components/DeleteDialog.svelte +18 -7
  50. package/src/lib/components/EditPage.svelte +575 -70
  51. package/src/lib/components/EditorToolbar.svelte +202 -29
  52. package/src/lib/components/LinkPicker.svelte +14 -6
  53. package/src/lib/components/LoginPage.svelte +16 -4
  54. package/src/lib/components/MarkdownEditor.svelte +80 -34
  55. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  56. package/src/lib/components/RenameDialog.svelte +13 -4
  57. package/src/lib/components/WebLinkDialog.svelte +89 -0
  58. package/src/lib/components/cairn-admin.css +26 -4
  59. package/src/lib/components/editor-highlight.ts +67 -0
  60. package/src/lib/components/markdown-directives.ts +23 -0
  61. package/src/lib/components/markdown-format.ts +118 -13
  62. package/src/lib/content/pending.ts +24 -0
  63. package/src/lib/diagnostics/conditions.ts +16 -0
  64. package/src/lib/email.ts +31 -1
  65. package/src/lib/github/branches.ts +83 -0
  66. package/src/lib/log/events.ts +3 -0
  67. package/src/lib/sveltekit/auth-routes.ts +59 -29
  68. package/src/lib/sveltekit/content-routes.ts +391 -73
  69. package/src/lib/sveltekit/index.ts +1 -1
@@ -28,7 +28,14 @@ interface Props {
28
28
  /**
29
29
  * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
30
30
  * markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
31
- * `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
31
+ * `?/save` action. The title field is hoisted above the editor card as the document title; the
32
+ * remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
33
+ * toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
34
+ * swap the editing surface for the rendered preview inside the same card; every visit lands on
35
+ * Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
36
+ * and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
37
+ * pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
38
+ * transient flashes, and the editor card's footer holds the word count and the Markdown help.
32
39
  */
33
40
  declare const EditPage: import("svelte").Component<Props, {}, "">;
34
41
  type EditPage = ReturnType<typeof EditPage>;
@@ -1,35 +1,62 @@
1
1
  <!--
2
2
  @component
3
- The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
4
- asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
5
- cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
6
- house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
3
+ The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
4
+ More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
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).
7
8
  -->
8
9
  <script lang="ts">
10
+ import type { Snippet } from 'svelte';
9
11
  import type { FormatKind } from './markdown-format.js';
10
12
 
11
13
  interface Props {
12
14
  /** Apply a markdown transform to the editor's current selection. */
13
15
  format: (kind: FormatKind) => void;
16
+ /** Which pane the editor card shows; the segmented control reflects it. */
17
+ mode: 'write' | 'preview';
18
+ /** Ask the host to switch panes. */
19
+ onMode: (m: 'write' | 'preview') => void;
20
+ /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
21
+ insertControls?: Snippet;
14
22
  }
15
23
 
16
- let { format }: Props = $props();
24
+ let { format, mode, onMode, insertControls }: Props = $props();
17
25
 
18
26
  // Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
19
27
  // markup stays declarative (no per-icon raw html). Paths follow the house outline style.
20
- const buttons: { kind: FormatKind; label: string; paths: string[] }[] = [
21
- { kind: 'bold', label: 'Bold', paths: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'] },
22
- { kind: 'italic', label: 'Italic', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
23
- { kind: 'heading', label: 'Heading', paths: ['M6 4v16', 'M18 4v16', 'M6 12h12'] },
28
+ type ToolButton = { kind: FormatKind; label: string; paths: string[] };
29
+
30
+ // Labels carry the shortcut where one exists. "Ctrl" is written literally for macOS readers
31
+ // too; detecting the platform buys little for what it costs.
32
+ const textButtons: ToolButton[] = [
33
+ { kind: 'bold', label: 'Bold (Ctrl+B)', paths: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'] },
34
+ { kind: 'italic', label: 'Italic (Ctrl+I)', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
35
+ ];
36
+
37
+ const structureButtons: ToolButton[] = [
38
+ {
39
+ kind: 'h2',
40
+ label: 'Heading',
41
+ paths: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1'],
42
+ },
24
43
  {
25
- kind: 'link',
26
- label: 'Link',
44
+ kind: 'h3',
45
+ label: 'Smaller heading',
27
46
  paths: [
28
- 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
29
- 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
47
+ 'M4 12h8',
48
+ 'M4 18V6',
49
+ 'M12 18V6',
50
+ 'M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2',
51
+ 'M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2',
30
52
  ],
31
53
  },
32
54
  { kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
55
+ {
56
+ kind: 'ol',
57
+ label: 'Numbered list',
58
+ paths: ['M10 12h11', 'M10 18h11', 'M10 6h11', 'M4 10h2', 'M4 6h1v4', 'M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'],
59
+ },
33
60
  {
34
61
  kind: 'quote',
35
62
  label: 'Quote',
@@ -38,24 +65,170 @@ house style (24x24 viewBox, `currentColor`, round caps), so the row matches the
38
65
  'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
39
66
  ],
40
67
  },
41
- { kind: 'code', label: 'Code', paths: ['M16 18l6-6-6-6', 'M8 6l-6 6 6 6'] },
42
68
  ];
69
+
70
+ const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
71
+
72
+ const moreItems: { kind: FormatKind; label: string }[] = [
73
+ { kind: 'strike', label: 'Strikethrough' },
74
+ { kind: 'code', label: 'Inline code' },
75
+ { kind: 'codeblock', label: 'Code block' },
76
+ { kind: 'table', label: 'Table' },
77
+ { kind: 'hr', label: 'Horizontal rule' },
78
+ { kind: 'task', label: 'Task list' },
79
+ ];
80
+
81
+ // The More menu's popover element and its open state, mirrored from the toggle event into
82
+ // aria-expanded on the trigger.
83
+ let moreMenu = $state<HTMLUListElement | null>(null);
84
+ let moreOpen = $state(false);
85
+
86
+ function pickMore(kind: FormatKind) {
87
+ 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();
90
+ }
91
+
92
+ let toolbarEl = $state<HTMLDivElement | null>(null);
93
+ // The roving tab stop's position among the strip's enabled top-level controls. The Write/Preview
94
+ // tabs join the toolbar's roving order instead of managing their own arrow keys: the ARIA toolbar
95
+ // pattern allows either, and one arrow model over the whole strip is the simpler of the two.
96
+ let roving = $state(0);
97
+
98
+ /** The strip's top-level controls in DOM order: every enabled button outside the More menu's
99
+ * popover and outside the insert controls' dialogs. The host's insertControls render their own
100
+ * buttons, so the set is queried, not declared. */
101
+ function rovingControls(): HTMLElement[] {
102
+ if (!toolbarEl) return [];
103
+ return Array.from(toolbarEl.querySelectorAll<HTMLElement>('button')).filter(
104
+ (el) => !el.hasAttribute('disabled') && !el.closest('[popover]') && !el.closest('dialog'),
105
+ );
106
+ }
107
+
108
+ // Keep exactly one tab stop. Runs on mount (the snippet's buttons render synchronously, so the
109
+ // first pass sees them) and again whenever the stop moves or a mode switch changes which
110
+ // controls are enabled.
111
+ $effect(() => {
112
+ void mode;
113
+ const items = rovingControls();
114
+ if (items.length === 0) return;
115
+ const stop = Math.min(roving, items.length - 1);
116
+ for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
117
+ });
118
+
119
+ function onToolbarKeydown(e: KeyboardEvent) {
120
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
121
+ // Leave the keys alone inside the open More menu; its items are not part of the roving order.
122
+ if ((e.target as HTMLElement | null)?.closest('[popover]')) return;
123
+ const items = rovingControls();
124
+ if (items.length === 0) return;
125
+ const current = items.indexOf(document.activeElement as HTMLElement);
126
+ const base = current >= 0 ? current : Math.min(roving, items.length - 1);
127
+ roving = (base + (e.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
128
+ items[roving].focus();
129
+ e.preventDefault();
130
+ }
43
131
  </script>
44
132
 
45
- <div class="border-base-300 bg-base-200 flex gap-1 border-b p-1" role="toolbar" aria-label="Formatting">
46
- {#each buttons as button (button.kind)}
47
- <button
48
- type="button"
49
- class="btn btn-ghost btn-sm btn-square"
50
- aria-label={button.label}
51
- title={button.label}
52
- onclick={() => format(button.kind)}
53
- >
54
- <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
55
- {#each button.paths as d (d)}
56
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
57
- {/each}
58
- </svg>
59
- </button>
133
+ {#snippet strokeIcon(paths: string[])}
134
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
135
+ {#each paths as d (d)}
136
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
137
+ {/each}
138
+ </svg>
139
+ {/snippet}
140
+
141
+ {#snippet glyphButton(button: ToolButton)}
142
+ <button
143
+ type="button"
144
+ class="btn btn-ghost btn-sm btn-square"
145
+ aria-label={button.label}
146
+ title={button.label}
147
+ disabled={mode === 'preview'}
148
+ onclick={() => format(button.kind)}
149
+ >
150
+ {@render strokeIcon(button.paths)}
151
+ </button>
152
+ {/snippet}
153
+
154
+ {#snippet tab(m: 'write' | 'preview', label: string)}
155
+ <button
156
+ type="button"
157
+ role="tab"
158
+ id={`cairn-tab-${m}`}
159
+ aria-selected={mode === m}
160
+ aria-controls={`cairn-pane-${m}`}
161
+ class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
162
+ onclick={() => onMode(m)}
163
+ >
164
+ {label}
165
+ </button>
166
+ {/snippet}
167
+
168
+ <!-- tabindex -1: the container is never a tab stop itself; the roving tabindex on its controls
169
+ carries keyboard entry, per the ARIA toolbar pattern. -->
170
+ <div
171
+ bind:this={toolbarEl}
172
+ class="bg-base-100 flex flex-wrap items-center gap-1 border-b border-[var(--cairn-card-border)] p-1"
173
+ role="toolbar"
174
+ aria-label="Formatting"
175
+ tabindex="-1"
176
+ onkeydown={onToolbarKeydown}
177
+ >
178
+ {#each textButtons as button (button.kind)}
179
+ {@render glyphButton(button)}
180
+ {/each}
181
+
182
+ <div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
183
+
184
+ {#each structureButtons as button (button.kind)}
185
+ {@render glyphButton(button)}
60
186
  {/each}
187
+ <!-- The More menu is a DaisyUI v5 popover dropdown: click to open (never focus-in-transit),
188
+ Escape and light dismiss from the Popover API, and the anchor-name/position-anchor pair
189
+ places the panel under its trigger. -->
190
+ <button
191
+ type="button"
192
+ class="btn btn-ghost btn-sm btn-square"
193
+ aria-label="More formatting"
194
+ title="More formatting"
195
+ aria-expanded={moreOpen}
196
+ popovertarget="cairn-more-formatting-menu"
197
+ style="anchor-name:--cairn-more-formatting"
198
+ disabled={mode === 'preview'}
199
+ >
200
+ {@render strokeIcon(ellipsisPaths)}
201
+ </button>
202
+ <ul
203
+ bind:this={moreMenu}
204
+ popover="auto"
205
+ id="cairn-more-formatting-menu"
206
+ style="position-anchor:--cairn-more-formatting"
207
+ ontoggle={(e) => (moreOpen = e.newState === 'open')}
208
+ class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
209
+ >
210
+ {#each moreItems as item (item.kind)}
211
+ <li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
212
+ {/each}
213
+ </ul>
214
+
215
+ {#if insertControls}
216
+ <div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
217
+ <!-- The host's controls carry their own disabled state in Preview; this wrapper just keeps
218
+ any stray pointer target in the snippet inert while the pane is read-only. -->
219
+ <div
220
+ class="flex items-center gap-1"
221
+ class:pointer-events-none={mode === 'preview'}
222
+ class:opacity-50={mode === 'preview'}
223
+ >
224
+ {@render insertControls()}
225
+ </div>
226
+ {/if}
227
+
228
+ <!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
229
+ the strip inside the same editor card. -->
230
+ <div class="join ml-auto" role="tablist" aria-label="Editor view">
231
+ {@render tab('write', 'Write')}
232
+ {@render tab('preview', 'Preview')}
233
+ </div>
61
234
  </div>
@@ -1,13 +1,21 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { FormatKind } from './markdown-format.js';
2
3
  interface Props {
3
4
  /** Apply a markdown transform to the editor's current selection. */
4
5
  format: (kind: FormatKind) => void;
6
+ /** Which pane the editor card shows; the segmented control reflects it. */
7
+ mode: 'write' | 'preview';
8
+ /** Ask the host to switch panes. */
9
+ onMode: (m: 'write' | 'preview') => void;
10
+ /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
11
+ insertControls?: Snippet;
5
12
  }
6
13
  /**
7
- * The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
8
- * asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
9
- * cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
10
- * house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
14
+ * The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
15
+ * More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
16
+ * 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).
11
19
  */
12
20
  declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
13
21
  type EditorToolbar = ReturnType<typeof EditorToolbar>;
@@ -4,7 +4,7 @@ The "Link to page" control and its modal. It lists the site's posts and pages fr
4
4
  manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
5
5
  showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
6
6
  editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
7
- conventions. The plain-URL link stays the toolbar's link button; this is for an internal target.
7
+ conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
8
8
  -->
9
9
  <script lang="ts">
10
10
  import type { LinkTarget } from '../content/manifest.js';
@@ -15,9 +15,14 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
15
15
  linkTargets: LinkTarget[];
16
16
  /** Insert an inline cairn link at the editor cursor. */
17
17
  insert: (href: string, title: string) => void;
18
+ /** Disable the trigger; the host sets it while Preview shows. */
19
+ disabled?: boolean;
20
+ /** Render the built-in Link to page trigger. False mounts only the dialog, for a host that
21
+ * supplies its own trigger and opens the dialog through the exported open(). */
22
+ trigger?: boolean;
18
23
  }
19
24
 
20
- let { linkTargets, insert }: Props = $props();
25
+ let { linkTargets, insert, disabled = false, trigger = true }: Props = $props();
21
26
 
22
27
  let dialog = $state<HTMLDialogElement | null>(null);
23
28
  let query = $state('');
@@ -48,7 +53,8 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
48
53
  .sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
49
54
  });
50
55
 
51
- function open() {
56
+ /** Open the picker programmatically, for a host that drives it without the trigger. */
57
+ export function open() {
52
58
  query = '';
53
59
  dialog?.showModal();
54
60
  }
@@ -61,9 +67,11 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
61
67
  }
62
68
  </script>
63
69
 
64
- <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
65
- Link to page
66
- </button>
70
+ {#if trigger}
71
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" {disabled} onclick={open}>
72
+ Link to page
73
+ </button>
74
+ {/if}
67
75
 
68
76
  <dialog class="modal" aria-labelledby="cairn-link-dialog-title" bind:this={dialog}>
69
77
  <div class="modal-box">
@@ -4,14 +4,21 @@ interface Props {
4
4
  linkTargets: LinkTarget[];
5
5
  /** Insert an inline cairn link at the editor cursor. */
6
6
  insert: (href: string, title: string) => void;
7
+ /** Disable the trigger; the host sets it while Preview shows. */
8
+ disabled?: boolean;
9
+ /** Render the built-in Link to page trigger. False mounts only the dialog, for a host that
10
+ * supplies its own trigger and opens the dialog through the exported open(). */
11
+ trigger?: boolean;
7
12
  }
8
13
  /**
9
14
  * The "Link to page" control and its modal. It lists the site's posts and pages from the committed
10
15
  * manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
11
16
  * showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
12
17
  * editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
13
- * conventions. The plain-URL link stays the toolbar's link button; this is for an internal target.
18
+ * conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
14
19
  */
15
- declare const LinkPicker: import("svelte").Component<Props, {}, "">;
20
+ declare const LinkPicker: import("svelte").Component<Props, {
21
+ open: () => void;
22
+ }, "">;
16
23
  type LinkPicker = ReturnType<typeof LinkPicker>;
17
24
  export default LinkPicker;
@@ -17,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
17
17
  interface Props {
18
18
  /** The login load's data: the site name, an optional error, and the CSRF token. */
19
19
  data: { siteName: string; error: string | null; csrf: string };
20
- /** The action result: `sent` is true once a request was accepted. */
21
- form: { sent?: boolean } | null;
20
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
21
+ * neutral, send-error, and throttled outcomes. */
22
+ form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
22
23
  }
23
24
 
24
25
  let { data, form }: Props = $props();
@@ -52,7 +53,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
52
53
  <div data-theme="cairn-admin" bind:this={rootEl}>
53
54
  <div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
54
55
  <div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
55
- {#if form?.sent && !dismissed}
56
+ {#if (form?.status === 'sent' || form?.sent) && !dismissed}
56
57
  <!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
57
58
  instruction. The fallback help sits in a gentle inset note below. -->
58
59
  <div role="status" class="flex flex-col items-center text-center">
@@ -86,7 +87,18 @@ the allowlist, so the page never leaks membership (spec §7.1).
86
87
  <div class="mb-6 flex justify-center">{@render brand()}</div>
87
88
  <h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
88
89
  <p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
89
- {#if data.error}
90
+ {#if form?.status === 'send_error'}
91
+ <div role="alert" class="alert alert-warning mb-3 text-sm">
92
+ We're having trouble sending sign-in links right now. Please contact the site owner.
93
+ </div>
94
+ {:else if form?.status === 'throttled'}
95
+ <div role="status" class="alert mb-3 text-sm">
96
+ You requested a link recently. Check your inbox, or wait a minute and try again.
97
+ </div>
98
+ {/if}
99
+ <!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
100
+ send failure never shows the stale expired-link alert alongside the new state. -->
101
+ {#if data.error && !form?.status}
90
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
91
103
  {/if}
92
104
  <form method="POST" class="flex flex-col gap-3">
@@ -6,9 +6,11 @@ interface Props {
6
6
  error: string | null;
7
7
  csrf: string;
8
8
  };
9
- /** The action result: `sent` is true once a request was accepted. */
9
+ /** The action result. `sent` is true once a request was accepted; `status` discriminates the
10
+ * neutral, send-error, and throttled outcomes. */
10
11
  form: {
11
12
  sent?: boolean;
13
+ status?: 'sent' | 'send_error' | 'throttled';
12
14
  } | null;
13
15
  }
14
16
  /**
@@ -1,15 +1,15 @@
1
1
  <!--
2
2
  @component
3
3
  The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
4
- value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
4
+ value and cursor-edit callbacks. CodeMirror is client-only, so it mounts after the component does
5
5
  through a dynamic import; until then a plain textarea carries the value so the form still submits, and
6
- the hidden field mirrors the value throughout. The edit surface owns its toolbar; the design-accurate
7
- preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
6
+ the hidden field mirrors the value throughout. The host owns the toolbar and the card chrome, driving
7
+ selection transforms through the registerFormat seam; the design-accurate preview lives in EditPage
8
+ through the adapter's render. Swapping the editor stays a one-file change.
8
9
  -->
9
10
  <script lang="ts">
10
11
  import { onMount, onDestroy } from 'svelte';
11
- import EditorToolbar from './EditorToolbar.svelte';
12
- import { applyMarkdownFormat, insertInlineLink, type FormatKind } from './markdown-format.js';
12
+ import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
13
13
 
14
14
  interface Props {
15
15
  /** The markdown source; bindable so the parent reads edits back. */
@@ -20,12 +20,24 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
20
20
  registerInsert?: (insert: (text: string) => void) => void;
21
21
  /** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
22
22
  registerInsertLink?: (insert: (href: string, title: string) => void) => void;
23
+ /** Receives a `() => string` returning the selected text; the web link dialog reads it. */
24
+ registerGetSelection?: (get: () => string) => void;
25
+ /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
26
+ registerFormat?: (format: (kind: FormatKind) => void) => void;
23
27
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
24
28
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
25
29
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
26
30
  }
27
31
 
28
- let { value = $bindable(), name, registerInsert, registerInsertLink, completionSources = [] }: Props = $props();
32
+ let {
33
+ value = $bindable(),
34
+ name,
35
+ registerInsert,
36
+ registerInsertLink,
37
+ registerGetSelection,
38
+ registerFormat,
39
+ completionSources = [],
40
+ }: Props = $props();
29
41
 
30
42
  let host = $state<HTMLDivElement | null>(null);
31
43
  let mounted = $state(false);
@@ -41,19 +53,47 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
41
53
  const commandsMod = await import('@codemirror/commands');
42
54
  const languageMod = await import('@codemirror/language');
43
55
  const autocompleteMod = await import('@codemirror/autocomplete');
56
+ const highlightMod = await import('./editor-highlight.js');
44
57
 
45
58
  if (!host) return;
46
59
 
47
60
  const { EditorView, keymap } = viewMod;
61
+ // Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
62
+ // tooltip above all) renders dark-on-dark instead of light-on-dark.
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.
65
+ const directiveInk = {
66
+ backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
67
+ color: 'var(--color-accent)',
68
+ };
48
69
  const theme = EditorView.theme(
49
70
  {
50
- '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.875rem' },
51
- '.cm-content': { fontFamily: 'ui-monospace, monospace', padding: '0.75rem', lineHeight: '1.7' },
71
+ '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.9375rem' },
72
+ // The 50vh floor keeps a short entry reading as a writing surface, and because the
73
+ // contenteditable content area carries the height, a click in the empty space below the
74
+ // text still lands in the editor and focuses it.
75
+ '.cm-content': {
76
+ fontFamily: 'ui-monospace, monospace',
77
+ padding: '0.875rem 1.25rem',
78
+ lineHeight: '1.8',
79
+ minHeight: '50vh',
80
+ },
52
81
  '.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
53
- '&.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '-2px' },
82
+ // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
83
+ // focused text-entry surface as keyboard-modal, so a 2px ring would shout through every
84
+ // typing session. One subtle line keeps focus visible (WCAG 2.4.7) without competing
85
+ // with the manuscript. The 70% primary mix clears the 3:1 non-text contrast floor
86
+ // (WCAG 1.4.11) on both themes (3.23:1 light, 3.32:1 dark), where 45% measured near 2:1.
87
+ '&.cm-focused': {
88
+ outline: '1px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
89
+ outlineOffset: '-1px',
90
+ },
54
91
  '.cm-line': { padding: '0' },
92
+ '.cm-cairn-directive-fence': directiveInk,
93
+ '.cm-cairn-directive-leaf': directiveInk,
94
+ '.cm-cairn-directive-inline': directiveInk,
55
95
  },
56
- { dark: false },
96
+ { dark: isDark },
57
97
  );
58
98
 
59
99
  view = new EditorView({
@@ -70,7 +110,9 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
70
110
  [autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
71
111
  : []),
72
112
  EditorView.lineWrapping,
73
- languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
113
+ languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
114
+ highlightMod.cairnDirectivePlugin(),
115
+ EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
74
116
  theme,
75
117
  EditorView.updateListener.of((update) => {
76
118
  if (update.docChanged) value = update.state.doc.toString();
@@ -81,6 +123,8 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
81
123
 
82
124
  registerInsert?.(insertAtCursor);
83
125
  registerInsertLink?.(insertLink);
126
+ registerGetSelection?.(selectedText);
127
+ registerFormat?.(applyFormat);
84
128
  mounted = true;
85
129
  });
86
130
 
@@ -108,6 +152,20 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
108
152
  view.focus();
109
153
  }
110
154
 
155
+ // Run a pure selection transform over the mounted editor: hand it the document and selection,
156
+ // dispatch the document and selection it returns, and put focus back on the surface.
157
+ function transformSelection(transform: (doc: string, from: number, to: number) => FormatResult) {
158
+ if (!view) return;
159
+ const { from, to } = view.state.selection.main;
160
+ const doc = view.state.doc.toString();
161
+ const next = transform(doc, from, to);
162
+ view.dispatch({
163
+ changes: { from: 0, to: doc.length, insert: next.doc },
164
+ selection: { anchor: next.from, head: next.to },
165
+ });
166
+ view.focus();
167
+ }
168
+
111
169
  function insertLink(href: string, title: string) {
112
170
  if (!view) {
113
171
  // The editor has not mounted yet; append the link to the raw value so a pick is never lost,
@@ -116,35 +174,23 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
116
174
  value = value ? `${value} ${link}` : link;
117
175
  return;
118
176
  }
177
+ transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
178
+ }
179
+
180
+ function selectedText(): string {
181
+ if (!view) return '';
119
182
  const { from, to } = view.state.selection.main;
120
- const doc = view.state.doc.toString();
121
- const next = insertInlineLink(doc, from, to, href, title);
122
- view.dispatch({
123
- changes: { from: 0, to: doc.length, insert: next.doc },
124
- selection: { anchor: next.from, head: next.to },
125
- });
126
- view.focus();
183
+ return view.state.sliceDoc(from, to);
127
184
  }
128
185
 
129
186
  function applyFormat(kind: FormatKind) {
130
- if (!view) return;
131
- const { from, to } = view.state.selection.main;
132
- const doc = view.state.doc.toString();
133
- const next = applyMarkdownFormat(doc, from, to, kind);
134
- view.dispatch({
135
- changes: { from: 0, to: doc.length, insert: next.doc },
136
- selection: { anchor: next.from, head: next.to },
137
- });
138
- view.focus();
187
+ transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
139
188
  }
140
189
  </script>
141
190
 
142
191
  <input type="hidden" {name} {value} />
143
192
 
144
- <div class="border-base-300 overflow-hidden rounded-box border">
145
- <EditorToolbar format={applyFormat} />
146
- <div bind:this={host}></div>
147
- {#if !mounted}
148
- <textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
149
- {/if}
150
- </div>
193
+ <div bind:this={host}></div>
194
+ {#if !mounted}
195
+ <textarea class="textarea min-h-[50vh] w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
196
+ {/if}