@glw907/cairn-cms 0.50.0 → 0.52.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 (80) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/components/EditPage.svelte +125 -16
  3. package/dist/components/EditPage.svelte.d.ts +4 -1
  4. package/dist/components/EditorToolbar.svelte +135 -10
  5. package/dist/components/EditorToolbar.svelte.d.ts +19 -2
  6. package/dist/components/MarkdownEditor.svelte +112 -6
  7. package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
  8. package/dist/components/cairn-admin.css +69 -9
  9. package/dist/components/editor-highlight.d.ts +2 -0
  10. package/dist/components/editor-highlight.js +79 -15
  11. package/dist/components/editor-modes.d.ts +26 -0
  12. package/dist/components/editor-modes.js +92 -0
  13. package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
  14. package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  15. package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  16. package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  17. package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  18. package/dist/components/markdown-directives.d.ts +51 -0
  19. package/dist/components/markdown-directives.js +130 -1
  20. package/dist/components/preview-doc.d.ts +27 -0
  21. package/dist/components/preview-doc.js +64 -0
  22. package/dist/content/compose.js +1 -0
  23. package/dist/content/types.d.ts +33 -0
  24. package/dist/diagnostics/conditions.js +24 -0
  25. package/dist/doctor/bin.js +30 -12
  26. package/dist/doctor/check-floors.d.ts +15 -0
  27. package/dist/doctor/check-floors.js +107 -0
  28. package/dist/doctor/check-probe.d.ts +3 -0
  29. package/dist/doctor/check-probe.js +123 -0
  30. package/dist/doctor/checks-github.js +1 -1
  31. package/dist/doctor/checks-local.d.ts +1 -0
  32. package/dist/doctor/checks-local.js +28 -2
  33. package/dist/doctor/cloudflare-api.js +2 -2
  34. package/dist/doctor/index.d.ts +28 -3
  35. package/dist/doctor/index.js +47 -6
  36. package/dist/doctor/types.d.ts +2 -0
  37. package/dist/doctor/wrangler-config.d.ts +4 -0
  38. package/dist/doctor/wrangler-config.js +11 -0
  39. package/dist/env.d.ts +2 -1
  40. package/dist/env.js +9 -4
  41. package/dist/index.d.ts +1 -1
  42. package/dist/sveltekit/content-routes.d.ts +5 -1
  43. package/dist/sveltekit/content-routes.js +25 -17
  44. package/dist/sveltekit/guard.d.ts +8 -2
  45. package/dist/sveltekit/guard.js +3 -1
  46. package/dist/sveltekit/nav-routes.js +3 -9
  47. package/dist/vite/index.d.ts +16 -0
  48. package/dist/vite/index.js +57 -13
  49. package/package.json +2 -2
  50. package/src/lib/components/EditPage.svelte +125 -16
  51. package/src/lib/components/EditorToolbar.svelte +135 -10
  52. package/src/lib/components/MarkdownEditor.svelte +112 -6
  53. package/src/lib/components/cairn-admin.css +95 -5
  54. package/src/lib/components/editor-highlight.ts +91 -14
  55. package/src/lib/components/editor-modes.ts +106 -0
  56. package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
  57. package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  58. package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  59. package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  60. package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  61. package/src/lib/components/markdown-directives.ts +151 -1
  62. package/src/lib/components/preview-doc.ts +82 -0
  63. package/src/lib/content/compose.ts +1 -0
  64. package/src/lib/content/types.ts +32 -0
  65. package/src/lib/diagnostics/conditions.ts +24 -0
  66. package/src/lib/doctor/bin.ts +35 -10
  67. package/src/lib/doctor/check-floors.ts +124 -0
  68. package/src/lib/doctor/check-probe.ts +138 -0
  69. package/src/lib/doctor/checks-github.ts +3 -1
  70. package/src/lib/doctor/checks-local.ts +28 -2
  71. package/src/lib/doctor/cloudflare-api.ts +4 -2
  72. package/src/lib/doctor/index.ts +67 -6
  73. package/src/lib/doctor/types.ts +2 -0
  74. package/src/lib/doctor/wrangler-config.ts +11 -0
  75. package/src/lib/env.ts +9 -4
  76. package/src/lib/index.ts +2 -0
  77. package/src/lib/sveltekit/content-routes.ts +29 -17
  78. package/src/lib/sveltekit/guard.ts +4 -2
  79. package/src/lib/sveltekit/nav-routes.ts +3 -10
  80. package/src/lib/vite/index.ts +71 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.52.0
