@glw907/cairn-cms 0.52.1 → 0.53.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 CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.53.0
6
+
7
+ An iterative design session on the editor-as-home direction, shipped as one window.
8
+
9
+ The admin's UI face is now IBM Plex Sans (self-hosted, SIL OFL), replacing Figtree: the editor
10
+ writes in iA Writer Mono, which descends from IBM Plex Mono, so the chrome and the manuscript
11
+ share one type skeleton. The brand display face (Bricolage Grotesque) is unchanged.
12
+
13
+ The editor gains two surface postures, persisted and toggled from the card footer: Prose (the
14
+ default) is the writing instrument, a 72ch centered measure at a larger type step; Markup is the
15
+ working surface, a wide dense fill for tables, attributed directives, and long URLs. The footer
16
+ is now the writing-environment strip (word count, postures, focus mode, typewriter, help), the
17
+ insert actions joined the toolbar as icons, and the document title sits on the manuscript's left
18
+ edge. Focus mode now also eases the directive rails and the title back with the dimmed field.
19
+
20
+ The chrome cedes the stage: a narrower nav sidebar and details column, a wider gutter around the
21
+ editor, a quieter details card, rebalanced surface margins, and the topbar pinned to the brand
22
+ band's height so the header hairline meets across the seam.
23
+
24
+ Consumers may: pass the new optional `surface` prop ('prose' | 'markup') when embedding
25
+ `MarkdownEditor` directly. No action required; the release is additive.
26
+
5
27
  ## 0.52.1
6
28
 
7
29
  Two field reports from the first 0.52.0 session, both in-editor polish with no consumer action.
@@ -244,8 +244,10 @@ identical on every host regardless of the site's own theme.
244
244
 
245
245
  <div class="drawer-content flex flex-col">
246
246
  <!-- The topbar is a flat, opaque continuation of the sidebar's brand band: same surface and the
247
- same hairline, no shadow, so the two form one clean header strip across the sidebar seam. -->
248
- <div class="navbar bg-base-100 border-b border-[var(--cairn-card-border)] sticky top-0 z-30 gap-2 px-4 lg:px-8">
247
+ same hairline, no shadow, so the two form one clean header strip across the sidebar seam.
248
+ The height is pinned to the brand band's h-16 (a content-driven navbar drifts with font
249
+ metrics, and the two border-bottoms stop meeting at the seam). -->
250
+ <div class="navbar bg-base-100 border-b border-[var(--cairn-card-border)] sticky top-0 z-30 h-16 min-h-16 gap-2 px-4 py-0 lg:px-8">
249
251
  <div class="flex-none lg:hidden">
250
252
  <label for="cairn-drawer" aria-label="Open menu" class="btn btn-square btn-ghost">
251
253
  <MenuIcon class="h-5 w-5" />
@@ -293,7 +295,7 @@ identical on every host regardless of the site's own theme.
293
295
  </div>
294
296
  </div>
295
297
 
296
- <main class="flex-1 p-4 lg:p-8">
298
+ <main class="flex-1 p-4 lg:px-10 lg:py-8">
297
299
  {@render children()}
298
300
  </main>
299
301
 
@@ -380,7 +382,7 @@ identical on every host regardless of the site's own theme.
380
382
 
381
383
  <div class="drawer-side">
382
384
  <label for="cairn-drawer" aria-label="Close menu" class="drawer-overlay"></label>
383
- <nav class="bg-base-100 flex min-h-full w-64 flex-col border-r border-[var(--cairn-card-border)]" aria-label="Site content">
385
+ <nav class="bg-base-100 flex min-h-full w-56 flex-col border-r border-[var(--cairn-card-border)]" aria-label="Site content">
384
386
  <!-- Brand band, the same height as the topbar. The mark sits in a filled "app-icon" tile, which
385
387
  anchors the corner as a deliberate brand object rather than a washed box. The logo and
386
388
  wordmark link to the admin home. -->
@@ -12,12 +12,16 @@ sizes to a persisted device width picked from the toolbar's capsule. A sticky gl
12
12
  carries the breadcrumb, the status badges, the save-state indicator,
13
13
  and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
14
14
  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.
15
+ transient flashes, and the editor card's footer is the writing-environment strip: the word
16
+ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
16
17
  -->
