@glw907/cairn-cms 0.52.1 → 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 (45) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/components/AdminLayout.svelte +58 -23
  3. package/dist/components/EditPage.svelte +456 -124
  4. package/dist/components/EditPage.svelte.d.ts +4 -2
  5. package/dist/components/EditorToolbar.svelte +29 -53
  6. package/dist/components/EditorToolbar.svelte.d.ts +3 -11
  7. package/dist/components/MarkdownEditor.svelte +163 -24
  8. package/dist/components/MarkdownEditor.svelte.d.ts +3 -0
  9. package/dist/components/MarkdownHelpDialog.svelte +5 -0
  10. package/dist/components/ShortcutsDialog.svelte +37 -0
  11. package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
  12. package/dist/components/ShortcutsGrid.svelte +18 -0
  13. package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
  14. package/dist/components/cairn-admin.css +199 -99
  15. package/dist/components/editor-folding.d.ts +7 -0
  16. package/dist/components/editor-folding.js +331 -0
  17. package/dist/components/editor-highlight.js +55 -6
  18. package/dist/components/editor-shortcuts.d.ts +16 -0
  19. package/dist/components/editor-shortcuts.js +36 -0
  20. package/dist/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
  21. package/dist/components/fonts/ibm-plex-sans.woff2 +0 -0
  22. package/dist/components/markdown-directives.d.ts +17 -0
  23. package/dist/components/markdown-directives.js +41 -0
  24. package/dist/components/topbar-context.d.ts +13 -0
  25. package/dist/components/topbar-context.js +17 -0
  26. package/dist/sveltekit/static-admin-page.js +2 -2
  27. package/package.json +1 -1
  28. package/src/lib/components/AdminLayout.svelte +58 -23
  29. package/src/lib/components/EditPage.svelte +456 -124
  30. package/src/lib/components/EditorToolbar.svelte +29 -53
  31. package/src/lib/components/MarkdownEditor.svelte +163 -24
  32. package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
  33. package/src/lib/components/ShortcutsDialog.svelte +37 -0
  34. package/src/lib/components/ShortcutsGrid.svelte +18 -0
  35. package/src/lib/components/cairn-admin.css +51 -14
  36. package/src/lib/components/editor-folding.ts +356 -0
  37. package/src/lib/components/editor-highlight.ts +54 -4
  38. package/src/lib/components/editor-shortcuts.ts +42 -0
  39. package/src/lib/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
  40. package/src/lib/components/fonts/ibm-plex-sans.woff2 +0 -0
  41. package/src/lib/components/markdown-directives.ts +42 -0
  42. package/src/lib/components/topbar-context.ts +30 -0
  43. package/src/lib/sveltekit/static-admin-page.ts +2 -2
  44. package/dist/components/fonts/figtree.woff2 +0 -0
  45. package/src/lib/components/fonts/figtree.woff2 +0 -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
@@ -12,12 +13,18 @@ sizes to a persisted device width picked from the toolbar's capsule. A sticky gl
12
13
  carries the breadcrumb, the status badges, the save-state indicator,
13
14
  and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
14
15
  pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
15
- transient flashes, and the editor card's footer holds the word count and the Markdown help.
16
+ transient flashes, and the editor card's footer is the writing-environment strip: the word
17
+ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
16
18
  -->
17
19
  <script lang="ts">
18
20
  import { flushSync, untrack } from 'svelte';
19
21
  import { beforeNavigate } from '$app/navigation';
20
22
  import { page } from '$app/state';
23
+ import BlocksIcon from '@lucide/svelte/icons/blocks';
24
+ import LinkIcon from '@lucide/svelte/icons/link';
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';
21
28
  import CsrfField from './CsrfField.svelte';
22
29
  import MarkdownEditor from './MarkdownEditor.svelte';
23
30
  import EditorToolbar from './EditorToolbar.svelte';
@@ -27,6 +34,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
27
34
  import DeleteDialog from './DeleteDialog.svelte';
28
35
  import RenameDialog from './RenameDialog.svelte';
29
36
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
37
+ import ShortcutsDialog from './ShortcutsDialog.svelte';
30
38
  import { cairnLinkCompletionSource } from './link-completion.js';
