@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.
- package/CHANGELOG.md +46 -0
- package/dist/components/AdminLayout.svelte +58 -23
- package/dist/components/EditPage.svelte +456 -124
- package/dist/components/EditPage.svelte.d.ts +4 -2
- package/dist/components/EditorToolbar.svelte +29 -53
- package/dist/components/EditorToolbar.svelte.d.ts +3 -11
- package/dist/components/MarkdownEditor.svelte +163 -24
- package/dist/components/MarkdownEditor.svelte.d.ts +3 -0
- package/dist/components/MarkdownHelpDialog.svelte +5 -0
- package/dist/components/ShortcutsDialog.svelte +37 -0
- package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
- package/dist/components/ShortcutsGrid.svelte +18 -0
- package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +199 -99
- package/dist/components/editor-folding.d.ts +7 -0
- package/dist/components/editor-folding.js +331 -0
- package/dist/components/editor-highlight.js +55 -6
- package/dist/components/editor-shortcuts.d.ts +16 -0
- package/dist/components/editor-shortcuts.js +36 -0
- package/dist/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
- package/dist/components/fonts/ibm-plex-sans.woff2 +0 -0
- package/dist/components/markdown-directives.d.ts +17 -0
- package/dist/components/markdown-directives.js +41 -0
- package/dist/components/topbar-context.d.ts +13 -0
- package/dist/components/topbar-context.js +17 -0
- package/dist/sveltekit/static-admin-page.js +2 -2
- package/package.json +1 -1
- package/src/lib/components/AdminLayout.svelte +58 -23
- package/src/lib/components/EditPage.svelte +456 -124
- package/src/lib/components/EditorToolbar.svelte +29 -53
- package/src/lib/components/MarkdownEditor.svelte +163 -24
- package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
- package/src/lib/components/ShortcutsDialog.svelte +37 -0
- package/src/lib/components/ShortcutsGrid.svelte +18 -0
- package/src/lib/components/cairn-admin.css +51 -14
- package/src/lib/components/editor-folding.ts +356 -0
- package/src/lib/components/editor-highlight.ts +54 -4
- package/src/lib/components/editor-shortcuts.ts +42 -0
- package/src/lib/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
- package/src/lib/components/fonts/ibm-plex-sans.woff2 +0 -0
- package/src/lib/components/markdown-directives.ts +42 -0
- package/src/lib/components/topbar-context.ts +30 -0
- package/src/lib/sveltekit/static-admin-page.ts +2 -2
- package/dist/components/fonts/figtree.woff2 +0 -0
- 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
|
|
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
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
644
|
-
|
|
645
|
-
the
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
<!--
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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}
|