17
18
  <script lang="ts">
18
19
  import { flushSync, untrack } from 'svelte';
19
20
  import { beforeNavigate } from '$app/navigation';
20
21
  import { page } from '$app/state';
22
+ import BlocksIcon from '@lucide/svelte/icons/blocks';
23
+ import LinkIcon from '@lucide/svelte/icons/link';
24
+ import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
21
25
  import CsrfField from './CsrfField.svelte';
22
26
  import MarkdownEditor from './MarkdownEditor.svelte';
23
27
  import EditorToolbar from './EditorToolbar.svelte';
@@ -178,16 +182,21 @@ transient flashes, and the editor card's footer holds the word count and the Mar
178
182
  device = id;
179
183
  localStorage.setItem(deviceStorageKey, id);
180
184
  }
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.
185
+ // The writing modes (focus, typewriter) and the surface posture, per-browser preferences on
186
+ // the device pick's pattern: read in an effect so SSR never touches localStorage, written by
187
+ // the card footer's toggles. The effect tracks nothing reactive, so it runs once.
184
188
  const focusStorageKey = 'cairn-editor-focus-mode';
185
189
  const typewriterStorageKey = 'cairn-editor-typewriter';
190
+ const surfaceStorageKey = 'cairn-editor-surface';
186
191
  let focusMode = $state(false);
187
192
  let typewriter = $state(false);
193
+ // The surface posture: prose (the writing instrument) by default; markup is the dense
194
+ // working surface.
195
+ let surface = $state<'prose' | 'markup'>('prose');
188
196
  $effect(() => {
189
197
  focusMode = localStorage.getItem(focusStorageKey) === 'true';
190
198
  typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
199
+ if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
191
200
  });
192
201
  function setFocusMode(on: boolean) {
193
202
  focusMode = on;
@@ -197,6 +206,15 @@ transient flashes, and the editor card's footer holds the word count and the Mar
197
206
  typewriter = on;
198
207
  localStorage.setItem(typewriterStorageKey, String(on));
199
208
  }
209
+ function setSurface(posture: 'prose' | 'markup') {
210
+ surface = posture;
211
+ localStorage.setItem(surfaceStorageKey, posture);
212
+ }
213
+ // One source for the footer toggles' pressed/idle styling. The class names must stay verbatim
214
+ // string literals: the admin CSS build's @source scan reads this file as raw text.
215
+ function footerToggleClass(pressed: boolean): string {
216
+ return `btn btn-ghost btn-xs font-normal ${pressed ? 'bg-primary/10 text-primary' : 'text-[var(--color-muted)]'}`;
217
+ }
200
218
  const activeDevice = $derived(previewDevice(device));
201
219
  // The iframe document around the rendered html: the site's stylesheets from the adapter's
202
220
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
@@ -633,29 +651,37 @@ transient flashes, and the editor card's footer holds the word count and the Mar
633
651
  onsubmit={onEditSubmit}
634
652
  oninput={onFormInput}
635
653
  oninvalidcapture={onFormInvalid}
636
- class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
654
+ class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_17rem] lg:gap-10'}
637
655
  >
638
656
  <CsrfField />
639
657
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
640
658
 
641
659
  <!-- In Write mode the card hugs the manuscript: the column caps near the 70ch measure and
642
660
  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]'}>
661
+ column for its device frames. The cap follows the surface posture: prose hugs its 72ch
662
+ measure (49rem covers it at the prose type step), markup puts the ceiling near 89ch of
663
+ the base face for tables, attributed directives, and long URLs. The toggle lives in the
664
+ card footer with the other writing preferences. -->
665
+ <div class={mode === 'preview' ? 'lg:order-1' : `lg:order-1 mx-auto w-full ${surface === 'prose' ? 'max-w-[49rem]' : 'max-w-[56rem]'}`}>
647
666
  {#if titleField}
648
667
  <!-- The hoisted document title: large, borderless, in the display face, so the manuscript
649
668
  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
- />
669
+ admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there).
670
+ In markup posture the surface fills the card, so shared inline padding is the whole
671
+ alignment; in prose posture the manuscript centers on its measure, so the wrapper
672
+ mirrors that geometry (the editor face at the prose size, the measure, auto margins).
673
+ Under focus mode the title eases back with the rest of the context unless it holds
674
+ focus itself. -->
675
+ <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'}>
676
+ <input
677
+ 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' : ''}"
678
+ name="title"
679
+ value={str(data.frontmatter.title)}
680
+ placeholder={titleField.label}
681
+ aria-label={titleField.label}
682
+ required={titleField.required}
683
+ />
684
+ </div>
659
685
  {/if}
