vowel 0.6.4 → 0.7.0-beta.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 (166) hide show
  1. package/README.md +1 -1
  2. package/bin.js +47 -27
  3. package/cli.js +15 -0
  4. package/config.js +93 -26
  5. package/extractDate.js +70 -6
  6. package/index.js +14 -5
  7. package/package.json +39 -8
  8. package/plugins/assets/index.js +65 -0
  9. package/plugins/atproto/index.js +132 -0
  10. package/plugins/atproto/password.js +71 -0
  11. package/plugins/atproto/publish.js +406 -0
  12. package/plugins/atproto/record.js +144 -0
  13. package/plugins/atproto/rkey.js +54 -0
  14. package/plugins/deploy-cloudflare/index.js +30 -16
  15. package/plugins/fonts/index.js +18 -21
  16. package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
  17. package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
  18. package/plugins/html/bundle/index.js +21145 -0
  19. package/plugins/html/editor/EditButton.svelte +152 -0
  20. package/plugins/html/editor/Editor.svelte +51 -0
  21. package/plugins/html/editor/Overlays.svelte +6 -0
  22. package/plugins/html/editor/SettingsPanel.svelte +397 -0
  23. package/plugins/html/editor/Toolbar.svelte +644 -0
  24. package/plugins/html/editor/create-session.js +250 -0
  25. package/plugins/html/editor/directives.js +128 -0
  26. package/plugins/html/editor/frontmatter.js +97 -0
  27. package/plugins/html/editor/ingest.js +339 -0
  28. package/plugins/html/editor/main.js +94 -0
  29. package/plugins/html/editor/nodes/Alert.svelte +21 -0
  30. package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
  31. package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
  32. package/plugins/html/editor/nodes/Embed.svelte +19 -0
  33. package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
  34. package/plugins/html/editor/nodes/Heading.svelte +15 -0
  35. package/plugins/html/editor/nodes/Highlight.svelte +11 -0
  36. package/plugins/html/editor/nodes/Image.svelte +18 -0
  37. package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
  38. package/plugins/html/editor/nodes/Link.svelte +17 -0
  39. package/plugins/html/editor/nodes/List.svelte +15 -0
  40. package/plugins/html/editor/nodes/ListItem.svelte +9 -0
  41. package/plugins/html/editor/nodes/Page.svelte +21 -0
  42. package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
  43. package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
  44. package/plugins/html/editor/nodes/Strong.svelte +11 -0
  45. package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
  46. package/plugins/html/editor/nodes/index.js +18 -0
  47. package/plugins/html/editor/rollup.config.js +35 -0
  48. package/plugins/html/editor/serialize.js +157 -0
  49. package/plugins/html/editor/settings-file.js +83 -0
  50. package/plugins/html/editor/socket.js +202 -0
  51. package/plugins/html/editor/source.js +43 -0
  52. package/plugins/html/image.js +31 -5
  53. package/plugins/html/index.js +430 -458
  54. package/plugins/html/partials.js +299 -0
  55. package/plugins/html/socialLinks.js +67 -0
  56. package/plugins/html/writeRules.js +377 -0
  57. package/plugins/icons/index.js +2 -6
  58. package/plugins/images/index.js +78 -55
  59. package/plugins/markdown/brandColors.js +10 -0
  60. package/plugins/markdown/colorScheme.js +64 -0
  61. package/plugins/markdown/emoji.js +30 -0
  62. package/plugins/markdown/frontmatter.js +253 -0
  63. package/plugins/markdown/index.js +276 -334
  64. package/plugins/markdown/links.js +97 -0
  65. package/plugins/markdown/metadata.js +229 -68
  66. package/plugins/markdown/readRules.js +143 -0
  67. package/plugins/markdown/typography.js +109 -0
  68. package/plugins/markdown/visitor.js +143 -0
  69. package/plugins/publish/index.js +34 -0
  70. package/plugins/robots/agents.js +79 -0
  71. package/plugins/robots/index.js +30 -10
  72. package/plugins/robots/robots.js +48 -0
  73. package/plugins/styles/index.js +111 -18
  74. package/plugins/styles/theme.js +116 -0
  75. package/plugins/urls/index.js +49 -62
  76. package/plugins/urls/urlMetadata.js +246 -0
  77. package/plugins/vectors/index.js +5 -9
  78. package/plugins/xml/entryContent.js +103 -0
  79. package/plugins/xml/index.js +105 -43
  80. package/secretPaths.js +170 -0
  81. package/stylesheets/DefaultStyles.css +367 -437
  82. package/stylesheets/ResetStyles.css +2 -38
  83. package/stylesheets/SyntaxHighlightingStyles.css +36 -4
  84. package/stylesheets/TypographyStyles.css +386 -128
  85. package/systemPaths.js +48 -0
  86. package/utils.js +56 -5
  87. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
  88. package/.github/ISSUE_TEMPLATE/config.yml +0 -7
  89. package/docs-source/.cache.json +0 -9
  90. package/docs-source/.obsidian/app.json +0 -3
  91. package/docs-source/.obsidian/appearance.json +0 -3
  92. package/docs-source/.obsidian/core-plugins-migration.json +0 -30
  93. package/docs-source/.obsidian/core-plugins.json +0 -20
  94. package/docs-source/.obsidian/workspace.json +0 -168
  95. package/docs-source/.stackblitzrc +0 -3
  96. package/docs-source/.votive.db +0 -0
  97. package/docs-source/about.md +0 -3
  98. package/docs-source/blog/home.md +0 -5
  99. package/docs-source/blog/url-ui.md +0 -21
  100. package/docs-source/blog.md +0 -5
  101. package/docs-source/docs/.votive.db +0 -0
  102. package/docs-source/docs/deploy.md +0 -67
  103. package/docs-source/docs/file-structure.md +0 -31
  104. package/docs-source/docs/folder-settings.md +0 -23
  105. package/docs-source/docs/home.md +0 -55
  106. package/docs-source/docs/images.md +0 -10
  107. package/docs-source/docs/items.md +0 -13
  108. package/docs-source/docs/pages.md +0 -141
  109. package/docs-source/docs/settings.md +0 -4
  110. package/docs-source/docs/styling.md +0 -34
  111. package/docs-source/docs/taxonomies.md +0 -37
  112. package/docs-source/features/cards.md +0 -7
  113. package/docs-source/features/editing.md +0 -7
  114. package/docs-source/features/emoji.md +0 -7
  115. package/docs-source/features/frontmatter.md +0 -7
  116. package/docs-source/features/lists.md +0 -7
  117. package/docs-source/features/navigation.md +0 -7
  118. package/docs-source/features/rich-previews.md +0 -7
  119. package/docs-source/features/robots.md +0 -7
  120. package/docs-source/features/rss.md +0 -7
  121. package/docs-source/features/sitemap.md +0 -7
  122. package/docs-source/features/speed.md +0 -7
  123. package/docs-source/features/static.md +0 -7
  124. package/docs-source/features/taxonomies.md +0 -7
  125. package/docs-source/home.md +0 -42
  126. package/docs-source/output/about.html +0 -1
  127. package/docs-source/output/blog/url-ui.html +0 -8
  128. package/docs-source/output/blog.html +0 -1
  129. package/docs-source/output/default.css +0 -1
  130. package/docs-source/output/docs/deploy.html +0 -34
  131. package/docs-source/output/docs/file-structure.html +0 -24
  132. package/docs-source/output/docs/folder-settings.html +0 -20
  133. package/docs-source/output/docs/images.html +0 -2
  134. package/docs-source/output/docs/items.html +0 -6
  135. package/docs-source/output/docs/pages.html +0 -101
  136. package/docs-source/output/docs/styling.html +0 -18
  137. package/docs-source/output/docs/taxonomies.html +0 -20
  138. package/docs-source/output/docs.html +0 -28
  139. package/docs-source/output/features/cards.html +0 -1
  140. package/docs-source/output/features/editing.html +0 -1
  141. package/docs-source/output/features/emoji.html +0 -1
  142. package/docs-source/output/features/frontmatter.html +0 -1
  143. package/docs-source/output/features/lists.html +0 -1
  144. package/docs-source/output/features/navigation.html +0 -1
  145. package/docs-source/output/features/rich-previews.html +0 -1
  146. package/docs-source/output/features/robots.html +0 -1
  147. package/docs-source/output/features/rss.html +0 -1
  148. package/docs-source/output/features/sitemap.html +0 -1
  149. package/docs-source/output/features/speed.html +0 -1
  150. package/docs-source/output/features/static.html +0 -1
  151. package/docs-source/output/features/taxonomies.html +0 -1
  152. package/docs-source/output/features.html +0 -1
  153. package/docs-source/output/feed.xml +0 -1
  154. package/docs-source/output/index.html +0 -21
  155. package/docs-source/output/reset.css +0 -1
  156. package/docs-source/output/roadmap.html +0 -87
  157. package/docs-source/output/robots.txt +0 -14
  158. package/docs-source/output/sitemap.xml +0 -16
  159. package/docs-source/output/typography.css +0 -1
  160. package/docs-source/roadmap.md +0 -98
  161. package/docs-source/settings.md +0 -13
  162. package/getMetadata.js +0 -41
  163. package/jsconfig.json +0 -10
  164. package/plugins/markdown/robots.js +0 -18
  165. package/regex.js +0 -36
  166. package/stylesheets/brand-colors.css +0 -119
