@glw907/cairn-cms 0.52.0 → 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 +30 -0
- package/dist/components/AdminLayout.svelte +6 -4
- package/dist/components/EditPage.svelte +122 -47
- package/dist/components/EditPage.svelte.d.ts +2 -1
- package/dist/components/EditorToolbar.svelte +3 -43
- package/dist/components/EditorToolbar.svelte.d.ts +3 -11
- package/dist/components/MarkdownEditor.svelte +64 -17
- package/dist/components/MarkdownEditor.svelte.d.ts +3 -0
- package/dist/components/cairn-admin.css +81 -7
- package/dist/components/fonts/{Figtree-OFL.txt → IBMPlexSans-OFL.txt} +2 -2
- package/dist/components/fonts/ibm-plex-sans.woff2 +0 -0
- package/dist/sveltekit/static-admin-page.js +2 -2
- package/package.json +1 -1
- package/src/lib/components/AdminLayout.svelte +6 -4
- package/src/lib/components/EditPage.svelte +122 -47
- package/src/lib/components/EditorToolbar.svelte +3 -43
- package/src/lib/components/MarkdownEditor.svelte +64 -17
- package/src/lib/components/cairn-admin.css +27 -3
- 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/sveltekit/static-admin-page.ts +2 -2
- package/dist/components/fonts/figtree.woff2 +0 -0
- package/src/lib/components/fonts/figtree.woff2 +0 -0
|
@@ -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
|
|
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
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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,24 +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-[
|
|
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
|
|
660
|
+
centers, so the card frame never spans emptiness on a wide window. Preview keeps the full
|
|
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]'}`}>
|
|
642
666
|
{#if titleField}
|
|
643
667
|
<!-- The hoisted document title: large, borderless, in the display face, so the manuscript
|
|
644
668
|
reads as the protagonist. It submits as name="title", the same field as before. The
|
|
645
|
-
admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there).
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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>
|
|
654
685
|
{/if}
|
|
655
686
|
<!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
|
|
656
687
|
reads as a single object. The card carries the formatting shortcuts for everything in it. -->
|
|
@@ -660,52 +691,46 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
660
691
|
role="group"
|
|
661
692
|
aria-label="Editor"
|
|
662
693
|
>
|
|
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
|
-
>
|
|
694
|
+
<EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
|
|
674
695
|
{#snippet insertControls()}
|
|
675
696
|
<!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
|
|
676
|
-
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. -->
|
|
677
700
|
{#if hasComponents}
|
|
678
701
|
<button
|
|
679
702
|
type="button"
|
|
680
|
-
class="btn btn-sm btn-ghost"
|
|
703
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
681
704
|
aria-haspopup="dialog"
|
|
682
705
|
aria-label="Insert block"
|
|
706
|
+
title="Insert block"
|
|
683
707
|
disabled={insertDisabled}
|
|
684
708
|
onclick={() => insertDialog?.open()}
|
|
685
709
|
>
|
|
686
|
-
|
|
710
|
+
<BlocksIcon class="h-4 w-4" aria-hidden="true" />
|
|
687
711
|
</button>
|
|
688
712
|
{/if}
|
|
689
713
|
<button
|
|
690
714
|
type="button"
|
|
691
|
-
class="btn btn-sm btn-ghost"
|
|
715
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
692
716
|
aria-haspopup="dialog"
|
|
693
717
|
aria-label="Web link (Ctrl+K)"
|
|
694
718
|
title="Web link (Ctrl+K)"
|
|
695
719
|
disabled={insertDisabled}
|
|
696
720
|
onclick={() => webLinkDialog?.open()}
|
|
697
721
|
>
|
|
698
|
-
|
|
722
|
+
<LinkIcon class="h-4 w-4" aria-hidden="true" />
|
|
699
723
|
</button>
|
|
700
724
|
<button
|
|
701
725
|
type="button"
|
|
702
|
-
class="btn btn-sm btn-ghost"
|
|
726
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
703
727
|
aria-haspopup="dialog"
|
|
704
728
|
aria-label="Link to page"
|
|
729
|
+
title="Link to page"
|
|
705
730
|
disabled={insertDisabled}
|
|
706
731
|
onclick={() => linkPicker?.open()}
|
|
707
732
|
>
|
|
708
|
-
|
|
733
|
+
<FileSymlinkIcon class="h-4 w-4" aria-hidden="true" />
|
|
709
734
|
</button>
|
|
710
735
|
<button
|
|
711
736
|
type="button"
|
|
@@ -728,6 +753,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
728
753
|
<MarkdownEditor
|
|
729
754
|
bind:value={body}
|
|
730
755
|
name="body"
|
|
756
|
+
{surface}
|
|
731
757
|
registerInsert={(fn) => (insert = fn)}
|
|
732
758
|
registerInsertLink={(fn) => (insertLink = fn)}
|
|
733
759
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
@@ -788,17 +814,64 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
788
814
|
</div>
|
|
789
815
|
{/if}
|
|
790
816
|
<!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
|
|
791
|
-
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. -->
|
|
792
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)]">
|
|
793
822
|
<span>{wordLabel}</span>
|
|
794
|
-
<
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
aria-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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>
|
|
802
875
|
</div>
|
|
803
876
|
</div>
|
|
804
877
|
</div>
|
|
@@ -808,7 +881,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
808
881
|
<aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
|
|
809
882
|
<!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
|
|
810
883
|
a real legend that screen readers announce with the fields it holds. -->
|
|
811
|
-
|
|
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">
|
|
812
887
|
{#if detailFields.length}
|
|
813
888
|
<fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
|
|
814
889
|
<legend class={eyebrowClass}>Details</legend>
|
|
@@ -817,12 +892,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
817
892
|
{@const f = field as TextareaField}
|
|
818
893
|
<label class="flex flex-col gap-1">
|
|
819
894
|
<span class="text-sm font-medium">{f.label}</span>
|
|
820
|
-
<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>
|
|
821
896
|
</label>
|
|
822
897
|
{:else if field.type === 'date'}
|
|
823
898
|
<label class="flex flex-col gap-1">
|
|
824
899
|
<span class="text-sm font-medium">{field.label}</span>
|
|
825
|
-
<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])} />
|
|
826
901
|
</label>
|
|
827
902
|
{:else if field.type === 'boolean'}
|
|
828
903
|
<label class="label cursor-pointer justify-start gap-2">
|
|
@@ -855,7 +930,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
855
930
|
<label class="flex flex-col gap-1">
|
|
856
931
|
<span class="text-sm font-medium">{f.label}</span>
|
|
857
932
|
<input
|
|
858
|
-
class="input"
|
|
933
|
+
class="input input-sm"
|
|
859
934
|
name={f.name}
|
|
860
935
|
aria-label={f.label}
|
|
861
936
|
placeholder={f.placeholder}
|
|
@@ -865,7 +940,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
865
940
|
{:else}
|
|
866
941
|
<label class="flex flex-col gap-1">
|
|
867
942
|
<span class="text-sm font-medium">{field.label}</span>
|
|
868
|
-
<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} />
|
|
869
944
|
</label>
|
|
870
945
|
{/if}
|
|
871
946
|
{/each}
|
|
@@ -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
|
|
9
|
-
|
|
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}
|
|
@@ -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');
|
|
@@ -75,9 +85,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
75
85
|
// tooltip above all) renders dark-on-dark instead of light-on-dark.
|
|
76
86
|
const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
|
|
77
87
|
// The directive machinery treatment: rails, not bands. A row at depth N draws every rail
|
|
78
|
-
// 1..N as literal nested brackets: 2px accent bars at x offsets 0-2,
|
|
79
|
-
// of surface between them
|
|
80
|
-
//
|
|
88
|
+
// 1..N as literal nested brackets: 2px accent bars at x offsets 0-2, 6-8, and 12-14 with 4px
|
|
89
|
+
// of surface between them (a gap of twice the bar weight, the floor for two parallel rules
|
|
90
|
+
// to read as separate lines rather than one thick one), stacked as inset box shadows (top
|
|
91
|
+
// layer first, so each bar sits over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
|
|
81
92
|
// cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
|
|
82
93
|
// outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
|
|
83
94
|
// braces dim to the marker tone while the name and label keep a depth-stepped ink. Leaf and
|
|
@@ -91,7 +102,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
91
102
|
const rails = (depth: number, active = false): string => {
|
|
92
103
|
const layers: string[] = [];
|
|
93
104
|
for (let d = 1; d <= depth; d++) {
|
|
94
|
-
const edge =
|
|
105
|
+
const edge = 6 * d - 4;
|
|
95
106
|
if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
|
|
96
107
|
const own = active && d === depth;
|
|
97
108
|
layers.push(
|
|
@@ -120,19 +131,23 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
120
131
|
const theme = EditorView.theme(
|
|
121
132
|
{
|
|
122
133
|
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
|
|
123
|
-
// The
|
|
124
|
-
// contenteditable content area carries the height, a
|
|
125
|
-
// text still lands in the editor and focuses it.
|
|
126
|
-
//
|
|
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.
|
|
127
142
|
'.cm-content': {
|
|
128
143
|
// The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
|
|
129
144
|
// fallback keeps the surface monospace outside an admin theme wrapper.
|
|
130
145
|
fontFamily: "var(--font-editor, ui-monospace, monospace)",
|
|
131
|
-
padding
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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',
|
|
136
151
|
},
|
|
137
152
|
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
138
153
|
// A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
|
|
@@ -145,9 +160,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
145
160
|
outlineOffset: '-1px',
|
|
146
161
|
},
|
|
147
162
|
'.cm-line': { padding: '0' },
|
|
148
|
-
// The gutter: directive rows pad left so the text clears the deepest rail stack
|
|
149
|
-
//
|
|
150
|
-
|
|
163
|
+
// The gutter: directive rows pad left so the text clears the deepest rail stack (the
|
|
164
|
+
// depth-3 bar ends at 14px, the active one at 15px; 1.5rem keeps ~10px of air beyond
|
|
165
|
+
// it). Static structure (caret-independent), so caret movement shifts no layout.
|
|
166
|
+
'.cm-cairn-directive-fence, .cm-cairn-directive-content': { paddingLeft: '1.5rem' },
|
|
151
167
|
...railRules,
|
|
152
168
|
'.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
|
|
153
169
|
'.cm-cairn-directive-label': { color: 'var(--color-accent)' },
|
|
@@ -174,13 +190,41 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
174
190
|
color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
|
|
175
191
|
backgroundColor: 'transparent',
|
|
176
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' },
|
|
177
219
|
},
|
|
178
220
|
{ dark: isDark },
|
|
179
221
|
);
|
|
222
|
+
markupTheme = EditorView.theme({ '.cm-content': { lineHeight: '1.8' } }, { dark: isDark });
|
|
180
223
|
|
|
181
224
|
modes = modesMod;
|
|
182
225
|
focusCompartment = new stateMod.Compartment();
|
|
183
226
|
typewriterCompartment = new stateMod.Compartment();
|
|
227
|
+
surfaceCompartment = new stateMod.Compartment();
|
|
184
228
|
|
|
185
229
|
view = new EditorView({
|
|
186
230
|
parent: host,
|
|
@@ -205,6 +249,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
205
249
|
highlightMod.cairnDirectivePlugin(),
|
|
206
250
|
EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
|
|
207
251
|
theme,
|
|
252
|
+
surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
|
|
208
253
|
EditorView.updateListener.of((update) => {
|
|
209
254
|
if (update.docChanged) value = update.state.doc.toString();
|
|
210
255
|
}),
|
|
@@ -237,11 +282,13 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
237
282
|
$effect(() => {
|
|
238
283
|
const focus = focusMode;
|
|
239
284
|
const typing = typewriter;
|
|
240
|
-
|
|
285
|
+
const posture = surface;
|
|
286
|
+
if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
|
|
241
287
|
view.dispatch({
|
|
242
288
|
effects: [
|
|
243
289
|
focusCompartment.reconfigure(focus ? modes.focusMode() : []),
|
|
244
290
|
typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
|
|
291
|
+
surfaceCompartment.reconfigure((posture === 'prose' ? proseTheme : markupTheme) ?? []),
|
|
245
292
|
],
|
|
246
293
|
});
|
|
247
294
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Cairn's own typefaces, self-hosted so the admin needs no external font request.
|
|
1
|
+
/* Cairn's own typefaces, self-hosted so the admin needs no external font request. IBM Plex Sans carries
|
|
2
2
|
the body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and
|
|
3
3
|
the page headings a distinct voice; iA Writer Mono is the editor writing surface. The first two are
|
|
4
4
|
variable (one file spans the weight range), the editor face ships four static files, and all three
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
of use, so this fully overrides the host's theme with no @plugin and no host build step. */
|
|
11
11
|
[data-theme='cairn-admin'] {
|
|
12
12
|
color-scheme: light;
|
|
13
|
-
--font-body: '
|
|
13
|
+
--font-body: 'IBM Plex Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
14
14
|
--font-display: 'Bricolage Grotesque Variable', var(--font-body);
|
|
15
15
|
--font-editor: 'iA Writer Mono', ui-monospace, monospace;
|
|
16
16
|
font-family: var(--font-body);
|
|
@@ -63,6 +63,14 @@
|
|
|
63
63
|
ever sits on; on the chips it measured as low as 2.63:1. Do not lighten without
|
|
64
64
|
re-checking. */
|
|
65
65
|
--cairn-focus-dim-ink: oklch(66% 0.01 75);
|
|
66
|
+
/* Focus mode's rail percentages: the directive bars dim with their text (about a third of the
|
|
67
|
+
quiet 72/82/92 ramp), so a dimmed block keeps its structure without staying the one
|
|
68
|
+
chromatic object in the field. Deliberately sub-3:1, the same transient-state call as the
|
|
69
|
+
dim ink; the quiet ramp is one toggle away. */
|
|
70
|
+
--cairn-focus-dim-rail-1: 24%;
|
|
71
|
+
--cairn-focus-dim-rail-2: 28%;
|
|
72
|
+
--cairn-focus-dim-rail-3: 32%;
|
|
73
|
+
--cairn-focus-dim-rail-active: 36%;
|
|
66
74
|
--color-neutral: oklch(32% 0.012 75);
|
|
67
75
|
--color-neutral-content: oklch(96% 0.004 75);
|
|
68
76
|
|
|
@@ -103,7 +111,7 @@
|
|
|
103
111
|
>= 4.5:1 contrast on the dark bases. The polish pass tunes these values against the reference. */
|
|
104
112
|
[data-theme='cairn-admin-dark'] {
|
|
105
113
|
color-scheme: dark;
|
|
106
|
-
--font-body: '
|
|
114
|
+
--font-body: 'IBM Plex Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
107
115
|
--font-display: 'Bricolage Grotesque Variable', var(--font-body);
|
|
108
116
|
--font-editor: 'iA Writer Mono', ui-monospace, monospace;
|
|
109
117
|
font-family: var(--font-body);
|
|
@@ -119,6 +127,9 @@
|
|
|
119
127
|
--color-base-300: oklch(30% 0.014 75);
|
|
120
128
|
--color-base-content: oklch(93% 0.006 75);
|
|
121
129
|
|
|
130
|
+
/* Locked pair: pressed footer-toggle text (text-primary on the bg-primary/10 plate over
|
|
131
|
+
dark base-100) measures 4.66:1, only 0.16 above the AA floor. Do not darken dark
|
|
132
|
+
--color-primary without re-checking that pair. */
|
|
122
133
|
--color-primary: oklch(68% 0.18 293);
|
|
123
134
|
--color-primary-content: oklch(20% 0.04 293);
|
|
124
135
|
--color-secondary: oklch(72% 0.02 75);
|
|
@@ -153,6 +164,12 @@
|
|
|
153
164
|
the proposed 50% measured 2.74:1, under the 3:1 floor, so it sits at 53%. Locked pair on
|
|
154
165
|
base-100: 3.12:1. Do not darken without re-checking. */
|
|
155
166
|
--cairn-focus-dim-ink: oklch(53% 0.01 75);
|
|
167
|
+
/* Focus mode's rail percentages on dark, a third of the quiet 62/74/86 ramp; the same
|
|
168
|
+
deliberate sub-3:1 transient-state call as the dim ink. */
|
|
169
|
+
--cairn-focus-dim-rail-1: 21%;
|
|
170
|
+
--cairn-focus-dim-rail-2: 25%;
|
|
171
|
+
--cairn-focus-dim-rail-3: 29%;
|
|
172
|
+
--cairn-focus-dim-rail-active: 33%;
|
|
156
173
|
--color-neutral: oklch(80% 0.01 75);
|
|
157
174
|
--color-neutral-content: oklch(22% 0.008 75);
|
|
158
175
|
|
|
@@ -260,6 +277,13 @@
|
|
|
260
277
|
outline-offset: -1px;
|
|
261
278
|
}
|
|
262
279
|
|
|
280
|
+
/* Under focus mode the document title eases back with the rest of the context: at 30px bold
|
|
281
|
+
it would otherwise be the strongest ink on the page and pull the eye off the lit
|
|
282
|
+
paragraph. Its own focus restores full ink, so editing the title is never dimmed. */
|
|
283
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .cairn-doc-title-dim:not(:focus) {
|
|
284
|
+
color: var(--cairn-focus-dim-ink);
|
|
285
|
+
}
|
|
286
|
+
|
|
263
287
|
/* Menu items come as anchors or buttons, and the omitted Preflight is what made them match:
|
|
264
288
|
without it a button keeps the UA chrome (outset border, gray fill, centered system-font
|
|
265
289
|
text) while its anchor siblings render flat. This scoped substitute levels the buttons to
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copyright
|
|
1
|
+
Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved.
|
|
2
2
|
|
|
3
3
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
4
|
This license is copied below, and is also available with a FAQ at:
|
|
@@ -18,7 +18,7 @@ with others.
|
|
|
18
18
|
|
|
19
19
|
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
20
|
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
-
fonts, including any derivative works, can be bundled, embedded,
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
22
|
redistributed and/or sold with any software provided that any reserved
|
|
23
23
|
names are not used by derivative works. The fonts and derivatives,
|
|
24
24
|
however, cannot be released under any other type of license. The
|
|
Binary file
|