660
686
  <!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
661
687
  reads as a single object. The card carries the formatting shortcuts for everything in it. -->
@@ -665,52 +691,46 @@ transient flashes, and the editor card's footer holds the word count and the Mar
665
691
  role="group"
666
692
  aria-label="Editor"
667
693
  >
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
- >
694
+ <EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
679
695
  {#snippet insertControls()}
680
696
  <!-- 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. -->
697
+ dialogs themselves mount outside the edit form at the bottom of this component.
698
+ Icon buttons like the format strip beside them: the labels live in aria-label and
699
+ the title tooltip, so the Insert group reads as part of one instrument strip. -->
682
700
  {#if hasComponents}
683
701
  <button
684
702
  type="button"
685
- class="btn btn-sm btn-ghost"
703
+ class="btn btn-sm btn-ghost btn-square"
686
704
  aria-haspopup="dialog"
687
705
  aria-label="Insert block"
706
+ title="Insert block"
688
707
  disabled={insertDisabled}
689
708
  onclick={() => insertDialog?.open()}
690
709
  >
691
- Insert block
710
+ <BlocksIcon class="h-4 w-4" aria-hidden="true" />
692
711
  </button>
693
712
  {/if}
694
713
  <button
695
714
  type="button"
696
- class="btn btn-sm btn-ghost"
715
+ class="btn btn-sm btn-ghost btn-square"
697
716
  aria-haspopup="dialog"
698
717
  aria-label="Web link (Ctrl+K)"
699
718
  title="Web link (Ctrl+K)"
700
719
  disabled={insertDisabled}
701
720
  onclick={() => webLinkDialog?.open()}
702
721
  >
703
- Web link
722
+ <LinkIcon class="h-4 w-4" aria-hidden="true" />
704
723
  </button>
705
724
  <button
706
725
  type="button"
707
- class="btn btn-sm btn-ghost"
726
+ class="btn btn-sm btn-ghost btn-square"
708
727
  aria-haspopup="dialog"
709
728
  aria-label="Link to page"
729
+ title="Link to page"
710
730
  disabled={insertDisabled}
711
731
  onclick={() => linkPicker?.open()}
712
732
  >
713
- Link to page
733
+ <FileSymlinkIcon class="h-4 w-4" aria-hidden="true" />
714
734
  </button>
715
735
  <button
716
736
  type="button"
@@ -733,6 +753,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
733
753
  <MarkdownEditor
734
754
  bind:value={body}
735
755
  name="body"
756
+ {surface}
736
757
  registerInsert={(fn) => (insert = fn)}
737
758
  registerInsertLink={(fn) => (insertLink = fn)}
738
759
  registerGetSelection={(fn) => (getSelection = fn)}
@@ -793,17 +814,64 @@ transient flashes, and the editor card's footer holds the word count and the Mar
793
814
  </div>
794
815
  {/if}
795
816
  <!-- 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. -->
817
+ frame never jumps between tabs and the count keeps reading while proofing. The strip
818
+ carries the writing environment (the count, the persisted writing modes, help) while
819
+ the top toolbar acts on the text; the toggles live here visible rather than buried in
820
+ an overflow menu. -->
797
821
  <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
822
  <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>
823
+ <div class="flex items-center gap-1">
824
+ <!-- The pressed check is the non-color state cue (WCAG 1.4.1): pressed and idle
825
+ toggles share weight and size, so hue alone must not carry the state. -->
826
+ <div role="group" aria-label="Editing surface" class="flex items-center gap-1">
827
+ <button
828
+ type="button"
829
+ class={footerToggleClass(surface === 'prose')}
830
+ aria-pressed={surface === 'prose'}
831
+ onclick={() => setSurface('prose')}
832
+ >
833
+ {#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}
834
+ Prose
835
+ </button>
836
+ <button
837
+ type="button"
838
+ class={footerToggleClass(surface === 'markup')}
839
+ aria-pressed={surface === 'markup'}
840
+ onclick={() => setSurface('markup')}
841
+ >
842
+ {#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}
843
+ Markup
844
+ </button>
845
+ </div>
846
+ <div class="mx-1 h-4 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
847
+ <button
848
+ type="button"
849
+ class={footerToggleClass(focusMode)}
850
+ aria-pressed={focusMode}
851
+ onclick={() => setFocusMode(!focusMode)}
852
+ >
853
+ {#if focusMode}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
854
+ Focus mode
855
+ </button>
856
+ <button
857
+ type="button"
858
+ class={footerToggleClass(typewriter)}
859
+ aria-pressed={typewriter}
860
+ onclick={() => setTypewriter(!typewriter)}
861
+ >
862
+ {#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
863
+ Typewriter
864
+ </button>
865
+ <div class="mx-1 h-4 w-px bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
866
+ <button
867
+ type="button"
868
+ class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
869
+ aria-haspopup="dialog"
870
+ onclick={() => helpDialog?.open()}
871
+ >
872
+ Markdown help
873
+ </button>
874
+ </div>
807
875
  </div>
808
876
  </div>
809
877
  </div>
@@ -813,7 +881,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
813
881
  <aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
814
882
  <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
815
883
  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)]">
884
+ <!-- Quieter than the editor card on purpose (hairline, no shadow): the editor is the one
885
+ floating object on the page, and the details read as margin furniture beside it. -->
886
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-6 p-4">
817
887
  {#if detailFields.length}
818
888
  <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
819
889
  <legend class={eyebrowClass}>Details</legend>
@@ -822,12 +892,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
822
892
  {@const f = field as TextareaField}
823
893
  <label class="flex flex-col gap-1">
824
894
  <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>
895
+ <textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
826
896
  </label>
827
897
  {:else if field.type === 'date'}
828
898
  <label class="flex flex-col gap-1">
829
899
  <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])} />
900
+ <input class="input input-sm" type="date" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} />
831
901
  </label>
832
902
  {:else if field.type === 'boolean'}
833
903
  <label class="label cursor-pointer justify-start gap-2">
@@ -860,7 +930,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
860
930
  <label class="flex flex-col gap-1">
861
931
  <span class="text-sm font-medium">{f.label}</span>
862
932
  <input
863
- class="input"
933
+ class="input input-sm"
864
934
  name={f.name}
865
935
  aria-label={f.label}
866
936
  placeholder={f.placeholder}
@@ -870,7 +940,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
870
940
  {:else}
871
941
  <label class="flex flex-col gap-1">
872
942
  <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} />