6
+
7
+ The editor became a quiet writing surface. The manuscript renders in self-hosted iA Writer Mono
8
+ (SIL OFL) at a centered 70-character measure, heading sizes step by level, every syntax marker
9
+ and URL recedes to the muted ink while the content keeps full strength, inline code sits on a
10
+ soft chip, and quote text reads in full ink with only the `>` dimmed. The editor also parses
11
+ GFM now, so the toolbar's strikethrough, tables, and task lists highlight as you type.
12
+
13
+ Directive machinery trades its row bands for bracket rails: a container draws a depth-stepped
14
+ rail from opener to closer, nested containers draw nested rails, the fence line's name and label
15
+ keep the accent while the colons and braces fade, and the block holding your caret reads one
16
+ step stronger. The treatment is AA-checked in both themes.
17
+
18
+ Two writing modes join the toolbar's overflow menu, each persisted per browser: focus mode fades
19
+ every paragraph but the caret's (a deliberate, documented sub-AA dim with chip backgrounds
20
+ flattened), and typewriter scrolling holds the caret line at vertical center.
21
+
22
+ Consumers may: pass the new optional `focusMode` and `typewriter` booleans when embedding
23
+ `MarkdownEditor` directly; sites on the stock `EditPage` get the toggles and persistence for
24
+ free. No action required; the release is additive.
25
+
26
+ ## 0.51.0
27
+
28
+ The `svelte` peer dependency floor rises from `^5.0.0` to `^5.56.3`, turning the 0.40.0 advisory
29
+ into an enforced range: consumer sites compile the shipped `.svelte` sources, and svelte `5.56.1`
30
+ miscompiles parenthesized boolean groupings. `cairn-doctor` gains a `config.dependency-floors`
31
+ check in its default set, which compares the lockfile's resolved `svelte` and `@sveltejs/kit`
32
+ versions against the peer ranges the installed engine declares.
33
+
34
+ Consumers must: raise the `svelte` devDependency range to at least `^5.56.3` (and `@sveltejs/kit`
35
+ to `^2.12` where it sits lower) and reinstall so the lockfile re-resolves. A site pinning svelte
36
+ below the floor now draws an npm peer warning or resolution failure on install, and the doctor
37
+ reports the below-floor version as a blocker.
38
+
39
+ The edit page's preview now renders inside a sandboxed iframe whose document links the site's own
40
+ stylesheets, so an entry proofs in the site's real styling without that CSS ever touching the
41
+ admin document. The adapter gains an optional `preview` member naming the compiled CSS URLs (a
42
+ Vite `?url` import resolves the hashed asset) plus `bodyClass` and `containerClass` for the site's
43
+ body classes and content wrapper, and a `byConcept` map overrides either class per concept for a
44
+ site whose posts and pages wrap content differently. While Preview shows, the sidebar steps aside
45
+ so the document takes the full width, and a width menu on the Preview tab sizes the frame to
46
+ Desktop, Tablet, Phone, or Small phone, persisted per browser.
47
+
48
+ Consumers should: wire `preview` in the adapter, referencing the sheet only through `?url` and
49
+ linking the same URL from the site layout, the way
50
+ [the adapter guide](docs/guides/define-an-adapter-and-schema.md) shows. Without the knob the
51
+ preview renders unstyled markup behind a one-line hint.
52
+
53
+ The editor's directive highlighting now recognizes labeled and attributed `:::` openers and fences
54
+ of four or more colons, where before only bare closers matched, and nested containers step their
55
+ band and rail tint by depth. No consumer action.
56
+
57
+ `cairn-doctor` derives its missing inputs from the repo it runs in: the backend owner and repo
58
+ plus the sender address come from evaluating the site's config module through the manifest bin's
59
+ Vite machinery, and the Cloudflare account id comes from the wrangler config, with flags and
60
+ environment variables taking precedence. A new `--probe <url>` flag runs a zero-side-effect live
61
+ check against the deployed admin's sign-in surface: the login envelope, the CSRF cookie and field,
62
+ and the uniform non-leak answer to a stranger's sign-in request. Consumers may: drop the `--from`
63
+ and `--repo` flags from doctor invocations and run `npx cairn-doctor --probe <url>` after a
64
+ deploy.
65
+
5
66
  ## 0.50.0