@@ -0,0 +1,152 @@
1
+ <script>
2
+ // The editor's entry point, and the only thing a previewed page carries
3
+ // until it is pressed: everything Svedit needs is reached through
4
+ // onedit (see main.js's startEditing), not from here.
5
+ //
6
+ // Styled as the same pill as Toolbar.svelte's bottom bar, which takes
7
+ // this corner over once editing starts - so pressing Edit reads as the
8
+ // button becoming the toolbar rather than as one widget replacing
9
+ // another.
10
+ let { onedit, onsettings } = $props();
11
+
12
+ let editing = $state(false);
13
+
14
+ // Non-empty once a press found elements ingest could not read. The page
15
+ // can't be edited at all in that case (ingest fails whole-document),
16
+ // so the button stays put and says so instead of disappearing.
17
+ let unrecognised = $state([]);
18
+
19
+ function start() {
20
+ const failed = onedit();
21
+
22
+ if (failed.length) {
23
+ unrecognised = failed;
24
+ return;
25
+ }
26
+
27
+ editing = true;
28
+ }
29
+ </script>
30
+
31
+ {#if !editing}
32
+ <div class="edit-launcher" data-vowel-client>
33
+ <button
34
+ class="edit settings"
35
+ onclick={onsettings}
36
+ title="Typography and theme settings"
37
+ >
38
+ Settings
39
+ </button>
40
+ <span class="divider" aria-hidden="true"></span>
41
+ <button
42
+ class="edit"
43
+ onclick={start}
44
+ disabled={unrecognised.length > 0}
45
+ title={unrecognised.length
46
+ ? `This page can't be edited yet: ${unrecognised.join(', ')}`
47
+ : 'Edit this page'}
48
+ >
49
+ {unrecognised.length ? "Can't edit" : 'Edit'}
50
+ </button>
51
+ </div>
52
+ {/if}
53
+
54
+ <style>
55
+ /* Same surface as .editor-toolbar in Toolbar.svelte. Duplicated rather
56
+ than shared: scoped styles don't cross components, and a global
57
+ stylesheet injected into someone else's page is a collision waiting
58
+ to happen. */
59
+ .edit-launcher {
60
+ position: fixed;
61
+ bottom: max(var(--s-4, 16px), env(safe-area-inset-bottom, 0px));
62
+ right: var(--s-4, 16px);
63
+ z-index: 50;
64
+ display: flex;
65
+ align-items: center;
66
+ width: fit-content;
67
+ padding: 4px;
68
+ color: var(--foreground, #111);
69
+ background: var(--background, #fff);
70
+ border: 1px solid oklch(from var(--foreground, #111) l c h / 0.12);
71
+ border-radius: 9999px;
72
+ box-shadow:
73
+ 0 1px 2px oklch(0% 0 0 / 0.12),
74
+ 0 4px 16px oklch(0% 0 0 / 0.08);
75
+ pointer-events: auto;
76
+ }
77
+
78
+ /* Matches .toggle-editable, down to leaving font-family alone: the
79
+ toolbar's Save button inherits the UA button font too, and the two
80
+ sit in the same corner one after the other. */
81
+ .edit {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ box-sizing: border-box;
86
+ height: 36px;
87
+ min-height: 36px;
88
+ flex: none;
89
+ padding: 0 1rem;
90
+ border: none;
91
+ border-radius: 9999px;
92
+ background: transparent;
93
+ color: var(--editing, #2563eb);
94
+ font-size: 0.875rem;
95
+ font-weight: 600;
96
+ line-height: 1;
97
+ cursor: pointer;
98
+ pointer-events: auto;
99
+ transition:
100
+ background 150ms,
101
+ transform 150ms;
102
+ outline: 1px solid transparent;
103
+ }
104
+
105
+ @media (hover: hover) {
106
+ .edit:hover:not(:disabled) {
107
+ background: oklch(from var(--editing, #2563eb) l c h / 0.08);
108
+ }
109
+ }
110
+
111
+ .edit:active:not(:disabled) {
112
+ background: oklch(from var(--editing, #2563eb) l c h / 0.12);
113
+ transform: translateY(1px) scale(0.97);
114
+ }
115
+
116
+ .edit:focus-visible {
117
+ outline: none;
118
+ box-shadow: inset 0 0 0 1px var(--editing, #2563eb);
119
+ }
120
+
121
+ /* Separates the two actions inside the one pill, the same hairline
122
+ Toolbar.svelte uses between tool groups. */
123
+ .divider {
124
+ flex: none;
125
+ width: 1px;
126
+ height: 20px;
127
+ margin-inline: 2px;
128
+ background: oklch(from var(--foreground, #111) l c h / 0.15);
129
+ }
130
+
131
+ /* Secondary next to Edit: the same pill, in the text colour rather
132
+ than the accent, so Edit stays the one obvious action. */
133
+ .settings {
134
+ color: var(--foreground, #111);
135
+ font-weight: 500;
136
+ }
137
+
138
+ @media (hover: hover) {
139
+ .settings:hover:not(:disabled) {
140
+ background: oklch(from var(--foreground, #111) l c h / 0.06);
141
+ }
142
+ }
143
+
144
+ .settings:active:not(:disabled) {
145
+ background: oklch(from var(--foreground, #111) l c h / 0.09);
146
+ }
147
+
148
+ .edit:disabled {
149
+ color: oklch(from var(--foreground, #111) l c h / 0.4);
150
+ cursor: not-allowed;
151
+ }
152
+ </style>
@@ -0,0 +1,51 @@
1
+ <script>
2
+ import { setContext } from "svelte"
3
+ import { Svedit, KeyMapper } from "svedit"
4
+ import serialize from "./serialize.js"
5
+ import Toolbar from "./Toolbar.svelte"
6
+ import { getSource } from "./source.js"
7
+ import { splitFrontmatter, writeSettings as writeFile } from "./settings-file.js"
8
+
9
+ // Both props are read off the rendered page before this mounts, by
10
+ // main.js's startEditing: the session from section#content, the title
11
+ // and frontmatter from the <main> around it. Ingesting has to happen
12
+ // while the server output is still there, and section#content has to be
13
+ // emptied before this component is mounted into it - which is why
14
+ // neither belongs here.
15
+ //
16
+ // The frontmatter round trips unchanged for now; carrying it is what
17
+ // lets a save reproduce the whole file rather than just the body.
18
+ let { session, frontmatter } = $props()
19
+
20
+ // The button that mounted this said Edit, so editing is already on and
21
+ // the toolbar's own toggle reads Save.
22
+ let editable = $state(true)
23
+
24
+ const key_mapper = new KeyMapper()
25
+ setContext("key_mapper", key_mapper)
26
+
27
+ // Frontmatter from the source, body from the editor. The source comes
28
+ // with the page (see source.js) and is refreshed by every rebuild the
29
+ // socket pushes, so it is read at save time rather than when the
30
+ // editor opened. Taking the frontmatter from the source rather than
31
+ // the rendered page is what lets a key that never renders survive a
32
+ // save. The title is the one key that isn't kept: it is written back
33
+ // as a `#` heading, never as `title:`, so a file whose title lived
34
+ // only in frontmatter gains a heading and loses the key.
35
+ async function save() {
36
+ const { path, markdown: text } = getSource()
37
+ if (!path || typeof text !== "string") {
38
+ console.error("[vowel] not saved: this page has no source to save to")
39
+ return
40
+ }
41
+ const { title: ignoredTitle, ...properties } = splitFrontmatter(text).data
42
+ const markdown = serialize(session.doc, { title: frontmatter.title, properties })
43
+ await writeFile(path, markdown)
44
+ console.info("[vowel] saved %s", path)
45
+ }
46
+ </script>
47
+
48
+ <svelte:window onkeydown={key_mapper.handle_keydown.bind(key_mapper)} />
49
+
50
+ <Svedit {session} bind:editable path={[session.doc.document_id]} />
51
+ <Toolbar {session} {save} bind:editable />
@@ -0,0 +1,6 @@
1
+ <script>
2
+ // Overlays component for custom UI only (link previews, image editors, etc.)
3
+ // Node selection rendering is now handled by the library's NodeSelectionMarkers.
4
+ </script>
5
+
6
+ <!-- Add your custom overlay UI here (e.g. link previews, floating toolbars) -->
@@ -0,0 +1,397 @@
1
+ <script>
2
+ // The settings drawer.
3
+ //
4
+ // Full height down the left edge, and deliberately **not** modal: no
5
+ // backdrop, the page behind stays scrollable and clickable. Live
6
+ // preview is the whole point - you drag a slider and watch the
7
+ // headings resize - so anything that blocks the page would defeat it.
8
+ //
9
+ // Every control is derived from data rather than hardcoded: the site
10
+ // fields from `siteFields` below, the typography controls from the
11
+ // same table the build emits type.css from (typography.js), so the
12
+ // variables the panel writes for preview are the ones the sheet reads.
13
+ import { rampSettings, typographyVariables } from '../../markdown/typography.js';
14
+ import { fallbackColors } from '../../markdown/brandColors.js';
15
+ import { readSettings, joinFrontmatter, themeObject, writeSettings } from './settings-file.js';
16
+
17
+ let { onclose } = $props();
18
+
19
+ let loading = $state(true);
20
+ let error = $state(null);
21
+ let saving = $state(false);
22
+
23
+ // The whole file, so a save rewrites only what changed and leaves
24
+ // every other key - and the body - alone.
25
+ let file = $state({ data: {}, body: '' });
26
+
27
+ // Held apart from `data` until a theme field is actually edited, so a
28
+ // site that wrote `theme: default` keeps that spelling unless it has a
29
+ // reason not to. Editing only the title must not silently rewrite the
30
+ // theme into its object form.
31
+ let editedTheme = $state(null);
32
+
33
+ const theme = $derived(editedTheme ?? themeObject(file.data.theme));
34
+ const colors = $derived([0, 1].map((index) => theme.colors?.[index] ?? fallbackColors[index]));
35
+
36
+ // Top-level frontmatter keys, in the order they read as a form. Only
37
+ // settings vowel actually consumes are here - `author` is in both demo
38
+ // settings.md files and is read by nothing, so it isn't offered.
39
+ const siteFields = [
40
+ { key: 'name', label: 'Name', hint: 'The site name, shown in the header' },
41
+ { key: 'breadcrumb', label: 'Breadcrumb', hint: 'Label for the site root in navigation' },
42
+ { key: 'domain', label: 'Domain', hint: 'Needed for the sitemap and feed' },
43
+ { key: 'icon', label: 'Icon', hint: 'An emoji, or a path to an image' },
44
+ { key: 'logo', label: 'Logo', hint: 'Path to an SVG or image' },
45
+ { key: 'wordmark', label: 'Wordmark', hint: 'Path to an SVG or image' }
46
+ ];
47
+
48
+ // Setting a variable on the document is the whole of live preview:
49
+ // TypographyStyles.css computes every heading level from these, so
50
+ // the ramp recomputes as a slider moves. A key the author unsets goes
51
+ // back to the sheet's default by removing the override.
52
+ const preview = $derived(typographyVariables(theme));
53
+
54
+ $effect(() => {
55
+ const root = document.documentElement;
56
+ const set = new Set(preview.map(([name]) => name));
57
+ for (const { variable } of rampSettings) {
58
+ if (!set.has(variable)) root.style.removeProperty(variable);
59
+ }
60
+ for (const [name, value] of preview) root.style.setProperty(name, value);
61
+ // The colour scheme needs a rebuild to see: the ramps are generated
62
+ // by colorhorse on the server.
63
+ });
64
+
65
+ $effect(() => {
66
+ readSettings()
67
+ .then((result) => (file = result))
68
+ .catch((e) => (error = e.message))
69
+ .finally(() => (loading = false));
70
+ });
71
+
72
+ function setField(key, value) {
73
+ const data = { ...file.data };
74
+ if (value === '') delete data[key];
75
+ else data[key] = value;
76
+ file = { ...file, data };
77
+ }
78
+
79
+ function setTheme(key, value) {
80
+ // Clearing a field removes the key rather than writing `undefined`,
81
+ // so the sheet's default takes over and settings.md stays clean.
82
+ const { [key]: _, ...rest } = theme;
83
+ editedTheme = value === undefined ? rest : { ...rest, [key]: value };
84
+ }
85
+
86
+ /** Both seeds are written together - colorhorse takes a pair. */
87
+ function setColor(index, value) {
88
+ setTheme('colors', colors.map((current, position) => (position === index ? value : current)));
89
+ }
90
+
91
+ async function save() {
92
+ saving = true;
93
+ error = null;
94
+
95
+ const data = editedTheme ? { ...file.data, theme: editedTheme } : file.data;
96
+
97
+ try {
98
+ await writeSettings('settings.md', joinFrontmatter(data, file.body));
99
+ // No reload: the write lands in sourceFolder, the watcher
100
+ // rebuilds and votive's live-reload client patches the page - the
101
+ // same path a hand edit takes.
102
+ onclose();
103
+ } catch (e) {
104
+ error = e.message;
105
+ } finally {
106
+ saving = false;
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <aside class="settings-drawer" aria-label="Site settings" data-vowel-client>
112
+ <header>
113
+ <h2>Settings</h2>
114
+ <button class="close" onclick={onclose} title="Close settings">&#10005;</button>
115
+ </header>
116
+
117
+ {#if loading}
118
+ <p class="note">Reading settings.md…</p>
119
+ {:else}
120
+ <section>
121
+ <h3>Site</h3>
122
+ {#each siteFields as field (field.key)}
123
+ <label class="field">
124
+ <span>{field.label}</span>
125
+ <input
126
+ type="text"
127
+ value={file.data[field.key] ?? ''}
128
+ placeholder={field.hint}
129
+ title={field.hint}
130
+ oninput={(event) => setField(field.key, event.currentTarget.value)}
131
+ />
132
+ </label>
133
+ {/each}
134
+ </section>
135
+
136
+ <section>
137
+ <h3>Colors</h3>
138
+ <p class="note">Two seeds; every shade on the site is generated from them.</p>
139
+ {#each ['One', 'Two'] as label, index (label)}
140
+ <label class="field color">
141
+ <span>{label}</span>
142
+ <input
143
+ type="color"
144
+ value={colors[index]}
145
+ oninput={(event) => setColor(index, event.currentTarget.value)}
146
+ />
147
+ <input
148
+ type="text"
149
+ class="hex"
150
+ value={colors[index]}
151
+ spellcheck="false"
152
+ oninput={(event) => setColor(index, event.currentTarget.value)}
153
+ />
154
+ </label>
155
+ {/each}
156
+ </section>
157
+
158
+ <section>
159
+ <h3>Typography</h3>
160
+ <p class="note">Previews as you drag. Colours apply on save. A font family is set in your own stylesheet.</p>
161
+
162
+ {#each rampSettings as setting (setting.key)}
163
+ {@const current = Number(theme[setting.key] ?? setting.default)}
164
+ <label class="slider" title={setting.hint}>
165
+ <span>{setting.label}</span>
166
+ <input
167
+ type="range"
168
+ min={setting.min}
169
+ max={setting.max}
170
+ step={setting.step}
171
+ value={current}
172
+ oninput={(event) => setTheme(setting.key, Number(event.currentTarget.value))}
173
+ />
174
+ <output>{current}{setting.unit}</output>
175
+ </label>
176
+ {/each}
177
+ </section>
178
+ {/if}
179
+
180
+ {#if error}
181
+ <p class="error">{error}</p>
182
+ {/if}
183
+
184
+ <footer>
185
+ <button class="save" onclick={save} disabled={saving || loading}>
186
+ {saving ? 'Saving…' : 'Save'}
187
+ </button>
188
+ </footer>
189
+ </aside>
190
+
191
+ <style>
192
+ /* Full height down the left edge. No backdrop and nothing fixed over
193
+ the page: the site stays usable while you edit, which is what makes
194
+ the live preview worth having. The page is not pushed aside either -
195
+ that would mean writing to the host document's own layout. */
196
+ .settings-drawer {
197
+ /* Physical, not logical: the drawer is injected into someone
198
+ else's document and would flip to the right edge on an RTL
199
+ page, which is not what "left" means here. */
200
+ position: fixed;
201
+ top: 0;
202
+ bottom: 0;
203
+ left: 0;
204
+ z-index: 70;
205
+ box-sizing: border-box;
206
+ display: flex;
207
+ flex-direction: column;
208
+ width: min(340px, 100vw);
209
+ padding: 16px;
210
+ overflow-y: auto;
211
+ overscroll-behavior: contain;
212
+ color: var(--foreground, #111);
213
+ background: var(--background, #fff);
214
+ border-right: 1px solid oklch(from var(--foreground, #111) l c h / 0.12);
215
+ box-shadow: 0 0 24px oklch(0% 0 0 / 0.12);
216
+ font-size: 0.8125rem;
217
+ line-height: 1.4;
218
+ }
219
+
220
+ header {
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: space-between;
224
+ gap: 8px;
225
+ margin-bottom: 4px;
226
+ }
227
+
228
+ h2 {
229
+ margin: 0;
230
+ font-size: 1rem;
231
+ font-weight: 600;
232
+ letter-spacing: 0;
233
+ }
234
+
235
+ h3 {
236
+ margin: 0 0 8px;
237
+ font-size: 0.6875rem;
238
+ font-weight: 600;
239
+ letter-spacing: 0.08em;
240
+ text-transform: uppercase;
241
+ color: oklch(from var(--foreground, #111) l c h / 0.55);
242
+ }
243
+
244
+ section {
245
+ padding-block: 14px;
246
+ border-top: 1px solid oklch(from var(--foreground, #111) l c h / 0.1);
247
+ }
248
+
249
+ .close {
250
+ flex: none;
251
+ width: 28px;
252
+ height: 28px;
253
+ padding: 0;
254
+ border: none;
255
+ border-radius: 50%;
256
+ background: transparent;
257
+ color: inherit;
258
+ font-size: 0.75rem;
259
+ cursor: pointer;
260
+ }
261
+
262
+ @media (hover: hover) {
263
+ .close:hover {
264
+ background: oklch(from var(--foreground, #111) l c h / 0.06);
265
+ }
266
+ }
267
+
268
+ .note {
269
+ margin: 0 0 10px;
270
+ color: oklch(from var(--foreground, #111) l c h / 0.6);
271
+ }
272
+
273
+ .error {
274
+ margin: 10px 0 0;
275
+ color: oklch(0.55 0.2 25);
276
+ }
277
+
278
+ .field {
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: space-between;
282
+ gap: 8px;
283
+ margin-bottom: 8px;
284
+ }
285
+
286
+ .field > span {
287
+ flex: none;
288
+ width: 5.5rem;
289
+ color: oklch(from var(--foreground, #111) l c h / 0.6);
290
+ }
291
+
292
+ .field.check {
293
+ justify-content: flex-start;
294
+ }
295
+
296
+ input[type='text'],
297
+ select {
298
+ flex: 1;
299
+ min-width: 0;
300
+ box-sizing: border-box;
301
+ height: 30px;
302
+ padding: 0 6px;
303
+ color: inherit;
304
+ background: transparent;
305
+ border: 1px solid oklch(from var(--foreground, #111) l c h / 0.2);
306
+ border-radius: 8px;
307
+ font: inherit;
308
+ }
309
+
310
+ input[type='text']:focus-visible,
311
+ select:focus-visible {
312
+ outline: none;
313
+ border-color: var(--editing, #2563eb);
314
+ box-shadow: inset 0 0 0 1px var(--editing, #2563eb);
315
+ }
316
+
317
+ /* The swatch and its hex edit the same value; the swatch is for
318
+ choosing, the field is for pasting one you already have. */
319
+ .field.color input[type='color'] {
320
+ flex: none;
321
+ width: 34px;
322
+ height: 30px;
323
+ padding: 2px;
324
+ background: transparent;
325
+ border: 1px solid oklch(from var(--foreground, #111) l c h / 0.2);
326
+ border-radius: 8px;
327
+ cursor: pointer;
328
+ }
329
+
330
+ .hex {
331
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
332
+ text-transform: lowercase;
333
+ }
334
+
335
+ fieldset {
336
+ margin: 0 0 10px;
337
+ padding: 8px 10px;
338
+ border: 1px solid oklch(from var(--foreground, #111) l c h / 0.12);
339
+ border-radius: 10px;
340
+ }
341
+
342
+ legend {
343
+ padding: 0 4px;
344
+ font-weight: 600;
345
+ }
346
+
347
+ .slider {
348
+ display: grid;
349
+ grid-template-columns: 2.25rem 1fr 3.25rem;
350
+ align-items: center;
351
+ gap: 6px;
352
+ }
353
+
354
+ .slider span {
355
+ color: oklch(from var(--foreground, #111) l c h / 0.6);
356
+ }
357
+
358
+ .slider input {
359
+ width: 100%;
360
+ min-width: 0;
361
+ }
362
+
363
+ output {
364
+ font-variant-numeric: tabular-nums;
365
+ text-align: right;
366
+ color: oklch(from var(--foreground, #111) l c h / 0.6);
367
+ }
368
+
369
+ /* Sticks to the bottom of the drawer, above the scrolling content. */
370
+ footer {
371
+ position: sticky;
372
+ bottom: 0;
373
+ display: flex;
374
+ justify-content: flex-end;
375
+ margin-top: auto;
376
+ padding-top: 12px;
377
+ padding-bottom: 4px;
378
+ background: var(--background, #fff);
379
+ }
380
+
381
+ .save {
382
+ height: 32px;
383
+ padding: 0 1rem;
384
+ border: none;
385
+ border-radius: 9999px;
386
+ background: var(--editing, #2563eb);
387
+ color: var(--background, #fff);
388
+ font-size: 0.8125rem;
389
+ font-weight: 600;
390
+ cursor: pointer;
391
+ }
392
+
393
+ .save:disabled {
394
+ opacity: 0.5;
395
+ cursor: not-allowed;
396
+ }
397
+ </style>