943
+ <input class="input input-sm" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} required={field.required} />
874
944
  </label>
875
945
  {/if}
876
946
  {/each}
@@ -33,7 +33,8 @@ interface Props {
33
33
  * carries the breadcrumb, the status badges, the save-state indicator,
34
34
  * and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
35
35
  * pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
36
- * transient flashes, and the editor card's footer holds the word count and the Markdown help.
36
+ * transient flashes, and the editor card's footer is the writing-environment strip: the word
37
+ * count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
37
38
  */
38
39
  declare const EditPage: import("svelte").Component<Props, {}, "">;
39
40
  type EditPage = ReturnType<typeof EditPage>;
@@ -5,9 +5,9 @@ More overflow menu, then the host's Insert controls) and the Write/Preview segme
5
5
  right. Format buttons ask the host to transform the editor's current selection; the host supplies the
6
6
  Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
7
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
+ widths, reported to the host through `onDevice`. The writing-mode toggles live in the host's card
9
+ footer (the bottom strip carries the writing environment; this strip acts on the text). The glyphs
10
+ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
11
11
  -->
12
12
  <script lang="ts">
13
13
  import type { Snippet } from 'svelte';
@@ -26,14 +26,6 @@ house style (24x24 viewBox, `currentColor`, round caps).
26
26
  /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
27
27
  * while Preview shows. */
28
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;
37
29
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
38
30
  insertControls?: Snippet;
39
31
  }