31
39
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
32
40
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
@@ -54,6 +62,25 @@ transient flashes, and the editor card's footer holds the word count and the Mar
54
62
 
55
63
  let { data, registry, render, icons, form }: Props = $props();
56
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
+
57
84
  // `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
58
85
  // the author's edited markdown as form.body, so seed from that when present to keep the edits and
59
86
  // the broken link they were told to fix. On the success and delete-refused paths form carries no
@@ -100,13 +127,23 @@ transient flashes, and the editor card's footer holds the word count and the Mar
100
127
 
101
128
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
102
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);
103
134
 
104
- // A required sidebar field hidden by Preview cannot take the browser's validation report: an
105
- // invisible control is unfocusable, so the browser cancels the save silently with no message.
106
- // This capture-phase invalid listener flips back to Write first, and flushSync forces the pane
107
- // swap inside the event, so the report that follows the invalid events lands on a visible
108
- // control and the author sees what blocked the save.
109
- 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
+ }
110
147
  if (mode === 'write') return;
111
148
  flushSync(() => (mode = 'write'));
112
149
  }
@@ -137,8 +174,73 @@ transient flashes, and the editor card's footer holds the word count and the Mar
137
174
  // Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
138
175
  // parentheses, and consumers compile this source with their own svelte.
139
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
+ }
140
190
  if (!(e.ctrlKey || e.metaKey)) return;
141
- 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;
142
244
  // Always claim the shortcut so the browser's save-page dialog never opens over the admin.
143
245
  e.preventDefault();
144
246
  // Gate the submit itself: an in-flight POST must not race a second one, a clean page has
@@ -146,7 +248,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
146
248
  // an open modal would act on a surface the author cannot see.
147
249
  if (busy) return;
148
250
  if (!dirty && !data.isNew) return;
149
- if ((e.target as Element | null)?.closest?.('dialog')) return;
251
+ if (inDialog) return;
150
252
  editForm?.requestSubmit();
151
253
  };
152
254
  window.addEventListener('beforeunload', onBeforeUnload);
@@ -178,16 +280,28 @@ transient flashes, and the editor card's footer holds the word count and the Mar
178
280
  device = id;
179
281
  localStorage.setItem(deviceStorageKey, id);
180
282
  }
181
- // The writing modes (focus, typewriter), per-browser preferences on the device pick's pattern:
182
- // off by default, read in an effect so SSR never touches localStorage, written by the
183
- // toolbar's toggles. The effect tracks nothing reactive, so it runs once.
283
+ // The writing modes (focus, typewriter) and the surface posture, per-browser preferences on
284
+ // the device pick's pattern: read in an effect so SSR never touches localStorage, written by
285
+ // the card footer's toggles. The effect tracks nothing reactive, so it runs once.
184
286
  const focusStorageKey = 'cairn-editor-focus-mode';
185
287
  const typewriterStorageKey = 'cairn-editor-typewriter';
288
+ const surfaceStorageKey = 'cairn-editor-surface';
289
+ const zenStorageKey = 'cairn-editor-zen';
186
290
  let focusMode = $state(false);
187
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);
297
+ // The surface posture: prose (the writing instrument) by default; markup is the dense
298
+ // working surface.
299
+ let surface = $state<'prose' | 'markup'>('prose');
188
300
  $effect(() => {
189
301
  focusMode = localStorage.getItem(focusStorageKey) === 'true';
190
302
  typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
303
+ zen = localStorage.getItem(zenStorageKey) === 'true';
304
+ if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
191
305
  });
192
306
  function setFocusMode(on: boolean) {
193
307
  focusMode = on;
@@ -197,6 +311,43 @@ transient flashes, and the editor card's footer holds the word count and the Mar
197
311
  typewriter = on;
198
312
  localStorage.setItem(typewriterStorageKey, String(on));
199
313
  }
314
+ function setSurface(posture: 'prose' | 'markup') {
315
+ surface = posture;
316
+ localStorage.setItem(surfaceStorageKey, posture);
317
+ }
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)]'}`;
350
+ }
200
351
  const activeDevice = $derived(previewDevice(device));
201
352
  // The iframe document around the rendered html: the site's stylesheets from the adapter's
202
353
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
@@ -221,6 +372,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
221
372
  let renameDialog = $state<DialogHandle | null>(null);
222
373
  // The Markdown cheat sheet, opened from the editor card's footer.
223
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);
224
377
 
225
378
  // Whether the registry offers anything insertable, the same condition the insert dialog lists
226
379
  // by, so the toolbar trigger and the dialog appear and disappear together.
@@ -238,11 +391,35 @@ transient flashes, and the editor card's footer holds the word count and the Mar
238
391
  return 'badge-ghost';
239
392
  });
240
393
 
241
- // 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
242
395
  // event into aria-expanded on the trigger.
243
396
  let actionsMenu = $state<HTMLUListElement | null>(null);
244
397
  let actionsOpen = $state(false);
245
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
+
246
423
  // An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
247
424
  // dialog already closes an auto popover, so the explicit hide fires only when the menu is
248
425
  // still up.
@@ -298,6 +475,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
298
475
  leaving = false;
299
476
  fieldsDirty = false;
300
477
  mode = 'write';
478
+ detailsOpen = false;
301
479
  previewHtml = '';
302
480
  previewFailed = false;
303
481
  removedLinks = [];
@@ -403,7 +581,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
403
581
  function onEditorKeydown(e: KeyboardEvent) {
404
582
  if (!(e.ctrlKey || e.metaKey)) return;
405
583
  const key = e.key.toLowerCase();
406
- 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') {
407
591
  e.preventDefault();
408
592
  format('bold');
409
593
  } else if (key === 'i') {
@@ -414,6 +598,24 @@ transient flashes, and the editor card's footer holds the word count and the Mar
414
598
  webLinkDialog?.open();
415
599
  }
416
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
+ }
417
619
 
418
620
  // Render the design-accurate preview as the body changes, debounced. The site's render is the
419
621
  // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
@@ -465,48 +667,15 @@ transient flashes, and the editor card's footer holds the word count and the Mar
465
667
  const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
466
668
  </script>
467
669
 
