@glw907/cairn-cms 0.53.0 → 0.54.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 (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/components/AdminLayout.svelte +52 -19
  3. package/dist/components/EditPage.svelte +372 -110
  4. package/dist/components/EditPage.svelte.d.ts +2 -1
  5. package/dist/components/EditorToolbar.svelte +26 -10
  6. package/dist/components/MarkdownEditor.svelte +108 -14
  7. package/dist/components/MarkdownHelpDialog.svelte +5 -0
  8. package/dist/components/ShortcutsDialog.svelte +37 -0
  9. package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
  10. package/dist/components/ShortcutsGrid.svelte +18 -0
  11. package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
  12. package/dist/components/cairn-admin.css +138 -108
  13. package/dist/components/editor-folding.d.ts +7 -0
  14. package/dist/components/editor-folding.js +331 -0
  15. package/dist/components/editor-highlight.js +55 -6
  16. package/dist/components/editor-shortcuts.d.ts +16 -0
  17. package/dist/components/editor-shortcuts.js +36 -0
  18. package/dist/components/markdown-directives.d.ts +17 -0
  19. package/dist/components/markdown-directives.js +41 -0
  20. package/dist/components/topbar-context.d.ts +13 -0
  21. package/dist/components/topbar-context.js +17 -0
  22. package/package.json +1 -1
  23. package/src/lib/components/AdminLayout.svelte +52 -19
  24. package/src/lib/components/EditPage.svelte +372 -110
  25. package/src/lib/components/EditorToolbar.svelte +26 -10
  26. package/src/lib/components/MarkdownEditor.svelte +108 -14
  27. package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
  28. package/src/lib/components/ShortcutsDialog.svelte +37 -0
  29. package/src/lib/components/ShortcutsGrid.svelte +18 -0
  30. package/src/lib/components/cairn-admin.css +24 -11
  31. package/src/lib/components/editor-folding.ts +356 -0
  32. package/src/lib/components/editor-highlight.ts +54 -4
  33. package/src/lib/components/editor-shortcuts.ts +42 -0
  34. package/src/lib/components/markdown-directives.ts +42 -0
  35. package/src/lib/components/topbar-context.ts +30 -0
@@ -3,7 +3,8 @@
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
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
6
+ remaining fields group behind the Details slide-over (a fixed panel below the band, toggled from
7
+ the band's Details trigger or Ctrl+.) under Details, Visibility (the draft boolean as the Hidden
7
8
  toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
8
9
  swap the editing surface for the rendered preview inside the same card; every visit lands on
9
10
  Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
@@ -22,6 +23,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
22
23
  import BlocksIcon from '@lucide/svelte/icons/blocks';
23
24
  import LinkIcon from '@lucide/svelte/icons/link';
24
25
  import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
26
+ import PanelRightIcon from '@lucide/svelte/icons/panel-right';
27
+ import { useTopbar } from './topbar-context.js';
25
28
  import CsrfField from './CsrfField.svelte';
26
29
  import MarkdownEditor from './MarkdownEditor.svelte';
27
30
  import EditorToolbar from './EditorToolbar.svelte';
@@ -31,6 +34,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
31
34
  import DeleteDialog from './DeleteDialog.svelte';
32
35
  import RenameDialog from './RenameDialog.svelte';
33
36
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
37
+ import ShortcutsDialog from './ShortcutsDialog.svelte';
34
38
  import { cairnLinkCompletionSource } from './link-completion.js';
35
39
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
36
40
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
@@ -58,6 +62,25 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
58
62
 
59
63
  let { data, registry, render, icons, form }: Props = $props();
60
64
 
65
+ // The topbar context portal (AdminLayout owns the holder). The desk snippet below carries the
66
+ // document's status and action clusters; this effect registers it into the band on mount and
67
+ // nulls it on teardown, so CairnAdmin's view switch (which unmounts EditPage) clears the band.
68
+ // The holder is absent only when EditPage renders outside AdminLayout (it always renders inside
69
+ // it in the app); the optional chaining keeps that case inert.
70
+ const topbar = useTopbar();
71
+ $effect(() => {
72
+ if (!topbar) return;
73
+ topbar.desk = desk;
74
+ // Zen drops the band: AdminLayout reads this flag to remove the whole topbar element, so the
75
+ // desk's clusters and AdminLayout's own chrome (the drawer toggle, the breadcrumb) all slide
76
+ // away together. The effect tracks `zen`, so a toggle reaches the band live.
77
+ topbar.zen = zen;
78
+ return () => {
79
+ topbar.desk = null;
80
+ topbar.zen = false;
81
+ };
82
+ });
83
+
61
84
  // `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
62
85
  // the author's edited markdown as form.body, so seed from that when present to keep the edits and
63
86
  // the broken link they were told to fix. On the success and delete-refused paths form carries no
@@ -104,13 +127,23 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
104
127
 
105
128
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
106
129
  let editForm = $state<HTMLFormElement | null>(null);
130
+ // The header's Publish submitter, for the Ctrl/Cmd+Shift+S shortcut: requesting submit through it
131
+ // carries the ?/publish formaction and trips the busy flags down the existing submit path. It
132
+ // exists only while data.pending, so the shortcut no-ops when there is nothing to publish.
133
+ let publishButton = $state<HTMLButtonElement | null>(null);
107
134
 
108
- // A required sidebar field hidden by Preview cannot take the browser's validation report: an
109
- // invisible control is unfocusable, so the browser cancels the save silently with no message.
110
- // This capture-phase invalid listener flips back to Write first, and flushSync forces the pane
111
- // swap inside the event, so the report that follows the invalid events lands on a visible
112
- // control and the author sees what blocked the save.
113
- function onFormInvalid() {
135
+ // A required field hidden from the browser's validation report cannot take it: an invisible
136
+ // control is unfocusable, so the browser cancels the save silently with no message. Two surfaces
137
+ // can hide a required field, so this capture-phase invalid listener reveals whichever holds the
138
+ // invalid control before the report that follows fires. A field in the write pane needs Preview
139
+ // flipped back to Write; a field in the details slide-over needs the panel opened. flushSync
140
+ // forces the reveal inside the event, so the report lands on a now-visible control.
141
+ function onFormInvalid(e: Event) {
142
+ const target = e.target as Element | null;
143
+ if (target?.closest('aside')) {
144
+ if (!detailsOpen) flushSync(() => (detailsOpen = true));
145
+ return;
146
+ }
114
147
  if (mode === 'write') return;
115
148
  flushSync(() => (mode = 'write'));
116
149
  }
@@ -141,8 +174,73 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
141
174
  // Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
142
175
  // parentheses, and consumers compile this source with their own svelte.
143
176
  const onWindowKeydown = (e: KeyboardEvent) => {
177
+ // Escape precedence, top to bottom: an open dialog claims Escape natively, so step aside
178
+ // when one is up. Otherwise the details slide-over closes first (Task 8: it is a region, not
179
+ // a dialog, so it has no native light-dismiss), and only when no panel is open does Escape
180
+ // exit zen. So under zen with the panel open, the first Escape closes the panel and the
181
+ // second exits zen, which keeps the two affordances independent.
182
+ if (e.key === 'Escape' && (detailsOpen || zen)) {
183
+ const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
184
+ if (inDialog) return;
185
+ e.preventDefault();
186
+ if (detailsOpen) closeDetails();
187
+ else setZen(false);
188
+ return;
189
+ }
144
190
  if (!(e.ctrlKey || e.metaKey)) return;
145
- if (e.key.toLowerCase() !== 's') return;
191
+ const key = e.key.toLowerCase();
192
+ // The page-wide chords never act on a surface the author cannot see: a save, publish, mode
193
+ // flip, or focus toggle from inside an open modal is suppressed the same way.
194
+ const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
195
+ // Ctrl+/ opens the shortcuts sheet, the third discoverability surface. It reads off e.key so
196
+ // it survives the shifted glyph differences across layouts, and it stays clear of dialogs the
197
+ // same way the other chords do (the sheet is itself a dialog, so opening from inside one would
198
+ // stack modals over a surface the author cannot see).
199
+ if (!e.shiftKey && !e.altKey && e.key === '/') {
200
+ e.preventDefault();
201
+ if (inDialog) return;
202
+ shortcutsDialog?.open();
203
+ return;
204
+ }
205
+ if (e.shiftKey && key === 's') {
206
+ // Publish rides the header's Publish submitter so the ?/publish formaction and the busy
207
+ // flags follow the existing submit path; it exists only while pending, so this no-ops
208
+ // otherwise.
209
+ e.preventDefault();
210
+ if (busy || inDialog || !data.pending) return;
211
+ editForm?.requestSubmit(publishButton);
212
+ return;
213
+ }
214
+ if (e.altKey && key === 'p') {
215
+ e.preventDefault();
216
+ if (inDialog) return;
217
+ setMode(mode === 'write' ? 'preview' : 'write');
218
+ return;
219
+ }
220
+ if (e.shiftKey && key === 'f') {
221
+ e.preventDefault();
222
+ if (inDialog) return;
223
+ setFocusMode(!focusMode);
224
+ return;
225
+ }
226
+ // Ctrl+Shift+. toggles zen (the bindings' zen key); the period reads off e.key. This sits
227
+ // before the Ctrl+. panel block so the shifted chord is not mistaken for the panel toggle.
228
+ if (e.shiftKey && !e.altKey && e.key === '.') {
229
+ e.preventDefault();
230
+ if (inDialog) return;
231
+ setZen(!zen);
232
+ return;
233
+ }
234
+ // Ctrl+. toggles the details slide-over (the bindings' panel key); the period reads off
235
+ // e.key with no shift or alt.
236
+ if (!e.shiftKey && !e.altKey && e.key === '.') {
237
+ e.preventDefault();
238
+ if (inDialog) return;
239
+ toggleDetails();
240
+ return;
241
+ }
242
+ if (e.shiftKey || e.altKey) return;
243
+ if (key !== 's') return;
146
244
  // Always claim the shortcut so the browser's save-page dialog never opens over the admin.
147
245
  e.preventDefault();
148
246
  // Gate the submit itself: an in-flight POST must not race a second one, a clean page has
@@ -150,7 +248,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
150
248
  // an open modal would act on a surface the author cannot see.
151
249
  if (busy) return;
152
250
  if (!dirty && !data.isNew) return;
153
- if ((e.target as Element | null)?.closest?.('dialog')) return;
251
+ if (inDialog) return;
154
252
  editForm?.requestSubmit();
155
253
  };
156
254
  window.addEventListener('beforeunload', onBeforeUnload);
@@ -188,14 +286,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
188
286
  const focusStorageKey = 'cairn-editor-focus-mode';
189
287
  const typewriterStorageKey = 'cairn-editor-typewriter';
190
288
  const surfaceStorageKey = 'cairn-editor-surface';
289
+ const zenStorageKey = 'cairn-editor-zen';
191
290
  let focusMode = $state(false);
192
291
  let typewriter = $state(false);
292
+ // Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
293
+ // strip, and the footer go; the editing surface stays. It joins the editor-preference family on
294
+ // the same pattern (a localStorage key, read once below, written by the setter), and composes
295
+ // with focus mode and the postures rather than resetting them.
296
+ let zen = $state(false);
193
297
  // The surface posture: prose (the writing instrument) by default; markup is the dense
194
298
  // working surface.
195
299
  let surface = $state<'prose' | 'markup'>('prose');
196
300
  $effect(() => {
197
301
  focusMode = localStorage.getItem(focusStorageKey) === 'true';
198
302
  typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
303
+ zen = localStorage.getItem(zenStorageKey) === 'true';
199
304
  if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
200
305
  });
201
306
  function setFocusMode(on: boolean) {
@@ -210,10 +315,38 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
210
315
  surface = posture;
211
316
  localStorage.setItem(surfaceStorageKey, posture);
212
317
  }
213
- // One source for the footer toggles' pressed/idle styling. The class names must stay verbatim
214
- // string literals: the admin CSS build's @source scan reads this file as raw text.
215
- function footerToggleClass(pressed: boolean): string {
216
- return `btn btn-ghost btn-xs font-normal ${pressed ? 'bg-primary/10 text-primary' : 'text-[var(--color-muted)]'}`;
318
+ function setZen(on: boolean) {
319
+ // Entering zen hides the band, the document title, the toolbar strip, and the footer. Focus on
320
+ // any of those (a band action like Publish, a strip button, the title input, a footer toggle)
321
+ // would strand on a detached node when its host leaves the DOM, so move focus into the editing
322
+ // surface first. The surface (.cm-editor) and the exit chip are all that survive, so any focus
323
+ // outside the surface is about to hide. Reading activeElement before the DOM updates is what
324
+ // tells a hiding control from the surviving one.
325
+ const surface = editorCard?.querySelector('.cm-editor');
326
+ const focusHides = on && !surface?.contains(document.activeElement);
327
+ zen = on;
328
+ localStorage.setItem(zenStorageKey, String(on));
329
+ if (focusHides) {
330
+ // flushSync applies the zen layout (the strip and footer leave the DOM) before we reach for
331
+ // the surface, so the focus call lands on the now-sole interactive region.
332
+ flushSync();
333
+ (editorCard?.querySelector('.cm-content') as HTMLElement | null)?.focus();
334
+ }
335
+ }
336
+ // The footer controls dress as what they are (the spec's rule). Each helper returns a verbatim
337
+ // Tailwind class string: the admin CSS build's @source scan reads this file as raw text, so the
338
+ // utilities must appear whole, never assembled from fragments.
339
+ //
340
+ // A segment of the bordered posture control (the mockup's .seg). The shared group border carries
341
+ // the pick-one semantics, so a segment stays borderless; the active one tints and bolds. The
342
+ // admin's scoped button reset (cairn-admin.css) already strips the UA border and fill.
343
+ function segButtonClass(pressed: boolean): string {
344
+ return `inline-flex items-center gap-1 px-2.5 py-1 text-xs font-normal ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
345
+ }
346
+ // A standalone writing-mode toggle (the mockup's .ftr-toggle): rounded, transparent until hover,
347
+ // check-and-tint when pressed.
348
+ function ftrToggleClass(pressed: boolean): string {
349
+ return `ftr-toggle inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-normal hover:bg-base-content/[0.06] ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
217
350
  }
218
351
  const activeDevice = $derived(previewDevice(device));
219
352
  // The iframe document around the rendered html: the site's stylesheets from the adapter's
@@ -239,6 +372,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
239
372
  let renameDialog = $state<DialogHandle | null>(null);
240
373
  // The Markdown cheat sheet, opened from the editor card's footer.
241
374
  let helpDialog = $state<DialogHandle | null>(null);
375
+ // The keyboard shortcuts sheet, opened from anywhere on the desk by Ctrl+/.
376
+ let shortcutsDialog = $state<DialogHandle | null>(null);
242
377
 
243
378
  // Whether the registry offers anything insertable, the same condition the insert dialog lists
244
379
  // by, so the toolbar trigger and the dialog appear and disappear together.
@@ -256,11 +391,35 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
256
391
  return 'badge-ghost';
257
392
  });
258
393
 
259
- // The header overflow menu's popover element and its open state, mirrored from the toggle
394
+ // The band overflow menu's popover element and its open state, mirrored from the toggle
260
395
  // event into aria-expanded on the trigger.
261
396
  let actionsMenu = $state<HTMLUListElement | null>(null);
262
397
  let actionsOpen = $state(false);
263
398
 
399
+ // The details slide-over. The aside below carries the frontmatter groups; it stays physically
400
+ // inside the edit form (so the uncontrolled fields submit) but presents as a fixed panel under
401
+ // the band, hidden when closed so it leaves the a11y tree and the tab order while its
402
+ // display:none fields still post. Focus moves into the panel on open and returns to the trigger
403
+ // on close, the region-with-focus-management pattern (the a11y reviewer adjudicates region vs
404
+ // dialog at the pass gate).
405
+ let detailsOpen = $state(false);
406
+ let detailsTrigger = $state<HTMLButtonElement | null>(null);
407
+ let detailsClose = $state<HTMLButtonElement | null>(null);
408
+ function openDetails() {
409
+ // flushSync removes the panel's `hidden` attribute synchronously; a hidden element cannot
410
+ // take focus, so the close button must be visible before we move focus to it.
411
+ flushSync(() => (detailsOpen = true));
412
+ detailsClose?.focus();
413
+ }
414
+ function closeDetails() {
415
+ detailsOpen = false;
416
+ detailsTrigger?.focus();
417
+ }
418
+ function toggleDetails() {
419
+ if (detailsOpen) closeDetails();
420
+ else openDetails();
421
+ }
422
+
264
423
  // An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
265
424
  // dialog already closes an auto popover, so the explicit hide fires only when the menu is
266
425
  // still up.
@@ -316,6 +475,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
316
475
  leaving = false;
317
476
  fieldsDirty = false;
318
477
  mode = 'write';
478
+ detailsOpen = false;
319
479
  previewHtml = '';
320
480
  previewFailed = false;
321
481
  removedLinks = [];
@@ -421,7 +581,13 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
421
581
  function onEditorKeydown(e: KeyboardEvent) {
422
582
  if (!(e.ctrlKey || e.metaKey)) return;
423
583
  const key = e.key.toLowerCase();
424
- if (key === 'b') {
584
+ // The shifted-digit list trio (Ctrl+Shift+9/8/7) arrives as '('/'*'/'&' for e.key on US
585
+ // layouts, so the digit identity comes from e.code; the heading pair rides Ctrl+Alt+2/3.
586
+ const fmt = formatForKeydown(e);
587
+ if (fmt) {
588
+ e.preventDefault();
589
+ format(fmt);
590
+ } else if (key === 'b') {
425
591
  e.preventDefault();
426
592
  format('bold');
427
593
  } else if (key === 'i') {
@@ -432,6 +598,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
432
598
  webLinkDialog?.open();
433
599
  }
434
600
  }
601
+ // Maps the format-key chords to their FormatKind. Inline code is the plain Ctrl+E; quote and the
602
+ // two lists are Ctrl+Shift with the digit read from e.code (the shifted key glyph is layout
603
+ // dependent); the headings are the Ctrl+Alt+2/3 Google Docs idiom.
604
+ function formatForKeydown(e: KeyboardEvent): FormatKind | null {
605
+ if (e.altKey) {
606
+ if (e.key === '2') return 'h2';
607
+ if (e.key === '3') return 'h3';
608
+ return null;
609
+ }
610
+ if (e.shiftKey) {
611
+ if (e.code === 'Digit9') return 'quote';
612
+ if (e.code === 'Digit8') return 'ul';
613
+ if (e.code === 'Digit7') return 'ol';
614
+ return null;
615
+ }
616
+ if (e.key.toLowerCase() === 'e') return 'code';
617
+ return null;
618
+ }
435
619
 
436
620
  // Render the design-accurate preview as the body changes, debounced. The site's render is the
437
621
  // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
@@ -483,48 +667,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
483
667
  const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
484
668
  </script>
485
669
 
486
- <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
487
- reset above); script-level state and the beforeNavigate registration sit outside the block,
488
- so only the template rebuilds. -->
489
- {#key entryKey}
490
- <!-- The form's default button. The header's Publish (while pending) and Save submit the edit form
491
- from outside it, and the default button for implicit submission (Enter in a single-line
492
- field) is the FIRST form-owned submit button in tree order, which the header's Publish would
493
- otherwise be: Enter in the title would publish a half-finished edit. This sr-only button sits
494
- before the header, carries no formaction (so an implicit submit posts ?/save), and mirrors
495
- Save's disabled state, so Enter on a clean page submits nothing. -->
496
- <button
497
- type="submit"
498
- form="cairn-edit-form"
499
- class="sr-only"
500
- tabindex="-1"
501
- aria-hidden="true"
502
- disabled={busy || (!dirty && !data.isNew)}
503
- >
504
- Save
505
- </button>
506
-
507
- <!-- The sticky action header, a glass ruler: a translucent base-200 veil with backdrop blur the
508
- page scrolls beneath, never a second opaque band (the admin topbar keeps that role). It sticks
509
- under the h-16 topbar and bleeds across AdminLayout's content padding (p-4, lg:p-8) with
510
- matching negative margins, so the veil spans the whole content column. -->
511
- <header
512
- 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"
513
- >
514
- <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
515
- <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
516
- <a
517
- href={`/admin/${data.conceptId}`}
518
- class="flex shrink-0 items-center gap-0.5 text-sm text-[var(--color-muted)] transition-colors hover:text-base-content"
519
- >
520
- <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">
521
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18-6-6 6-6" />
522
- </svg>
523
- {data.label}
524
- </a>
525
- <!-- The manuscript heading below is the visible title; repeating it here read as
526
- duplication, so the header keeps the h1 for assistive tech only. -->
527
- <h1 class="sr-only">{data.title}</h1>
670
+ <!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
671
+ topbar context portal, to the right of the breadcrumb (the way back). Two clusters: the
672
+ document status behind a hairline (status badge, save-state) and the actions split by a
673
+ second hairline into the quiet pair (Details, overflow) and the lifecycle pair
674
+ (Publish, Save). The breadcrumb itself stays in AdminLayout, so the duplicate is gone. -->
675
+ {#snippet desk()}
676
+ <div class="ml-2 flex min-w-0 flex-1 items-center gap-3">
677
+ <!-- The document status, fenced off by a hairline on its left. -->
678
+ <div class="flex min-w-0 items-center gap-2.5 border-l border-[var(--cairn-card-border)] pl-3">
528
679
  <span class="badge badge-sm font-medium {statusBadge}">{status}</span>
529
680
  {#if data.frontmatter.draft === true}
530
681
  <span class="badge badge-neutral badge-sm font-medium">Hidden</span>
@@ -540,7 +691,39 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
540
691
  {saveState}
541
692
  </span>
542
693
  </div>
543
- <div class="ml-auto flex items-center gap-2">
694
+
695
+ <div class="ml-auto flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3">
696
+ <!-- The form's default button, FIRST in the actions cluster (and so first among the form's
697
+ submit buttons in tree order, since the band precedes the form). The default button for
698
+ implicit submission (Enter in a single-line field) is the first form-owned submit button
699
+ in tree order; without this the Publish button would claim it and Enter in the title
700
+ would publish a half-finished edit. This sr-only button carries no formaction (so an
701
+ implicit submit posts ?/save) and mirrors Save's disabled state, so Enter on a clean
702
+ page submits nothing. -->
703
+ <button
704
+ type="submit"
705
+ form="cairn-edit-form"
706
+ class="sr-only"
707
+ tabindex="-1"
708
+ aria-hidden="true"
709
+ disabled={busy || (!dirty && !data.isNew)}
710
+ >
711
+ Save
712
+ </button>
713
+
714
+ <!-- The quiet pair: the Details panel trigger and the overflow menu. The trigger toggles
715
+ the slide-over; aria-expanded mirrors its state and focus returns here on close. -->
716
+ <button
717
+ bind:this={detailsTrigger}
718
+ type="button"
719
+ class="btn btn-ghost btn-sm btn-square"
720
+ aria-label="Details"
721
+ title="Details"
722
+ aria-expanded={detailsOpen}
723
+ onclick={toggleDetails}
724
+ >
725
+ <PanelRightIcon class="h-4 w-4" aria-hidden="true" />
726
+ </button>
544
727
  <!-- The overflow menu is a DaisyUI v5 popover dropdown: click to open (never
545
728
  focus-in-transit), Escape and light dismiss from the Popover API, and the
546
729
  anchor-name/position-anchor pair places the panel under its trigger. -->
@@ -580,26 +763,34 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
580
763
  </button>
581
764
  </li>
582
765
  </ul>
583
- {#if data.pending}
584
- <!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
585
- <button type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
586
- {#if publishing}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Publishing…{:else}Publish{/if}
766
+
767
+ <!-- The lifecycle pair, fenced off by their own hairline. -->
768
+ <div class="flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3">
769
+ {#if data.pending}
770
+ <!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
771
+ <button bind:this={publishButton} type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
772
+ {#if publishing}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Publishing…{:else}Publish{/if}
773
+ </button>
774
+ {/if}
775
+ <!-- Save sleeps while the page is clean, agreeing with the band indicator; a new entry
776
+ stays saveable so it can be created as loaded. -->
777
+ <button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
778
+ {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
587
779
  </button>
588
- {/if}
589
- <!-- Save sleeps while the page is clean, agreeing with the header indicator; a new entry
590
- stays saveable so it can be created as loaded. -->
591
- <button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
592
- {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
593
- </button>
780
+ </div>
594
781
  </div>
595
782
  </div>
596
- </header>
783
+ {/snippet}
597
784
 
785
+ <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
786
+ reset above); script-level state and the beforeNavigate registration sit outside the block,
787
+ so only the template rebuilds. -->
788
+ {#key entryKey}
598
789
  <div class="sr-only" aria-live="polite">{politeMessage}</div>
599
790
  <div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
600
791
 
601
- <!-- The feedback strip slides in just under the header: @starting-style drives the entry, so the
602
- motion is pure CSS and the admin sheet's prefers-reduced-motion rule squashes it. -->
792
+ <!-- The feedback strip slides in directly under the one header band: @starting-style drives the
793
+ entry, so the motion is pure CSS and the admin sheet's prefers-reduced-motion rule squashes it. -->
603
794
  {#if flash}
604
795
  <div class="cairn-feedback alert alert-success mb-4 text-sm transition-all duration-300 starting:-translate-y-2 starting:opacity-0">
605
796
  {flash}
@@ -651,7 +842,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
651
842
  onsubmit={onEditSubmit}
652
843
  oninput={onFormInput}
653
844
  oninvalidcapture={onFormInvalid}
654
- class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_17rem] lg:gap-10'}
655
845
  >
656
846
  <CsrfField />
657
847
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
@@ -662,8 +852,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
662
852
  measure (49rem covers it at the prose type step), markup puts the ceiling near 89ch of
663
853
  the base face for tables, attributed directives, and long URLs. The toggle lives in the
664
854
  card footer with the other writing preferences. -->
665
- <div class={mode === 'preview' ? 'lg:order-1' : `lg:order-1 mx-auto w-full ${surface === 'prose' ? 'max-w-[49rem]' : 'max-w-[56rem]'}`}>
666
- {#if titleField}
855
+ <div class={mode === 'preview' ? '' : `mx-auto w-full ${surface === 'prose' ? 'max-w-[49rem]' : 'max-w-[56rem]'}`}>
856
+ <!-- The page's accessible name. The visible title is a borderless input, so a real heading
857
+ lives here for assistive tech (the band no longer carries one). -->
858
+ <h1 class="sr-only">{data.title}</h1>
859
+ {#if titleField && !zen}
667
860
  <!-- The hoisted document title: large, borderless, in the display face, so the manuscript
668
861
  reads as the protagonist. It submits as name="title", the same field as before. The
669
862
  admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there).
@@ -691,6 +884,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
691
884
  role="group"
692
885
  aria-label="Editor"
693
886
  >
887
+ {#if !zen}
694
888
  <EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
695
889
  {#snippet insertControls()}
696
890
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
@@ -747,6 +941,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
747
941
  </button>
748
942
  {/snippet}
749
943
  </EditorToolbar>
944
+ {/if}
750
945
  <!-- The Write pane stays mounted while Preview shows, so CodeMirror keeps its caret, scroll
751
946
  position, and undo history across the tab switch. -->
752
947
  <div id="cairn-pane-write" role="tabpanel" aria-labelledby="cairn-tab-write" class:hidden={mode === 'preview'}>
@@ -818,15 +1013,22 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
818
1013
  carries the writing environment (the count, the persisted writing modes, help) while
819
1014
  the top toolbar acts on the text; the toggles live here visible rather than buried in
820
1015
  an overflow menu. -->
1016
+ {#if !zen}
821
1017
  <div class="flex items-center justify-between border-t border-[var(--cairn-card-border)] px-3 py-1 text-xs text-[var(--color-muted)]">
822
1018
  <span>{wordLabel}</span>
823
- <div class="flex items-center gap-1">
824
- <!-- The pressed check is the non-color state cue (WCAG 1.4.1): pressed and idle
825
- toggles share weight and size, so hue alone must not carry the state. -->
826
- <div role="group" aria-label="Editing surface" class="flex items-center gap-1">
1019
+ <div class="flex items-center gap-3.5">
1020
+ <!-- The posture pair is one bordered segmented control: the shared border carries the
1021
+ pick-one semantics, so no group label is needed (the spec considered and declined
1022
+ them). The pressed check is the non-color state cue (WCAG 1.4.1): the segments share
1023
+ weight outside the active one, so hue alone never carries the state. -->
1024
+ <div
1025
+ role="group"
1026
+ aria-label="Editing surface"
1027
+ class="bg-base-100 inline-flex items-center overflow-hidden rounded-lg border border-[var(--cairn-card-border)]"
1028
+ >
827
1029
  <button
828
1030
  type="button"
829
- class={footerToggleClass(surface === 'prose')}
1031
+ class={segButtonClass(surface === 'prose')}
830
1032
  aria-pressed={surface === 'prose'}
831
1033
  onclick={() => setSurface('prose')}
832
1034
  >
@@ -835,7 +1037,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
835
1037
  </button>
836
1038
  <button
837
1039
  type="button"
838
- class={footerToggleClass(surface === 'markup')}
1040
+ class="{segButtonClass(surface === 'markup')} border-l border-[var(--cairn-card-border)]"
839
1041
  aria-pressed={surface === 'markup'}
840
1042
  onclick={() => setSurface('markup')}
841
1043
  >
@@ -843,29 +1045,43 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
843
1045
  Markup
844
1046
  </button>
845
1047
  </div>
846
- <div class="mx-1 h-4 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
847
- <button
848
- type="button"
849
- class={footerToggleClass(focusMode)}
850
- aria-pressed={focusMode}
851
- onclick={() => setFocusMode(!focusMode)}
852
- >
853
- {#if focusMode}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
854
- Focus mode
855
- </button>
856
- <button
857
- type="button"
858
- class={footerToggleClass(typewriter)}
859
- aria-pressed={typewriter}
860
- onclick={() => setTypewriter(!typewriter)}
861
- >
862
- {#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
863
- Typewriter
864
- </button>
865
- <div class="mx-1 h-4 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
1048
+ <!-- Focus mode and Typewriter are standalone check-and-tint toggles, no border. -->
1049
+ <div class="flex items-center gap-0.5">
1050
+ <button
1051
+ type="button"
1052
+ class={ftrToggleClass(focusMode)}
1053
+ aria-pressed={focusMode}
1054
+ onclick={() => setFocusMode(!focusMode)}
1055
+ >
1056
+ {#if focusMode}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
1057
+ Focus mode
1058
+ </button>
1059
+ <button
1060
+ type="button"
1061
+ class={ftrToggleClass(typewriter)}
1062
+ aria-pressed={typewriter}
1063
+ onclick={() => setTypewriter(!typewriter)}
1064
+ >
1065
+ {#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
1066
+ Typewriter
1067
+ </button>
1068
+ <!-- Zen enters from the footer (and Ctrl+Shift+.); it reads as a peer writing-mode
1069
+ toggle here, but once on it hides the whole footer, so the chip carries the way out. -->
1070
+ <button
1071
+ type="button"
1072
+ class={ftrToggleClass(zen)}
1073
+ aria-pressed={zen}
1074
+ onclick={() => setZen(!zen)}
1075
+ >
1076
+ {#if zen}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
1077
+ Zen
1078
+ </button>
1079
+ </div>
1080
+ <!-- Markdown help is a plain underlined link-styled button (a reference, not a control),
1081
+ no border, no fill. -->
866
1082
  <button
867
1083
  type="button"
868
- class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
1084
+ class="ftr-link cursor-pointer text-[var(--color-muted)] underline [text-decoration-color:color-mix(in_oklab,currentColor_40%,transparent)] [text-underline-offset:2px] hover:text-[var(--color-primary)]"
869
1085
  aria-haspopup="dialog"
870
1086
  onclick={() => helpDialog?.open()}
871
1087
  >
@@ -873,20 +1089,44 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
873
1089
  </button>
874
1090
  </div>
875
1091
  </div>
1092
+ {/if}
876
1093
  </div>
877
1094
  </div>
878
1095
 
879
- <!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
880
- field edits survive the round trip) and the editor column above spans the whole width. -->
881
- <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
882
- <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
883
- a real legend that screen readers announce with the fields it holds. -->
884
- <!-- Quieter than the editor card on purpose (hairline, no shadow): the editor is the one
885
- floating object on the page, and the details read as margin furniture beside it. -->
886
- <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-6 p-4">
1096
+ <!-- The details slide-over: a fixed panel below the band, the frontmatter groups behind a
1097
+ Details/close header. It stays physically inside the edit form so its uncontrolled fields
1098
+ submit; `hidden` when closed takes it out of the a11y tree and the tab order while its
1099
+ display:none fields still post. role="region" with aria-label names it for assistive tech;
1100
+ focus moves to the close button on open and back to the trigger on close (the
1101
+ region-with-focus-management pattern). -->
1102
+ <aside
1103
+ role="region"
1104
+ aria-label="Entry details"
1105
+ hidden={!detailsOpen}
1106
+ class="fixed right-0 top-16 bottom-0 z-30 w-[19rem] overflow-y-auto border-l border-[var(--cairn-card-border)] bg-base-100 p-4 shadow-[var(--cairn-shadow)]"
1107
+ >
1108
+ <!-- The panel header: the Details eyebrow and the close button. The eyebrow is a plain span
1109
+ (not a legend), so the three group legends below still read as the only sidebar legends. -->
1110
+ <div class="mb-3.5 flex items-center justify-between">
1111
+ <span class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Details</span>
1112
+ <button
1113
+ bind:this={detailsClose}
1114
+ type="button"
1115
+ class="btn btn-ghost btn-xs btn-square"
1116
+ aria-label="Close details"
1117
+ onclick={closeDetails}
1118
+ >
1119
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
1120
+ </button>
1121
+ </div>
1122
+ <!-- Three labeled groups. Each group is its own fieldset so its eyebrow is a real legend that
1123
+ screen readers announce with the fields it holds. -->
1124
+ <div class="flex flex-col gap-6">
887
1125
  {#if detailFields.length}
888
1126
  <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
889
- <legend class={eyebrowClass}>Details</legend>
1127
+ <!-- The panel header already shows the "Details" eyebrow, so this group's legend stays for
1128
+ the screen-reader grouping but hides visually, the way the mockup carries it once. -->
1129
+ <legend class="sr-only">Details</legend>
890
1130
  {#each detailFields as field (field.name)}
891
1131
  {#if field.type === 'textarea'}
892
1132
  {@const f = field as TextareaField}
@@ -974,6 +1214,27 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
974
1214
  </aside>
975
1215
  </form>
976
1216
 
1217
+ <!-- The floating zen chip (the mockup's .zen-chip): fixed top-right, it carries the two things the
1218
+ WordPress/Ghost rule says never disappear under zen, the live save state and the way out. The
1219
+ save-state span mirrors the band's, so the warning dot flips with `dirty` live; the Exit button
1220
+ restores the chrome, with the Esc hint as the secondary cue. It renders only under zen. -->
1221
+ {#if zen}
1222
+ <div class="cairn-zen-chip fixed right-[1.125rem] top-[0.875rem] z-40 flex items-center gap-2 rounded-xl border border-[var(--cairn-card-border)] bg-base-100 px-2.5 py-[5px] text-xs text-[var(--color-muted)] shadow-[var(--cairn-shadow)]">
1223
+ <span class="cairn-save-state flex items-center gap-1.5" aria-live="off">
1224
+ {#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{:else}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-success" aria-hidden="true"></span>{/if}
1225
+ {dirty ? 'Unsaved changes' : 'Saved'}
1226
+ </span>
1227
+ <span class="opacity-50" aria-hidden="true">·</span>
1228
+ <button
1229
+ type="button"
1230
+ class="ftr-link inline-flex items-center cursor-pointer text-[var(--color-muted)] underline [text-decoration-color:color-mix(in_oklab,currentColor_40%,transparent)] [text-underline-offset:2px] hover:text-[var(--color-primary)]"
1231
+ onclick={() => setZen(false)}
1232
+ >
1233
+ Exit zen<kbd class="ml-1.5 inline-block rounded border border-[var(--cairn-card-border)] px-1 text-[0.625rem] no-underline" aria-hidden="true">Esc</kbd>
1234
+ </button>
1235
+ </div>
1236
+ {/if}
1237
+
977
1238
  <!-- The toolbar's insert dialogs, mounted headless outside the edit form: each holds its own
978
1239
  <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
979
1240
  tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
@@ -994,6 +1255,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
994
1255
  onsubmitting={() => (leaving = true)}
995
1256
  />
996
1257
  <MarkdownHelpDialog bind:this={helpDialog} />
1258
+ <ShortcutsDialog bind:this={shortcutsDialog} />
997
1259
  <DeleteDialog
998
1260
  bind:this={deleteDialog}
999
1261
  trigger={false}