6
67
 
7
68
  The admin now mounts as one catch-all route. A new `createCairnAdmin(runtime, deps)` facade
@@ -6,13 +6,16 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
6
6
  remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
7
7
  toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
8
8
  swap the editing surface for the rendered preview inside the same card; every visit lands on
9
- Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
9
+ Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
10
+ adapter's `preview` knob), takes the full content width (the sidebar hides until Write), and
11
+ sizes to a persisted device width picked from the toolbar's capsule. A sticky glass header
12
+ carries the breadcrumb, the status badges, the save-state indicator,
10
13
  and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
11
14
  pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
12
15
  transient flashes, and the editor card's footer holds the word count and the Markdown help.
13
16
  -->
14
17
  <script lang="ts">
15
- import { untrack } from 'svelte';
18
+ import { flushSync, untrack } from 'svelte';
16
19
  import { beforeNavigate } from '$app/navigation';
17
20
  import { page } from '$app/state';
18
21
  import CsrfField from './CsrfField.svelte';
@@ -26,6 +29,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
26
29
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
27
30
  import { cairnLinkCompletionSource } from './link-completion.js';
28
31
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
32
+ import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
29
33
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
30
34
  import type { ComponentRegistry } from '../render/registry.js';
31
35
  import type { IconSet } from '../render/glyph.js';
@@ -97,6 +101,16 @@ transient flashes, and the editor card's footer holds the word count and the Mar
97
101
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
98
102
  let editForm = $state<HTMLFormElement | null>(null);
99
103
 
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() {
110
+ if (mode === 'write') return;
111
+ flushSync(() => (mode = 'write'));
112
+ }
113
+
100
114
  // The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
101
115
  // onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
102
116
  // navigation passes through because busy flips before it starts, and a non-edit POST's because
@@ -150,6 +164,43 @@ transient flashes, and the editor card's footer holds the word count and the Mar
150
164
  let previewHtml = $state('');
151
165
  // True after a render call threw, so the preview pane can say so instead of going blank.
152
166
  let previewFailed = $state(false);
167
+ // The preview frame's device width, a per-browser preference under its own key (the legacy
168
+ // 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
169
+ // the default; the storage read sits in an effect so it never runs during SSR, and it tracks
170
+ // nothing reactive, so it runs once.
171
+ const deviceStorageKey = 'cairn-editor-preview-device';
172
+ let device = $state<PreviewDeviceId>('desktop');
173
+ $effect(() => {
174
+ const stored = localStorage.getItem(deviceStorageKey);
175
+ if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
176
+ });
177
+ function setDevice(id: PreviewDeviceId) {
178
+ device = id;
179
+ localStorage.setItem(deviceStorageKey, id);
180
+ }
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.
184
+ const focusStorageKey = 'cairn-editor-focus-mode';
185
+ const typewriterStorageKey = 'cairn-editor-typewriter';
186
+ let focusMode = $state(false);
187
+ let typewriter = $state(false);
188
+ $effect(() => {
189
+ focusMode = localStorage.getItem(focusStorageKey) === 'true';
190
+ typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
191
+ });
192
+ function setFocusMode(on: boolean) {
193
+ focusMode = on;
194
+ localStorage.setItem(focusStorageKey, String(on));
195
+ }
196
+ function setTypewriter(on: boolean) {
197
+ typewriter = on;
198
+ localStorage.setItem(typewriterStorageKey, String(on));
199
+ }
200
+ const activeDevice = $derived(previewDevice(device));
201
+ // The iframe document around the rendered html: the site's stylesheets from the adapter's
202
+ // preview knob, or a styleless document (behind the hint below) when the site sets none.
203
+ const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
153
204
  let insert = $state.raw<(text: string) => void>(() => {});
154
205
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
155
206
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
@@ -388,7 +439,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
388
439
  }
389
440
  }
390
441
  }, 150);