@@ -44,10 +36,6 @@ house style (24x24 viewBox, `currentColor`, round caps).
44
36
  onMode,
45
37
  device = 'desktop',
46
38
  onDevice,
47
- focusMode = false,
48
- onFocusMode,
49
- typewriter = false,
50
- onTypewriter,
51
39
  insertControls,
52
40
  }: Props = $props();
53
41
 
@@ -265,34 +253,6 @@ house style (24x24 viewBox, `currentColor`, round caps).
265
253
  ontoggle={(e) => (moreOpen = e.newState === 'open')}
266
254
  class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
267
255
  >
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}
296
256
  {#each moreItems as item (item.kind)}
297
257
  <li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
298
258
  {/each}
@@ -13,14 +13,6 @@ interface Props {
13
13
  /** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
14
14
  * while Preview shows. */
15
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;
24
16
  /** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
25
17
  insertControls?: Snippet;
26
18
  }
@@ -30,9 +22,9 @@ interface Props {
30
22
  * right. Format buttons ask the host to transform the editor's current selection; the host supplies the
31
23
  * Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
32
24
  * 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).
25
+ * widths, reported to the host through `onDevice`. The writing-mode toggles live in the host's card
26
+ * footer (the bottom strip carries the writing environment; this strip acts on the text). The glyphs
27
+ * are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
36
28
  */
37
29
  declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
38
30
  type EditorToolbar = ReturnType<typeof EditorToolbar>;
@@ -31,6 +31,9 @@ through the adapter's render. Swapping the editor stays a one-file change.
31
31
  focusMode?: boolean;
32
32
  /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
33
33
  typewriter?: boolean;
34
+ /** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
35
+ * leading); markup is the working surface (fills the card, denser). Prose by default. */
36
+ surface?: 'prose' | 'markup';
34
37
  }
35
38
 
36
39
  let {
@@ -43,6 +46,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
43
46
  completionSources = [],
44
47
  focusMode = false,
45
48
  typewriter = false,
49
+ surface = 'prose',
46
50
  }: Props = $props();
47
51
 
48
52
  let host = $state<HTMLDivElement | null>(null);
@@ -57,6 +61,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
57
61
  let modes: typeof import('./editor-modes.js') | null = null;
58
62
  let focusCompartment: import('@codemirror/state').Compartment | null = null;
59
63
  let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
64
+ let surfaceCompartment: import('@codemirror/state').Compartment | null = null;
65
+ // The posture themes, swapped through the surface compartment. Each owns its type step and
66
+ // leading (the base theme deliberately sets neither on the content node, so the postures never
67
+ // contest it on adoption order). Built in onMount beside the base theme.
68
+ let proseTheme: import('@codemirror/state').Extension | null = null;
69
+ let markupTheme: import('@codemirror/state').Extension | null = null;
60
70
 
61
71
  onMount(async () => {
62
72
  const viewMod = await import('@codemirror/view');
@@ -121,19 +131,23 @@ through the adapter's render. Swapping the editor stays a one-file change.
121
131
  const theme = EditorView.theme(
122
132
  {
123
133
  '&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
124
- // The 50vh floor keeps a short entry reading as a writing surface, and because the
125
- // contenteditable content area carries the height, a click in the empty space below the
126
- // text still lands in the editor and focuses it. The 70ch cap with auto margins holds
127
- // the manuscript to a readable measure, centered in whatever width the card gives it.
134
+ // The 60vh floor keeps the surface reading as the page's center stage even when the
135
+ // entry is short, and because the contenteditable content area carries the height, a
136
+ // click in the empty space below the text still lands in the editor and focuses it.
137
+ // No inner measure cap: the surface fills the card the way a code editor fills its
138
+ // pane, and the card's own width (the host caps it near 89ch of this face) is the one
139
+ // constraint. The surface carries tables, attributed directives, and long URLs, so the
140
+ // ceiling leans toward the code-editor end of the ergonomic band rather than the
141
+ // long-form ideal; paragraphs wrap comfortably below it.
128
142
  '.cm-content': {
129
143
  // The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
130
144
  // fallback keeps the surface monospace outside an admin theme wrapper.
131
145
  fontFamily: "var(--font-editor, ui-monospace, monospace)",
132
- padding: '0.875rem 1.25rem',
133
- lineHeight: '1.8',
134
- minHeight: '50vh',
135
- maxWidth: '70ch',
136
- margin: '0 auto',
146
+ // Vertical padding holds at least one line-height of the body (1.8 x 1rem), with a
147
+ // touch more below than above (the optical center sits high); the sides then read as
148
+ // gutters rather than letterboxing.
149
+ padding: '2rem 1.25rem 2.5rem',
150
+ minHeight: '60vh',
137
151
  },
138
152
  '.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
139
153
  // A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
@@ -176,13 +190,41 @@ through the adapter's render. Swapping the editor stays a one-file change.
176
190
  color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
177
191
  backgroundColor: 'transparent',
178
192
  },
193
+ // The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
194
+ // element, so overriding the percentages on dimmed lines re-resolves every bar in place.
195
+ // Without this the directive block keeps full-strength bars and becomes the one
196
+ // chromatic object in the dimmed field. The active step needs the override too: focus
197
+ // mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
198
+ // container, so a container holding a blank line has dimmed rows that still carry the
199
+ // active rail.
200
+ '.cm-cairn-focus-dim': {
201
+ '--cairn-directive-rail-1': 'var(--cairn-focus-dim-rail-1, 24%)',
202
+ '--cairn-directive-rail-2': 'var(--cairn-focus-dim-rail-2, 28%)',
203
+ '--cairn-directive-rail-3': 'var(--cairn-focus-dim-rail-3, 32%)',
204
+ '--cairn-directive-rail-active': 'var(--cairn-focus-dim-rail-active, 36%)',
205
+ },
206
+ },
207
+ { dark: isDark },
208
+ );
209
+
210
+ // The prose posture: the writing instrument. A 72ch measure centered in the card, one type
211
+ // step up, looser leading. Markup posture (the base theme) keeps the dense fill for tables,
212
+ // directives, and long URLs. Placed after the base theme in the extension list, so its keys
213
+ // win the spec-order ties.
214
+ proseTheme = EditorView.theme(
215
+ {
216
+ // Scoped to the content node (not the editor root) so the base theme's root font-size
217
+ // never contests it, and so the 72ch measure resolves against the prose type step.
218
+ '.cm-content': { fontSize: '1.0625rem', lineHeight: '1.9', maxWidth: '72ch', margin: '0 auto' },
179
219
  },
180
220
  { dark: isDark },
181
221
  );
222
+ markupTheme = EditorView.theme({ '.cm-content': { lineHeight: '1.8' } }, { dark: isDark });
182
223
 
183
224
  modes = modesMod;
184
225
  focusCompartment = new stateMod.Compartment();
185
226
  typewriterCompartment = new stateMod.Compartment();
227
+ surfaceCompartment = new stateMod.Compartment();
186
228
 
187
229
  view = new EditorView({
188
230
  parent: host,
@@ -207,6 +249,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
207
249
  highlightMod.cairnDirectivePlugin(),
208
250
  EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
209
251
  theme,
252
+ surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
210
253
  EditorView.updateListener.of((update) => {
211
254
  if (update.docChanged) value = update.state.doc.toString();
212
255
  }),
@@ -239,11 +282,13 @@ through the adapter's render. Swapping the editor stays a one-file change.
239
282
  $effect(() => {
240
283
  const focus = focusMode;
241
284
  const typing = typewriter;
242
- if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment) return;
285
+ const posture = surface;
286
+ if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
243
287
  view.dispatch({
244
288
  effects: [
245
289
  focusCompartment.reconfigure(focus ? modes.focusMode() : []),
246
290
  typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
291
+ surfaceCompartment.reconfigure((posture === 'prose' ? proseTheme : markupTheme) ?? []),
247
292
  ],
248
293
  });
249
294
  });
@@ -19,6 +19,9 @@ interface Props {
19
19
  focusMode?: boolean;
20
20
  /** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
21
21
  typewriter?: boolean;
22
+ /** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
23
+ * leading); markup is the working surface (fills the card, denser). Prose by default. */
24
+ surface?: 'prose' | 'markup';
22
25
  }
23
26
  /**
24
27
  * The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable