@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
@@ -2,18 +2,30 @@
2
2
  @component
3
3
  The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
4
4
  markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
5
- `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
5
+ `?/save` action. The title field is hoisted above the editor card as the document title; the
6
+ remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
7
+ toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
8
+ swap the editing surface for the rendered preview inside the same card; every visit lands on
9
+ Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
10
+ and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
11
+ pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
12
+ transient flashes, and the editor card's footer holds the word count and the Markdown help.
6
13
  -->
7
14
  <script lang="ts">
8
15
  import { untrack } from 'svelte';
16
+ import { beforeNavigate } from '$app/navigation';
9
17
  import CsrfField from './CsrfField.svelte';
10
18
  import MarkdownEditor from './MarkdownEditor.svelte';
11
- import ComponentInsertDialog from './ComponentInsertDialog.svelte';
19
+ import EditorToolbar from './EditorToolbar.svelte';
20
+ import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
12
21
  import LinkPicker from './LinkPicker.svelte';
22
+ import WebLinkDialog from './WebLinkDialog.svelte';
13
23
  import DeleteDialog from './DeleteDialog.svelte';
14
24
  import RenameDialog from './RenameDialog.svelte';
25
+ import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
15
26
  import { cairnLinkCompletionSource } from './link-completion.js';
16
- import { unwrapCairnLink } from './markdown-format.js';
27
+ import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
28
+ import { directiveLineKind } from './markdown-directives.js';
17
29
  import type { ComponentRegistry } from '../render/registry.js';
18
30
  import type { IconSet } from '../render/glyph.js';
19
31
  import type { EditData } from '../sveltekit/content-routes.js';
@@ -46,10 +58,138 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
46
58
  // True from the moment the save form submits until the navigation it triggers replaces the page,
47
59
  // so the Save button shows a calm "Saving…" state instead of looking inert.
48
60
  let saving = $state(false);
49
- let showPreview = $state(false);
61
+ // The same working state for the Publish button, which rides the edit form via formaction. The
62
+ // submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
63
+ // a publish is in flight.
64
+ let publishing = $state(false);
65
+ function onEditSubmit(e: SubmitEvent) {
66
+ const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
67
+ if (formaction === '?/publish') publishing = true;
68
+ else saving = true;
69
+ }
70
+ // Either in-flight submit disables both buttons, so a second click cannot fire a second POST
71
+ // while the first navigation is still pending.
72
+ const busy = $derived(saving || publishing);
73
+ // True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
74
+ // document without flipping busy, so without this the leave guard would fire mid-discard while
75
+ // the page is still dirty, which is the primary discard scenario.
76
+ let leaving = $state(false);
77
+
78
+ // Dirty tracking. The body compares against the text the page loaded with (or the edited body a
79
+ // blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
80
+ // on any input event, and the navigation a save triggers reloads the page, which resets both.
81
+ const bodyDirty = $derived(body !== (form?.body ?? data.body));
82
+ let fieldsDirty = $state(false);
83
+ const dirty = $derived(bodyDirty || fieldsDirty);
84
+ // What the header's save-state indicator says.
85
+ const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
86
+ function onFormInput(e: Event) {
87
+ const target = e.target as Element | null;
88
+ // Two kinds of input event bubble through the form without being frontmatter edits: the link
89
+ // picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
90
+ // contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
91
+ // to the committed text reads clean again.
92
+ if (target?.closest('dialog, #cairn-pane-write')) return;
93
+ fieldsDirty = true;
94
+ }
95
+
96
+ // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
97
+ let editForm = $state<HTMLFormElement | null>(null);
98
+
99
+ // The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
100
+ // onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
101
+ // navigation passes through because busy flips before it starts, and a non-edit POST's because
102
+ // leaving does.
103
+ beforeNavigate((navigation) => {
104
+ if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
105
+ navigation.cancel();
106
+ });
107
+
108
+ // The browser half of the leave guard plus the page-wide save shortcut. The handlers read the
109
+ // current dirty and busy values at event time, so the effect itself tracks nothing and runs once.
110
+ $effect(() => {
111
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
112
+ if (dirty && !busy && !leaving) e.preventDefault();
113
+ };
114
+ // Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
115
+ // parentheses, and consumers compile this source with their own svelte.
116
+ const onWindowKeydown = (e: KeyboardEvent) => {
117
+ if (!(e.ctrlKey || e.metaKey)) return;
118
+ if (e.key.toLowerCase() !== 's') return;
119
+ // Always claim the shortcut so the browser's save-page dialog never opens over the admin.
120
+ e.preventDefault();
121
+ // Gate the submit itself: an in-flight POST must not race a second one, a clean page has
122
+ // nothing to save (a no-op save would still cut a pending branch), and a save from inside
123
+ // an open modal would act on a surface the author cannot see.
124
+ if (busy) return;
125
+ if (!dirty && !data.isNew) return;
126
+ if ((e.target as Element | null)?.closest?.('dialog')) return;
127
+ editForm?.requestSubmit();
128
+ };
129
+ window.addEventListener('beforeunload', onBeforeUnload);
130
+ window.addEventListener('keydown', onWindowKeydown);
131
+ return () => {
132
+ window.removeEventListener('beforeunload', onBeforeUnload);
133
+ window.removeEventListener('keydown', onWindowKeydown);
134
+ };
135
+ });
136
+ // The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
137
+ let discardDialog = $state<HTMLDialogElement | null>(null);
138
+ // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
139
+ // landing tab.
140
+ let mode = $state<'write' | 'preview'>('write');
50
141
  let previewHtml = $state('');
142
+ // True after a render call threw, so the preview pane can say so instead of going blank.
143
+ let previewFailed = $state(false);
51
144
  let insert = $state.raw<(text: string) => void>(() => {});
52
145
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
146
+ // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
147
+ // reads it for the Text field's default.
148
+ let getSelection = $state.raw<() => string>(() => '');
149
+ // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
150
+ let format = $state.raw<(kind: FormatKind) => void>(() => {});
151
+ // A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
152
+ type DialogHandle = { open: () => void };
153
+ // The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
154
+ // (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
155
+ // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
156
+ let webLinkDialog = $state<DialogHandle | null>(null);
157
+ let linkPicker = $state<DialogHandle | null>(null);
158
+ let insertDialog = $state<DialogHandle | null>(null);
159
+ // The lifecycle dialogs, opened from the header's overflow menu.
160
+ let deleteDialog = $state<DialogHandle | null>(null);
161
+ let renameDialog = $state<DialogHandle | null>(null);
162
+ // The Markdown cheat sheet, opened from the editor card's footer.
163
+ let helpDialog = $state<DialogHandle | null>(null);
164
+
165
+ // Whether the registry offers anything insertable, the same condition the insert dialog lists
166
+ // by, so the toolbar trigger and the dialog appear and disappear together.
167
+ const hasComponents = $derived(insertableDefs(registry).length > 0);
168
+
169
+ // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
170
+ // when it has never been published); otherwise the live site matches and it reads Published.
171
+ const status = $derived.by(() => {
172
+ if (!data.pending) return 'Published';
173
+ return data.published ? 'Edited' : 'New';
174
+ });
175
+ const statusBadge = $derived.by(() => {
176
+ if (status === 'Edited') return 'badge-warning';
177
+ if (status === 'New') return 'badge-info';
178
+ return 'badge-ghost';
179
+ });
180
+
181
+ // The header overflow menu's popover element and its open state, mirrored from the toggle
182
+ // event into aria-expanded on the trigger.
183
+ let actionsMenu = $state<HTMLUListElement | null>(null);
184
+ let actionsOpen = $state(false);
185
+
186
+ // An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
187
+ // dialog already closes an auto popover, so the explicit hide fires only when the menu is
188
+ // still up.
189
+ function pickAction(action: () => void) {
190
+ action();
191
+ if (actionsMenu?.matches(':popover-open')) actionsMenu.hidePopover();
192
+ }
53
193
 
54
194
  // The save guard's broken links, from the blocked action result. The fix unwraps a link in the
55
195
  // local body, which the bound editor reconciles, so the author re-saves clean.
@@ -74,24 +214,62 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
74
214
  // A rename that hit a collision or an invalid slug returns form.renameError.
75
215
  const renameError = $derived(form?.renameError ?? '');
76
216
 
77
- // After a save that links to a draft target, the redirect carries ?drafts=<tokens>.
217
+ // The entry this surface is editing. SvelteKit reuses the page component across a same-route
218
+ // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
219
+ // state seeded at init would survive the hop and show entry A's body over entry B's data with
220
+ // the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
221
+ // block around the template remounts the DOM to match (CodeMirror with its undo history, the
222
+ // uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
223
+ // beforeNavigate runs before the navigation completes, so it reads the old dirty value.
224
+ const entryKey = $derived(data.conceptId + '/' + data.id);
225
+ let seededKey = untrack(() => entryKey);
226
+ $effect.pre(() => {
227
+ const key = entryKey;
228
+ if (key === seededKey) return;
229
+ seededKey = key;
230
+ untrack(() => {
231
+ body = form?.body ?? data.body;
232
+ saving = false;
233
+ publishing = false;
234
+ leaving = false;
235
+ fieldsDirty = false;
236
+ mode = 'write';
237
+ previewHtml = '';
238
+ previewFailed = false;
239
+ removedLinks = [];
240
+ });
241
+ });
242
+
243
+ // After a save that links to a draft target, the redirect carries ?drafts=<tokens>. Re-read on
244
+ // an entry change too, since a client-side navigation swaps the search string under this effect.
78
245
  let draftWarning = $state('');
79
246
  $effect(() => {
247
+ void entryKey;
80
248
  const search = typeof location === 'undefined' ? '' : location.search;
81
249
  const drafts = new URLSearchParams(search).get('drafts');
82
250
  draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
83
251
  });
84
252
 
85
- // One persistent live region announces the current message, since a {#if}-gated role element
86
- // inserted fresh is announced inconsistently. A polite region carries the success and draft
87
- // notices; an assertive region carries the errors. The visible banners below keep their styling
88
- // but drop their roles, so a message is announced once.
89
- const politeMessage = $derived.by(() => {
90
- if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
91
- if (data.saved) return 'Saved.';
253
+ // The one transient feedback strip under the sticky header. The redirect flags are mutually
254
+ // exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
255
+ // A saved flash with a draft warning yields to the warning alert below, the prior behavior.
256
+ const flash = $derived.by(() => {
257
+ if (data.saved && !draftWarning)
258
+ return 'Saved. Your site keeps showing the published version until you publish.';
259
+ if (data.publishedFlash) return 'Published. The live site is rebuilding.';
260
+ if (data.discardedFlash) return 'Changes discarded.';
92
261
  if (data.renamed) return `The URL is now ${data.slug}.`;
93
262
  return '';
94
263
  });
264
+
265
+ // One persistent live region announces the current message, since a {#if}-gated role element
266
+ // inserted fresh is announced inconsistently. A polite region carries the success and draft
267
+ // notices (the flash, plus the draft notice the strip yields to); an assertive region carries
268
+ // the errors. The visible banners below keep their styling but drop their roles, so a message
269
+ // is announced once.
270
+ const politeMessage = $derived(
271
+ draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
272
+ );
95
273
  const assertiveMessage = $derived.by(() => {
96
274
  if (data.error) return data.error;
97
275
  if (renameError) return renameError;
@@ -106,6 +284,17 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
106
284
  return '';
107
285
  });
108
286
 
287
+ // The editor footer's word count, over the local body so it tracks every keystroke. Directive
288
+ // machinery lines and table rows are dropped first, so the count reads as the author's prose.
289
+ const countedBody = $derived(
290
+ body
291
+ .split('\n')
292
+ .filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
293
+ .join('\n'),
294
+ );
295
+ const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
296
+ const wordLabel = $derived(wordCount === 1 ? '1 word' : `${wordCount} words`);
297
+
109
298
  // The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
110
299
  // returns undefined for a missing target so the render step marks it cairn-broken-link.
111
300
  const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
@@ -113,16 +302,38 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
113
302
  // The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
114
303
  const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
115
304
 
116
- const PREVIEW_KEY = 'cairn-admin:preview';
305
+ function setMode(m: 'write' | 'preview') {
306
+ mode = m;
307
+ }
308
+
309
+ // Preview is read-only, so the insert controls the page renders into the toolbar disable with
310
+ // the strip's own format buttons.
311
+ const insertDisabled = $derived(mode === 'preview');
117
312
 
313
+ // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
314
+ // strip or the surface, without claiming the keys page-wide. The listener attaches
315
+ // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
316
+ // rule cannot tell apart on a declarative handler.
317
+ let editorCard = $state<HTMLDivElement | null>(null);
118
318
  $effect(() => {
119
- // Restore the per-user preference once, on mount.
120
- showPreview = localStorage.getItem(PREVIEW_KEY) === '1';
319
+ const card = editorCard;
320
+ if (!card) return;
321
+ card.addEventListener('keydown', onEditorKeydown);
322
+ return () => card.removeEventListener('keydown', onEditorKeydown);
121
323
  });
122
-
123
- function togglePreview() {
124
- showPreview = !showPreview;
125
- localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
324
+ function onEditorKeydown(e: KeyboardEvent) {
325
+ if (!(e.ctrlKey || e.metaKey)) return;
326
+ const key = e.key.toLowerCase();
327
+ if (key === 'b') {
328
+ e.preventDefault();
329
+ format('bold');
330
+ } else if (key === 'i') {
331
+ e.preventDefault();
332
+ format('italic');
333
+ } else if (key === 'k') {
334
+ e.preventDefault();
335
+ webLinkDialog?.open();
336
+ }
126
337
  }
127
338
 
128
339
  // Render the design-accurate preview as the body changes, debounced. The site's render is the
@@ -131,16 +342,22 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
131
342
  // async render call resolves after a newer one has started, the stale result is discarded.
132
343
  let previewRun = 0;
133
344
  $effect(() => {
134
- if (!showPreview || !render) return;
345
+ if (mode !== 'preview' || !render) return;
135
346
  const md = body;
136
347
  const resolve = resolveLink; // tracked read in the effect body
137
348
  const run = ++previewRun;
138
349
  const handle = setTimeout(async () => {
139
350
  try {
140
351
  const html = await render(md, { resolve });
141
- if (run === previewRun) previewHtml = html;
352
+ if (run === previewRun) {
353
+ previewHtml = html;
354
+ previewFailed = false;
355
+ }
142
356
  } catch {
143
- if (run === previewRun) previewHtml = '';
357
+ if (run === previewRun) {
358
+ previewHtml = '';
359
+ previewFailed = true;
360
+ }
144
361
  }
145
362
  }, 150);
146
363
  return () => clearTimeout(handle);
@@ -150,38 +367,140 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
150
367
  function str(v: unknown): string {
151
368
  return v == null ? '' : String(v);
152
369
  }
370
+
371
+ // The eyebrow legend each sidebar group opens with, one class string for all three.
372
+ const eyebrowClass =
373
+ 'mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
374
+
375
+ // The sidebar's grouping. The title field hoists above the editor card as the document title,
376
+ // and a boolean named draft becomes the Visibility group's Hidden toggle (both production
377
+ // adapters use that name); everything else is a Details field.
378
+ const titleField = $derived(data.fields.find((f) => f.name === 'title'));
379
+ const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
380
+ const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
153
381
  </script>
154
382
 
155
- <header class="mb-6 flex items-center justify-between gap-2">
156
- <div>
157
- <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.title}</h1>
158
- <p class="text-xs text-[var(--color-muted)]">{data.label}: {data.id}</p>
159
- </div>
160
- <div class="flex items-center gap-2">
161
- <ComponentInsertDialog {registry} {insert} {icons} />
162
- <LinkPicker linkTargets={data.linkTargets} insert={insertLink} />
163
- <RenameDialog conceptId={data.conceptId} id={data.id} label={data.label} slug={data.slug} />
164
- <DeleteDialog conceptId={data.conceptId} id={data.id} label={data.label} inboundLinks={data.inboundLinks} />
165
- <button
166
- type="button"
167
- class="btn btn-sm btn-ghost"
168
- aria-expanded={showPreview}
169
- aria-controls="cairn-preview"
170
- onclick={togglePreview}
171
- >
172
- {showPreview ? 'Hide preview' : 'Show preview'}
173
- </button>
383
+ <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
384
+ reset above); script-level state and the beforeNavigate registration sit outside the block,
385
+ so only the template rebuilds. -->
386
+ {#key entryKey}
387
+ <!-- The form's default button. The header's Publish (while pending) and Save submit the edit form
388
+ from outside it, and the default button for implicit submission (Enter in a single-line
389
+ field) is the FIRST form-owned submit button in tree order, which the header's Publish would
390
+ otherwise be: Enter in the title would publish a half-finished edit. This sr-only button sits
391
+ before the header, carries no formaction (so an implicit submit posts ?/save), and mirrors
392
+ Save's disabled state, so Enter on a clean page submits nothing. -->
393
+ <button
394
+ type="submit"
395
+ form="cairn-edit-form"
396
+ class="sr-only"
397
+ tabindex="-1"
398
+ aria-hidden="true"
399
+ disabled={busy || (!dirty && !data.isNew)}
400
+ >
401
+ Save
402
+ </button>
403
+
404
+ <!-- The sticky action header, a glass ruler: a translucent base-200 veil with backdrop blur the
405
+ page scrolls beneath, never a second opaque band (the admin topbar keeps that role). It sticks
406
+ under the h-16 topbar and bleeds across AdminLayout's content padding (p-4, lg:p-8) with
407
+ matching negative margins, so the veil spans the whole content column. -->
408
+ <header
409
+ class="sticky top-16 z-10 -mx-4 mb-6 border-b border-[var(--cairn-card-border)] bg-base-200/90 px-4 py-3 backdrop-blur lg:-mx-8 lg:px-8"
410
+ >
411
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
412
+ <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
413
+ <a
414
+ href={`/admin/${data.conceptId}`}
415
+ class="flex shrink-0 items-center gap-0.5 text-sm text-[var(--color-muted)] transition-colors hover:text-base-content"
416
+ >
417
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
418
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18-6-6 6-6" />
419
+ </svg>
420
+ {data.label}
421
+ </a>
422
+ <!-- The manuscript heading below is the visible title; repeating it here read as
423
+ duplication, so the header keeps the h1 for assistive tech only. -->
424
+ <h1 class="sr-only">{data.title}</h1>
425
+ <span class="badge badge-sm font-medium {statusBadge}">{status}</span>
426
+ {#if data.frontmatter.draft === true}
427
+ <span class="badge badge-neutral badge-sm font-medium">Hidden</span>
428
+ {/if}
429
+ <!-- The save-state indicator eases in and out; the admin sheet's prefers-reduced-motion rule
430
+ squashes the transition for editors who asked for that. The dot is the quiet unsaved cue. -->
431
+ <span
432
+ class="cairn-save-state flex items-center gap-1.5 text-xs text-[var(--color-muted)] transition-opacity duration-300"
433
+ class:opacity-0={!saveState}
434
+ aria-live="off"
435
+ >
436
+ {#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
437
+ {saveState}
438
+ </span>
439
+ </div>
440
+ <div class="ml-auto flex items-center gap-2">
441
+ <!-- The overflow menu is a DaisyUI v5 popover dropdown: click to open (never
442
+ focus-in-transit), Escape and light dismiss from the Popover API, and the
443
+ anchor-name/position-anchor pair places the panel under its trigger. -->
444
+ <button
445
+ type="button"
446
+ class="btn btn-ghost btn-sm btn-square"
447
+ aria-label="More actions"
448
+ title="More actions"
449
+ aria-expanded={actionsOpen}
450
+ popovertarget="cairn-edit-actions-menu"
451
+ style="anchor-name:--cairn-edit-actions"
452
+ >
453
+ <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">
454
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01" />
455
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 12h.01" />
456
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 12h.01" />
457
+ </svg>
458
+ </button>
459
+ <ul
460
+ bind:this={actionsMenu}
461
+ popover="auto"
462
+ id="cairn-edit-actions-menu"
463
+ style="position-anchor:--cairn-edit-actions"
464
+ ontoggle={(e) => (actionsOpen = e.newState === 'open')}
465
+ 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)]"
466
+ >
467
+ {#if data.pending}
468
+ <li>
469
+ <button type="button" aria-haspopup="dialog" onclick={() => pickAction(() => discardDialog?.showModal())}>
470
+ Discard changes
471
+ </button>
472
+ </li>
473
+ {/if}
474
+ <li>
475
+ <button type="button" class="text-error" aria-haspopup="dialog" onclick={() => pickAction(() => deleteDialog?.open())}>
476
+ Delete
477
+ </button>
478
+ </li>
479
+ </ul>
480
+ {#if data.pending}
481
+ <!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
482
+ <button type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
483
+ {#if publishing}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Publishing…{:else}Publish{/if}
484
+ </button>
485
+ {/if}
486
+ <!-- Save sleeps while the page is clean, agreeing with the header indicator; a new entry
487
+ stays saveable so it can be created as loaded. -->
488
+ <button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
489
+ {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
490
+ </button>
491
+ </div>
174
492
  </div>
175
493
  </header>
176
494
 
177
495
  <div class="sr-only" aria-live="polite">{politeMessage}</div>
178
496
  <div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
179
497
 
180
- {#if data.saved && !draftWarning}
181
- <div class="alert alert-success mb-4 text-sm">Saved.</div>
182
- {/if}
183
- {#if data.renamed}
184
- <div class="alert alert-success mb-4 text-sm">The URL is now {data.slug}.</div>
498
+ <!-- The feedback strip slides in just under the header: @starting-style drives the entry, so the
499
+ motion is pure CSS and the admin sheet's prefers-reduced-motion rule squashes it. -->
500
+ {#if flash}
501
+ <div class="cairn-feedback alert alert-success mb-4 text-sm transition-all duration-300 starting:-translate-y-2 starting:opacity-0">
502
+ {flash}
503
+ </div>
185
504
  {/if}
186
505
  {#if data.error}
187
506
  <div class="alert alert-error mb-4 text-sm">{data.error}</div>
@@ -221,35 +540,142 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
221
540
  </div>
222
541
  {/if}
223
542
 
224
- <form method="POST" action="?/save" onsubmit={() => (saving = true)} class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
543
+ <form
544
+ method="POST"
545
+ action="?/save"
546
+ id="cairn-edit-form"
547
+ bind:this={editForm}
548
+ onsubmit={onEditSubmit}
549
+ oninput={onFormInput}
550
+ class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
551
+ >
225
552
  <CsrfField />
226
553
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
227
554
 
228
555
  <div class="lg:order-1">
229
- <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
230
- <MarkdownEditor
231
- bind:value={body}
232
- name="body"
233
- registerInsert={(fn) => (insert = fn)}
234
- registerInsertLink={(fn) => (insertLink = fn)}
235
- {completionSources}
556
+ {#if titleField}
557
+ <!-- The hoisted document title: large, borderless, in the display face, so the manuscript
558
+ reads as the protagonist. It submits as name="title", the same field as before. The
559
+ admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there). -->
560
+ <input
561
+ class="cairn-doc-title mb-4 w-full border-0 bg-transparent text-3xl font-bold tracking-tight font-[family-name:var(--font-display)] placeholder:text-[var(--color-muted)]"
562
+ name="title"
563
+ value={str(data.frontmatter.title)}
564
+ placeholder={titleField.label}
565
+ aria-label={titleField.label}
566
+ required={titleField.required}
236
567
  />
237
- </div>
238
- {#if showPreview}
239
- <section
240
- id="cairn-preview"
241
- aria-label="Preview"
242
- class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 prose mt-4 max-w-none p-4 shadow-[var(--cairn-shadow)]"
243
- >
244
- {@html previewHtml}
245
- </section>
246
568
  {/if}
569
+ <!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
570
+ reads as a single object. The card carries the formatting shortcuts for everything in it. -->
571
+ <div
572
+ bind:this={editorCard}
573
+ class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]"
574
+ role="group"
575
+ aria-label="Editor"
576
+ >
577
+ <EditorToolbar {format} {mode} onMode={setMode}>
578
+ {#snippet insertControls()}
579
+ <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
580
+ dialogs themselves mount outside the edit form at the bottom of this component. -->
581
+ {#if hasComponents}
582
+ <button
583
+ type="button"
584
+ class="btn btn-sm btn-ghost"
585
+ aria-haspopup="dialog"
586
+ aria-label="Insert block"
587
+ disabled={insertDisabled}
588
+ onclick={() => insertDialog?.open()}
589
+ >
590
+ Insert block
591
+ </button>
592
+ {/if}
593
+ <button
594
+ type="button"
595
+ class="btn btn-sm btn-ghost"
596
+ aria-haspopup="dialog"
597
+ aria-label="Web link (Ctrl+K)"
598
+ title="Web link (Ctrl+K)"
599
+ disabled={insertDisabled}
600
+ onclick={() => webLinkDialog?.open()}
601
+ >
602
+ Web link
603
+ </button>
604
+ <button
605
+ type="button"
606
+ class="btn btn-sm btn-ghost"
607
+ aria-haspopup="dialog"
608
+ aria-label="Link to page"
609
+ disabled={insertDisabled}
610
+ onclick={() => linkPicker?.open()}
611
+ >
612
+ Link to page
613
+ </button>
614
+ <button
615
+ type="button"
616
+ class="btn btn-ghost btn-sm btn-square"
617
+ disabled
618
+ aria-label="Image (coming soon)"
619
+ title="Image (coming soon)"
620
+ >
621
+ <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">
622
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
623
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
624
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
625
+ </svg>
626
+ </button>
627
+ {/snippet}
628
+ </EditorToolbar>
629
+ <!-- The Write pane stays mounted while Preview shows, so CodeMirror keeps its caret, scroll
630
+ position, and undo history across the tab switch. -->
631
+ <div id="cairn-pane-write" role="tabpanel" aria-labelledby="cairn-tab-write" class:hidden={mode === 'preview'}>
632
+ <MarkdownEditor
633
+ bind:value={body}
634
+ name="body"
635
+ registerInsert={(fn) => (insert = fn)}
636
+ registerInsertLink={(fn) => (insertLink = fn)}
637
+ registerGetSelection={(fn) => (getSelection = fn)}
638
+ registerFormat={(fn) => (format = fn)}
639
+ {completionSources}
640
+ />
641
+ </div>
642
+ {#if mode === 'preview'}
643
+ <!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
644
+ tabpanel pattern's completeness requirement). -->
645
+ <div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
646
+ {#if previewHtml}
647
+ {@html previewHtml}
648
+ {:else if previewFailed}
649
+ <p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
650
+ {:else}
651
+ <p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
652
+ {/if}
653
+ </div>
654
+ {/if}
655
+ <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
656
+ frame never jumps between tabs and the count keeps reading while proofing. -->
657
+ <div class="flex items-center justify-between border-t border-[var(--cairn-card-border)] px-3 py-1 text-xs text-[var(--color-muted)]">
658
+ <span>{wordLabel}</span>
659
+ <button
660
+ type="button"
661
+ class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
662
+ aria-haspopup="dialog"
663
+ onclick={() => helpDialog?.open()}
664
+ >
665
+ Markdown help
666
+ </button>
667
+ </div>
668
+ </div>
247
669
  </div>
248
670
 
249
671
  <aside class="lg:order-2 mt-4 lg:mt-0">
250
- <fieldset class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-3 p-4 shadow-[var(--cairn-shadow)]">
251
- <legend class="sr-only">Frontmatter</legend>
252
- {#each data.fields as field (field.name)}
672
+ <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
673
+ a real legend that screen readers announce with the fields it holds. -->
674
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
675
+ {#if detailFields.length}
676
+ <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
677
+ <legend class={eyebrowClass}>Details</legend>
678
+ {#each detailFields as field (field.name)}
253
679
  {#if field.type === 'textarea'}
254
680
  {@const f = field as TextareaField}
255
681
  <label class="flex flex-col gap-1">
@@ -306,9 +732,88 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
306
732
  </label>
307
733
  {/if}
308
734
  {/each}
309
- <button type="submit" class="btn btn-primary mt-3" disabled={saving}>
310
- {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
311
- </button>
312
- </fieldset>
735
+ </fieldset>
736
+ {/if}
737
+ {#if draftField}
738
+ <fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
739
+ <legend class={eyebrowClass}>Visibility</legend>
740
+ <label class="label cursor-pointer justify-start gap-2">
741
+ <input class="checkbox checkbox-sm" type="checkbox" name="draft" checked={data.frontmatter.draft === true} />
742
+ <span class="text-sm">Hidden</span>
743
+ </label>
744
+ <p class="text-xs text-[var(--color-muted)]">Hidden entries stay off the site's lists and feeds, even when published.</p>
745
+ </fieldset>
746
+ {/if}
747
+ <fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
748
+ <legend class={eyebrowClass}>Address</legend>
749
+ <div class="flex items-center justify-between gap-2">
750
+ <code class="min-w-0 break-all text-xs text-[var(--color-muted)]">/{data.slug}</code>
751
+ <button
752
+ type="button"
753
+ class="btn btn-ghost btn-sm shrink-0"
754
+ aria-haspopup="dialog"
755
+ onclick={() => renameDialog?.open()}
756
+ >
757
+ Change URL
758
+ </button>
759
+ </div>
760
+ </fieldset>
761
+ </div>
313
762
  </aside>
314
763
  </form>
764
+
765
+ <!-- The toolbar's insert dialogs, mounted headless outside the edit form: each holds its own
766
+ <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
767
+ tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
768
+ through their exported open(). -->
769
+ <ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
770
+ <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
771
+ <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
772
+
773
+ <!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
774
+ exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
775
+ <RenameDialog
776
+ bind:this={renameDialog}
777
+ trigger={false}
778
+ conceptId={data.conceptId}
779
+ id={data.id}
780
+ label={data.label}
781
+ slug={data.slug}
782
+ onsubmitting={() => (leaving = true)}
783
+ />
784
+ <MarkdownHelpDialog bind:this={helpDialog} />
785
+ <DeleteDialog
786
+ bind:this={deleteDialog}
787
+ trigger={false}
788
+ conceptId={data.conceptId}
789
+ id={data.id}
790
+ label={data.label}
791
+ inboundLinks={data.inboundLinks}
792
+ pending={data.pending}
793
+ onsubmitting={() => (leaving = true)}
794
+ />
795
+
796
+ {#if data.pending}
797
+ <dialog class="modal" aria-labelledby="cairn-discard-dialog-title" bind:this={discardDialog}>
798
+ <div class="modal-box">
799
+ <div class="mb-3 flex items-center justify-between">
800
+ <h2 id="cairn-discard-dialog-title" class="text-base font-semibold">Discard the unpublished changes?</h2>
801
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => discardDialog?.close()}>✕</button>
802
+ </div>
803
+ {#if data.published}
804
+ <p class="mb-3 text-sm">This restores the live version. The changes cannot be recovered.</p>
805
+ {:else}
806
+ <p class="mb-3 text-sm">This entry has never been published, so discarding deletes it. Nothing can be recovered.</p>
807
+ {/if}
808
+ <form method="POST" action="?/discard" class="flex justify-end gap-2" onsubmit={() => (leaving = true)}>
809
+ <CsrfField />
810
+ <button type="button" class="btn btn-sm" onclick={() => discardDialog?.close()}>Cancel</button>
811
+ <button type="submit" class="btn btn-sm btn-error">Discard</button>
812
+ </form>
813
+ </div>
814
+ <form method="dialog" class="modal-backdrop">
815
+ <button tabindex="-1" aria-label="Close">close</button>
816
+ </form>
817
+ </dialog>
818
+ {/if}
819
+ {/key}