468
- <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
469
- reset above); script-level state and the beforeNavigate registration sit outside the block,
470
- so only the template rebuilds. -->
471
- {#key entryKey}
472
- <!-- The form's default button. The header's Publish (while pending) and Save submit the edit form
473
- from outside it, and the default button for implicit submission (Enter in a single-line
474
- field) is the FIRST form-owned submit button in tree order, which the header's Publish would
475
- otherwise be: Enter in the title would publish a half-finished edit. This sr-only button sits
476
- before the header, carries no formaction (so an implicit submit posts ?/save), and mirrors
477
- Save's disabled state, so Enter on a clean page submits nothing. -->
478
- <button
479
- type="submit"
480
- form="cairn-edit-form"
481
- class="sr-only"
482
- tabindex="-1"
483
- aria-hidden="true"
484
- disabled={busy || (!dirty && !data.isNew)}
485
- >
486
- Save
487
- </button>
488
-
489
- <!-- The sticky action header, a glass ruler: a translucent base-200 veil with backdrop blur the
490
- page scrolls beneath, never a second opaque band (the admin topbar keeps that role). It sticks
491
- under the h-16 topbar and bleeds across AdminLayout's content padding (p-4, lg:p-8) with
492
- matching negative margins, so the veil spans the whole content column. -->
493
- <header
494
- 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"
495
- >
496
- <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
497
- <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
498
- <a
499
- href={`/admin/${data.conceptId}`}
500
- class="flex shrink-0 items-center gap-0.5 text-sm text-[var(--color-muted)] transition-colors hover:text-base-content"
501
- >
502
- <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">
503
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18-6-6 6-6" />
504
- </svg>
505
- {data.label}
506
- </a>
507
- <!-- The manuscript heading below is the visible title; repeating it here read as
508
- duplication, so the header keeps the h1 for assistive tech only. -->
509
- <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">
510
679
  <span class="badge badge-sm font-medium {statusBadge}">{status}</span>
511
680
  {#if data.frontmatter.draft === true}
512
681
  <span class="badge badge-neutral badge-sm font-medium">Hidden</span>
@@ -522,7 +691,39 @@ transient flashes, and the editor card's footer holds the word count and the Mar
522
691
  {saveState}
523
692
  </span>
524
693
  </div>
525
- <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>
526
727
  <!-- The overflow menu is a DaisyUI v5 popover dropdown: click to open (never
527
728
  focus-in-transit), Escape and light dismiss from the Popover API, and the
528
729
  anchor-name/position-anchor pair places the panel under its trigger. -->
@@ -562,26 +763,34 @@ transient flashes, and the editor card's footer holds the word count and the Mar
562
763
  </button>
563
764
  </li>
564
765
  </ul>
565
- {#if data.pending}
566
- <!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
567
- <button type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
568
- {#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}
569
779
  </button>
570
- {/if}
571
- <!-- Save sleeps while the page is clean, agreeing with the header indicator; a new entry
572
- stays saveable so it can be created as loaded. -->
573
- <button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
574
- {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
575
- </button>
780
+ </div>
576
781
  </div>
577
782
  </div>
578
- </header>
783
+ {/snippet}
579
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}
580
789
  <div class="sr-only" aria-live="polite">{politeMessage}</div>
581
790
  <div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
582
791
 
583
- <!-- The feedback strip slides in just under the header: @starting-style drives the entry, so the
584
- 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. -->
585
794
  {#if flash}
586
795
  <div class="cairn-feedback alert alert-success mb-4 text-sm transition-all duration-300 starting:-translate-y-2 starting:opacity-0">
587
796
  {flash}
@@ -633,29 +842,39 @@ transient flashes, and the editor card's footer holds the word count and the Mar
633
842
  onsubmit={onEditSubmit}
634
843
  oninput={onFormInput}
635
844
  oninvalidcapture={onFormInvalid}
636
- class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
637
845
  >
638
846
  <CsrfField />
639
847
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
640
848
 
641
849
  <!-- In Write mode the card hugs the manuscript: the column caps near the 70ch measure and
642
850
  centers, so the card frame never spans emptiness on a wide window. Preview keeps the full
643
- column for its device frames. 48rem = the measure (70ch of the editor face at 1rem is
644
- about 42rem) plus the surface padding and the card borders, with enough left over that
645
- the toolbar keeps its single row. -->
646
- <div class={mode === 'preview' ? 'lg:order-1' : 'lg:order-1 mx-auto w-full max-w-[48rem]'}>
647
- {#if titleField}
851
+ column for its device frames. The cap follows the surface posture: prose hugs its 72ch
852
+ measure (49rem covers it at the prose type step), markup puts the ceiling near 89ch of
853
+ the base face for tables, attributed directives, and long URLs. The toggle lives in the
854
+ card footer with the other writing preferences. -->
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}
648
860
  <!-- The hoisted document title: large, borderless, in the display face, so the manuscript
649
861
  reads as the protagonist. It submits as name="title", the same field as before. The
650
- admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there). -->
651
- <input
652
- 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)]"
653
- name="title"
654
- value={str(data.frontmatter.title)}
655
- placeholder={titleField.label}
656
- aria-label={titleField.label}
657
- required={titleField.required}
658
- />
862
+ admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there).
863
+ In markup posture the surface fills the card, so shared inline padding is the whole
864
+ alignment; in prose posture the manuscript centers on its measure, so the wrapper
865
+ mirrors that geometry (the editor face at the prose size, the measure, auto margins).
866
+ Under focus mode the title eases back with the rest of the context unless it holds
867
+ focus itself. -->
868
+ <div class={surface === 'prose' ? 'mb-4 mx-auto w-full max-w-[72ch] px-5 text-[1.0625rem] font-[family-name:var(--font-editor,ui-monospace,monospace)]' : 'mb-4 w-full px-5'}>
869
+ <input
870
+ class="cairn-doc-title w-full border-0 bg-transparent text-3xl font-bold tracking-tight font-[family-name:var(--font-display)] placeholder:text-[var(--color-muted)] {focusMode ? 'cairn-doc-title-dim' : ''}"
871
+ name="title"
872
+ value={str(data.frontmatter.title)}
873
+ placeholder={titleField.label}
874
+ aria-label={titleField.label}
875
+ required={titleField.required}
876
+ />
877
+ </div>
659
878
  {/if}
660
879
  <!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
661
880
  reads as a single object. The card carries the formatting shortcuts for everything in it. -->
@@ -665,52 +884,47 @@ transient flashes, and the editor card's footer holds the word count and the Mar
665
884
  role="group"
666
885
  aria-label="Editor"
667
886
  >
668
- <EditorToolbar
669
- {format}
670
- {mode}
671
- onMode={setMode}
672
- {device}
673
- onDevice={setDevice}
674
- {focusMode}
675
- onFocusMode={setFocusMode}
676
- {typewriter}
677
- onTypewriter={setTypewriter}
678
- >
887
+ {#if !zen}
888
+ <EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
679
889
  {#snippet insertControls()}
680
890
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
681
- dialogs themselves mount outside the edit form at the bottom of this component. -->
891
+ dialogs themselves mount outside the edit form at the bottom of this component.
892
+ Icon buttons like the format strip beside them: the labels live in aria-label and
893
+ the title tooltip, so the Insert group reads as part of one instrument strip. -->
682
894
  {#if hasComponents}
683
895
  <button
684
896
  type="button"
685
- class="btn btn-sm btn-ghost"
897
+ class="btn btn-sm btn-ghost btn-square"
686
898
  aria-haspopup="dialog"
687
899
  aria-label="Insert block"
900
+ title="Insert block"
688
901
  disabled={insertDisabled}
689
902
  onclick={() => insertDialog?.open()}
690
903
  >
691
- Insert block
904
+ <BlocksIcon class="h-4 w-4" aria-hidden="true" />
692
905
  </button>
693
906
  {/if}
694
907
  <button
695
908
  type="button"
696
- class="btn btn-sm btn-ghost"
909
+ class="btn btn-sm btn-ghost btn-square"
697
910
  aria-haspopup="dialog"
698
911
  aria-label="Web link (Ctrl+K)"
699
912
  title="Web link (Ctrl+K)"
700
913
  disabled={insertDisabled}
701
914
  onclick={() => webLinkDialog?.open()}
702
915
  >
703
- Web link
916
+ <LinkIcon class="h-4 w-4" aria-hidden="true" />
704
917
  </button>
705
918
  <button
706
919
  type="button"
707
- class="btn btn-sm btn-ghost"
920
+ class="btn btn-sm btn-ghost btn-square"
708
921
  aria-haspopup="dialog"
709
922
  aria-label="Link to page"
923
+ title="Link to page"
710
924
  disabled={insertDisabled}
711
925
  onclick={() => linkPicker?.open()}
712
926
  >
713
- Link to page
927
+ <FileSymlinkIcon class="h-4 w-4" aria-hidden="true" />
714
928
  </button>
715
929
  <button
716
930
  type="button"
@@ -727,12 +941,14 @@ transient flashes, and the editor card's footer holds the word count and the Mar
727
941
  </button>
728
942
  {/snippet}
729
943
  </EditorToolbar>
944
+ {/if}
730
945
  <!-- The Write pane stays mounted while Preview shows, so CodeMirror keeps its caret, scroll
731
946
  position, and undo history across the tab switch. -->
732
947
  <div id="cairn-pane-write" role="tabpanel" aria-labelledby="cairn-tab-write" class:hidden={mode === 'preview'}>
733
948
  <MarkdownEditor
734
949
  bind:value={body}
735
950
  name="body"
951
+ {surface}
736
952
  registerInsert={(fn) => (insert = fn)}
737
953
  registerInsertLink={(fn) => (insertLink = fn)}
738
954
  registerGetSelection={(fn) => (getSelection = fn)}
@@ -793,41 +1009,135 @@ transient flashes, and the editor card's footer holds the word count and the Mar
793
1009
  </div>
794
1010
  {/if}
795
1011
  <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
796
- frame never jumps between tabs and the count keeps reading while proofing. -->
1012
+ frame never jumps between tabs and the count keeps reading while proofing. The strip
1013
+ carries the writing environment (the count, the persisted writing modes, help) while
1014
+ the top toolbar acts on the text; the toggles live here visible rather than buried in
1015
+ an overflow menu. -->
1016
+ {#if !zen}
797
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)]">
798
1018
  <span>{wordLabel}</span>
799
- <button
800
- type="button"
801
- class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
802
- aria-haspopup="dialog"
803
- onclick={() => helpDialog?.open()}
804
- >
805
- Markdown help
806
- </button>
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
+ >
1029
+ <button
1030
+ type="button"
1031
+ class={segButtonClass(surface === 'prose')}
1032
+ aria-pressed={surface === 'prose'}
1033
+ onclick={() => setSurface('prose')}
1034
+ >
1035
+ {#if surface === 'prose'}<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}
1036
+ Prose
1037
+ </button>
1038
+ <button
1039
+ type="button"
1040
+ class="{segButtonClass(surface === 'markup')} border-l border-[var(--cairn-card-border)]"
1041
+ aria-pressed={surface === 'markup'}
1042
+ onclick={() => setSurface('markup')}
1043
+ >
1044
+ {#if surface === 'markup'}<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}
1045
+ Markup
1046
+ </button>
1047
+ </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. -->
1082
+ <button
1083
+ type="button"
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)]"
1085
+ aria-haspopup="dialog"
1086
+ onclick={() => helpDialog?.open()}
1087
+ >
1088
+ Markdown help
1089
+ </button>
1090
+ </div>
807
1091
  </div>
1092
+ {/if}
808
1093
  </div>
809
1094
  </div>
810
1095
 
811
- <!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
812
- field edits survive the round trip) and the editor column above spans the whole width. -->
813
- <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
814
- <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
815
- a real legend that screen readers announce with the fields it holds. -->
816
- <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
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">
817
1125
  {#if detailFields.length}
818
1126
  <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
819
- <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>
820
1130
  {#each detailFields as field (field.name)}
821
1131
  {#if field.type === 'textarea'}
822
1132
  {@const f = field as TextareaField}
823
1133
  <label class="flex flex-col gap-1">
824
1134
  <span class="text-sm font-medium">{f.label}</span>
825
- <textarea class="textarea" name={f.name} aria-label={f.label} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
1135
+ <textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
826
1136
  </label>
827
1137
  {:else if field.type === 'date'}
828
1138
  <label class="flex flex-col gap-1">
829
1139
  <span class="text-sm font-medium">{field.label}</span>
830
- <input class="input" type="date" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} />
1140
+ <input class="input input-sm" type="date" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} />
831
1141
  </label>
832
1142
  {:else if field.type === 'boolean'}
833
1143
  <label class="label cursor-pointer justify-start gap-2">
@@ -860,7 +1170,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
860
1170
  <label class="flex flex-col gap-1">
861
1171
  <span class="text-sm font-medium">{f.label}</span>
862
1172
  <input
863
- class="input"
1173
+ class="input input-sm"
864
1174
  name={f.name}
865
1175
  aria-label={f.label}
866
1176
  placeholder={f.placeholder}
@@ -870,7 +1180,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
870
1180
  {:else}
871
1181
  <label class="flex flex-col gap-1">
872
1182
  <span class="text-sm font-medium">{field.label}</span>
873
- <input class="input" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} required={field.required} />
1183
+ <input class="input input-sm" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} required={field.required} />
874
1184
  </label>
875
1185
  {/if}
876
1186
  {/each}
@@ -904,6 +1214,27 @@ transient flashes, and the editor card's footer holds the word count and the Mar
904
1214
  </aside>
905
1215
  </form>
906
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
+
907
1238
  <!-- The toolbar's insert dialogs, mounted headless outside the edit form: each holds its own
908
1239
  <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
909
1240
  tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
@@ -924,6 +1255,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
924
1255
  onsubmitting={() => (leaving = true)}
925
1256
  />
926
1257
  <MarkdownHelpDialog bind:this={helpDialog} />
1258
+ <ShortcutsDialog bind:this={shortcutsDialog} />
927
1259
  <DeleteDialog
928
1260
  bind:this={deleteDialog}
929
1261
  trigger={false}