391
- return () => clearTimeout(handle);
442
+ return () => {
443
+ clearTimeout(handle);
444
+ // Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
445
+ // above cannot reach this counter, so without the bump a slow render for entry A could
446
+ // resolve after a same-route hop and write A's html into entry B's pane.
447
+ previewRun++;
448
+ };
392
449
  });
393
450
 
394
451
  // Coerce a frontmatter value to a string for text/date/textarea inputs.
@@ -575,7 +632,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
575
632
  bind:this={editForm}
576
633
  onsubmit={onEditSubmit}
577
634
  oninput={onFormInput}
578
- class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
635
+ oninvalidcapture={onFormInvalid}
636
+ class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
579
637
  >
580
638
  <CsrfField />
581
639
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
@@ -602,7 +660,17 @@ transient flashes, and the editor card's footer holds the word count and the Mar
602
660
  role="group"
603
661
  aria-label="Editor"
604
662
  >
605
- <EditorToolbar {format} {mode} onMode={setMode}>
663
+ <EditorToolbar
664
+ {format}
665
+ {mode}
666
+ onMode={setMode}
667
+ {device}
668
+ onDevice={setDevice}
669
+ {focusMode}
670
+ onFocusMode={setFocusMode}
671
+ {typewriter}
672
+ onTypewriter={setTypewriter}
673
+ >
606
674
  {#snippet insertControls()}
607
675
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
608
676
  dialogs themselves mount outside the edit form at the bottom of this component. -->
@@ -665,19 +733,58 @@ transient flashes, and the editor card's footer holds the word count and the Mar
665
733
  registerGetSelection={(fn) => (getSelection = fn)}
666
734
  registerFormat={(fn) => (format = fn)}
667
735
  {completionSources}
736
+ {focusMode}
737
+ {typewriter}
668
738
  />
669
739
  </div>
670
740
  {#if mode === 'preview'}
671
- <!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
672
- tabpanel pattern's completeness requirement). -->
673
- <div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
674
- {#if previewHtml}
675
- {@html previewHtml}
676
- {:else if previewFailed}
677
- <p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
678
- {:else}
679
- <p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
680
- {/if}
741
+ <!-- The preview ground: recessed under the floating frame card so the page reads as a
742
+ sheet on the desk. tabindex 0 only while a message shows in place of the iframe;
743
+ with the iframe up the frame itself is the pane's focusable content (the tabpanel
744
+ pattern's completeness requirement). -->
745
+ <div
746
+ id="cairn-pane-preview"
747
+ role="tabpanel"
748
+ aria-labelledby="cairn-tab-preview"
749
+ tabindex={previewHtml && !previewFailed ? undefined : 0}
750
+ class="bg-base-200 px-4 py-6 lg:px-8"
751
+ >
752
+ <!-- The frame column: centered, sized by the picked device (capped at the pane), with
753
+ the width eased; the admin sheet's prefers-reduced-motion rule squashes the move. -->
754
+ <div
755
+ class="cairn-preview-frame mx-auto max-w-full transition-[width] duration-300"
756
+ style:width={activeDevice.width === null ? '100%' : `${activeDevice.width}px`}
757
+ >
758
+ {#if activeDevice.width !== null}
759
+ <p class="mb-2 text-right text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">
760
+ {deviceLabel(activeDevice)}
761
+ </p>
762
+ {/if}
763
+ {#if !data.preview}
764
+ <p class="mb-2 text-xs text-[var(--color-muted)]">
765
+ Preview shows unstyled markup until the adapter's preview option names the site's stylesheets.
766
+ </p>
767
+ {/if}
768
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
769
+ {#if previewFailed}
770
+ <p class="p-4 text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
771
+ {:else if !previewHtml}
772
+ <p class="p-4 text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
773
+ {:else}
774
+ <!-- The site's render pipeline already sanitized the html (the floor strips
775
+ scripts and handlers); the empty sandbox is belt and braces on top. The
776
+ frame document's base tag targets every link at a new tab, which the
777
+ sandbox (no allow-popups) blocks, so a proofing click never navigates the
778
+ admin or the frame itself. tabindex 0 keeps the scrollable preview
779
+ keyboard-reachable (an iframe is not a sequential tab stop by itself); on
780
+ a link-heavy page that one inert Tab stop is a deliberate tradeoff. The
781
+ a11y rule reads any tabindex on a non-interactive element as a smell, but
782
+ a scrollable region is the recognized exception. -->
783
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
784
+ <iframe sandbox="" tabindex="0" title="Page preview" srcdoc={previewDoc} class="block h-[70vh] w-full"></iframe>
785
+ {/if}
786
+ </div>
787
+ </div>
681
788
  </div>
682
789
  {/if}
683
790
  <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
@@ -696,7 +803,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
696
803
  </div>
697
804
  </div>
698
805
 
699
- <aside class="lg:order-2 mt-4 lg:mt-0">
806
+ <!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
807
+ field edits survive the round trip) and the editor column above spans the whole width. -->
808
+ <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
700
809
  <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
701
810
  a real legend that screen readers announce with the fields it holds. -->
702
811
  <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
@@ -27,7 +27,10 @@ interface Props {
27
27
  * remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
28
28
  * toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
29
29
  * swap the editing surface for the rendered preview inside the same card; every visit lands on
30
- * Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
30
+ * Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
31
+ * adapter's `preview` knob), takes the full content width (the sidebar hides until Write), and
32
+ * sizes to a persisted device width picked from the toolbar's capsule. A sticky glass header
33
+ * carries the breadcrumb, the status badges, the save-state indicator,
31
34
  * and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
32
35
  * pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
33
36
  * transient flashes, and the editor card's footer holds the word count and the Markdown help.
@@ -3,12 +3,16 @@
3
3
  The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
4
4
  More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
5
5
  right. Format buttons ask the host to transform the editor's current selection; the host supplies the
6
- Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
7
- are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
6
+ Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
7
+ Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
8
+ widths, reported to the host through `onDevice`. The More menu also carries the host's persisted
9
+ writing-mode toggles (focus mode, typewriter scrolling) as pressed-state buttons above the format picks. The glyphs are stroke SVG icons in the admin's
10
+ house style (24x24 viewBox, `currentColor`, round caps).
8
11
  -->
9
12
  <script lang="ts">
10
13
  import type { Snippet } from 'svelte';
11
14
  import type { FormatKind } from './markdown-format.js';
15
+ import { deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
12
16
 
13
17
  interface Props {
14
18
  /** Apply a markdown transform to the editor's current selection. */
@@ -17,11 +21,35 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
17
21
  mode: 'write' | 'preview';
18
22
  /** Ask the host to switch panes. */
19
23
  onMode: (m: 'write' | 'preview') => void;
24
+ /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
25
+ device?: PreviewDeviceId;
26
+ /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
27
+ * while Preview shows. */
28
+ onDevice?: (id: PreviewDeviceId) => void;
29
+ /** Whether focus mode is on; the More menu's toggle reflects it. */
30
+ focusMode?: boolean;
31
+ /** Flip focus mode. When set, the toggle joins the More menu. */
32
+ onFocusMode?: (on: boolean) => void;
33
+ /** Whether typewriter scrolling is on; the More menu's toggle reflects it. */
34
+ typewriter?: boolean;
35
+ /** Flip typewriter scrolling. When set, the toggle joins the More menu. */
36
+ onTypewriter?: (on: boolean) => void;
20
37
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
21
38
  insertControls?: Snippet;
22
39
  }
23
40
 
24
- let { format, mode, onMode, insertControls }: Props = $props();
41
+ let {
42
+ format,
43
+ mode,
44
+ onMode,
45
+ device = 'desktop',
46
+ onDevice,
47
+ focusMode = false,
48
+ onFocusMode,
49
+ typewriter = false,
50
+ onTypewriter,
51
+ insertControls,
52
+ }: Props = $props();
25
53
 
26
54
  // Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
27
55
  // markup stays declarative (no per-icon raw html). Paths follow the house outline style.
@@ -68,6 +96,8 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
68
96
  ];
69
97
 
70
98
  const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
99
+ // The check glyph marking an active pick, shared by the More menu's toggles and the device list.
100
+ const checkPaths = ['M20 6 9 17l-5-5'];
71
101
 
72
102
  const moreItems: { kind: FormatKind; label: string }[] = [
73
103
  { kind: 'strike', label: 'Strikethrough' },
@@ -83,10 +113,27 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
83
113
  let moreMenu = $state<HTMLUListElement | null>(null);
84
114
  let moreOpen = $state(false);
85
115
 
116
+ // Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
117
+ function hideMenu(menu: HTMLUListElement | null) {
118
+ if (menu?.matches(':popover-open')) menu.hidePopover();
119
+ }
120
+
86
121
  function pickMore(kind: FormatKind) {
87
122
  format(kind);
88
- // Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
89
- if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
123
+ hideMenu(moreMenu);
124
+ }
125
+
126
+ // The device menu's popover element and its open state, mirrored from the toggle event into
127
+ // aria-expanded on the trigger (the More menu's pattern).
128
+ let deviceMenu = $state<HTMLUListElement | null>(null);
129
+ let deviceOpen = $state(false);
130
+ const activeDevice = $derived(previewDevice(device));
131
+ // Whether the device trigger renders as the capsule's third segment.
132
+ const showDeviceTrigger = $derived(mode === 'preview' && !!onDevice);
133
+
134
+ function pickDevice(id: PreviewDeviceId) {
135
+ onDevice?.(id);
136
+ hideMenu(deviceMenu);
90
137
  }
91
138
 
92
139
  let toolbarEl = $state<HTMLDivElement | null>(null);
@@ -156,13 +203,20 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
156
203
  {/snippet}
157
204
 
158
205
  {#snippet tab(m: 'write' | 'preview', label: string)}
206
+ <!-- The capsule look is manual rounding, not daisyUI's .join: join radii follow direct
207
+ children, and the device trigger must sit outside the tablist (ARIA required children),
208
+ so the segments square their shared edges themselves. Preview squares its right edge only
209
+ while the trigger extends the capsule. -->
159
210
  <button
160
211
  type="button"
161
212
  role="tab"
162
213
  id={`cairn-tab-${m}`}
163
214
  aria-selected={mode === m}
164
215
  aria-controls={`cairn-pane-${m}`}
165
- class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
216
+ class="btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
217
+ class:rounded-r-none={m === 'write' || showDeviceTrigger}
218
+ class:rounded-l-none={m === 'preview'}
219
+ class:-ml-px={m === 'preview'}
166
220
  onclick={() => onMode(m)}
167
221
  >
168
222
  {label}
@@ -211,6 +265,34 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
211
265
  ontoggle={(e) => (moreOpen = e.newState === 'open')}
212
266
  class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
213
267
  >
268
+ <!-- The writing modes sit above the format items behind a hairline, persisted by the host.
269
+ The device list's idiom: plain buttons with aria-pressed carrying the on/off state (this
270
+ popover list is not an ARIA menu, so a menuitemcheckbox would sit in an invalid context);
271
+ the check glyph mirrors the state visually. A flip leaves the menu open so the new
272
+ pressed state is perceivable in place; only a format pick dismisses it. -->
273
+ {#if onFocusMode}
274
+ <li>
275
+ <button type="button" aria-pressed={focusMode} onclick={() => onFocusMode(!focusMode)}>
276
+ <span class="grow">Focus mode</span>
277
+ {#if focusMode}
278
+ {@render strokeIcon(checkPaths)}
279
+ {/if}
280
+ </button>
281
+ </li>
282
+ {/if}
283
+ {#if onTypewriter}
284
+ <li>
285
+ <button type="button" aria-pressed={typewriter} onclick={() => onTypewriter(!typewriter)}>
286
+ <span class="grow">Typewriter scrolling</span>
287
+ {#if typewriter}
288
+ {@render strokeIcon(checkPaths)}
289
+ {/if}
290
+ </button>
291
+ </li>
292
+ {/if}
293
+ {#if onFocusMode || onTypewriter}
294
+ <li class="my-1 border-t border-[var(--cairn-card-border)]" role="separator"></li>
295
+ {/if}
214
296
  {#each moreItems as item (item.kind)}
215
297
  <li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
216
298
  {/each}
@@ -230,9 +312,52 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
230
312
  {/if}
231
313
 
232
314
  <!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
233
- the strip inside the same editor card. -->
234
- <div class="join ml-auto" role="tablist" aria-label="Editor view">
235
- {@render tab('write', 'Write')}
236
- {@render tab('preview', 'Preview')}
315
+ the strip inside the same editor card. The tablist wrapper holds ONLY the two tabs (ARIA
316
+ required children: anything else in a tablist makes assistive tech miscount the tabs).
317
+ While Preview shows, the device trigger reads as the capsule's third segment from the
318
+ flex row right after the wrapper; it is a plain button, not a tab. -->
319
+ <div class="ml-auto flex items-center">
320
+ <div role="tablist" aria-label="Editor view" class="flex items-center">
321
+ {@render tab('write', 'Write')}
322
+ {@render tab('preview', 'Preview')}
323
+ </div>
324
+ {#if showDeviceTrigger}
325
+ <button
326
+ type="button"
327
+ class="btn btn-sm btn-ghost gap-1 rounded-l-none -ml-px"
328
+ title="Preview width"
329
+ aria-expanded={deviceOpen}
330
+ popovertarget="cairn-preview-device-menu"
331
+ style="anchor-name:--cairn-preview-device"
332
+ >
333
+ <span class="sr-only">Preview width:</span>
334
+ {activeDevice.label}
335
+ {@render strokeIcon(['m6 9 6 6 6-6'])}
336
+ </button>
337
+ {/if}
237
338
  </div>
339
+ {#if showDeviceTrigger}
340
+ <!-- The device list mirrors the More menu exactly: a DaisyUI v5 popover dropdown of plain
341
+ buttons, with the active pick carried by aria-pressed and the check glyph. Deliberately
342
+ NOT the ARIA menu pattern: menu roles promise interactions this list does not have. -->
343
+ <ul
344
+ bind:this={deviceMenu}
345
+ popover="auto"
346
+ id="cairn-preview-device-menu"
347
+ style="position-anchor:--cairn-preview-device"
348
+ ontoggle={(e) => (deviceOpen = e.newState === 'open')}
349
+ class="dropdown dropdown-end menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
350
+ >
351
+ {#each previewDevices as d (d.id)}
352
+ <li>
353
+ <button type="button" aria-pressed={device === d.id} onclick={() => pickDevice(d.id)}>
354
+ <span class="grow">{deviceLabel(d)}</span>
355
+ {#if device === d.id}
356
+ {@render strokeIcon(checkPaths)}
357
+ {/if}
358
+ </button>
359
+ </li>
360
+ {/each}
361
+ </ul>
362
+ {/if}
238
363
  </div>
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { FormatKind } from './markdown-format.js';
3
+ import { type PreviewDeviceId } from './preview-doc.js';
3
4
  interface Props {
4
5
  /** Apply a markdown transform to the editor's current selection. */
5
6
  format: (kind: FormatKind) => void;
@@ -7,6 +8,19 @@ interface Props {
7
8
  mode: 'write' | 'preview';
8
9
  /** Ask the host to switch panes. */
9
10
  onMode: (m: 'write' | 'preview') => void;
11
+ /** The active preview-frame device, shown on the device trigger. Desktop when absent. */
12
+ device?: PreviewDeviceId;
13
+ /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
14
+ * while Preview shows. */
15
+ onDevice?: (id: PreviewDeviceId) => void;
16
+ /** Whether focus mode is on; the More menu's toggle reflects it. */
17
+ focusMode?: boolean;
18
+ /** Flip focus mode. When set, the toggle joins the More menu. */
19
+ onFocusMode?: (on: boolean) => void;
20
+ /** Whether typewriter scrolling is on; the More menu's toggle reflects it. */
21
+ typewriter?: boolean;
22
+ /** Flip typewriter scrolling. When set, the toggle joins the More menu. */
23
+ onTypewriter?: (on: boolean) => void;
10
24
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
11
25
  insertControls?: Snippet;
12
26
  }
@@ -14,8 +28,11 @@ interface Props {
14
28
  * The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
15
29
  * More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
16
30
  * right. Format buttons ask the host to transform the editor's current selection; the host supplies the
17
- * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
18
- * are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
31
+ * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
32
+ * Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
33
+ * widths, reported to the host through `onDevice`. The More menu also carries the host's persisted
34
+ * writing-mode toggles (focus mode, typewriter scrolling) as pressed-state buttons above the format picks. The glyphs are stroke SVG icons in the admin's
35
+ * house style (24x24 viewBox, `currentColor`, round caps).
19
36
  */
20
37
  declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
21
38
  type EditorToolbar = ReturnType<typeof EditorToolbar>;