@jxsuite/studio 0.17.0 → 0.18.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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * CSS Variables editor — form-based editor for managing design tokens. Colors and fonts are NOT
3
+ * media-aware; sizes/spacing are media-aware.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { ref } from "lit-html/directives/ref.js";
8
+ import { projectState } from "../store.js";
9
+ import { updateSiteConfig } from "../site-context.js";
10
+ import { getEffectiveMedia } from "../site-context.js";
11
+ import { friendlyNameToVar, varDisplayName } from "../utils/studio-utils.js";
12
+
13
+ /** @param {HTMLElement} container */
14
+ export function renderCssVarsEditor(container) {
15
+ const config = projectState.projectConfig || {};
16
+ const rootStyle = config.style || {};
17
+ const media = getEffectiveMedia(config.$media);
18
+
19
+ /**
20
+ * @type {{
21
+ * color: [string, any][];
22
+ * font: [string, any][];
23
+ * size: [string, any][];
24
+ * other: [string, any][];
25
+ * }}
26
+ */
27
+ const groups = { color: [], font: [], size: [], other: [] };
28
+ for (const [k, v] of Object.entries(rootStyle)) {
29
+ if (!k.startsWith("--")) continue;
30
+ if (typeof v !== "string" && typeof v !== "number") continue;
31
+ if (k.startsWith("--color")) groups.color.push([k, v]);
32
+ else if (k.startsWith("--font")) groups.font.push([k, v]);
33
+ else if (k.startsWith("--size") || k.startsWith("--spacing") || k.startsWith("--radius"))
34
+ groups.size.push([k, v]);
35
+ else groups.other.push([k, v]);
36
+ }
37
+
38
+ const mediaNames = media ? Object.keys(media).filter((m) => m !== "--") : [];
39
+
40
+ const save = () => {
41
+ updateSiteConfig({ style: { ...rootStyle } });
42
+ };
43
+
44
+ const updateVar = (/** @type {string} */ name, /** @type {string} */ val) => {
45
+ rootStyle[name] = val;
46
+ save();
47
+ };
48
+
49
+ const deleteVar = (/** @type {string} */ name) => {
50
+ delete rootStyle[name];
51
+ save();
52
+ renderCssVarsEditor(container);
53
+ };
54
+
55
+ const addVar = (
56
+ /** @type {string} */ prefix,
57
+ /** @type {string} */ friendlyName,
58
+ /** @type {string} */ val,
59
+ ) => {
60
+ const varName = friendlyNameToVar(friendlyName, prefix);
61
+ if (!varName || !val) return;
62
+ rootStyle[varName] = val;
63
+ save();
64
+ renderCssVarsEditor(container);
65
+ };
66
+
67
+ const tpl = html`
68
+ <div class="settings-section">
69
+ <h3 class="settings-section-title">CSS Variables</h3>
70
+
71
+ ${renderColorSection(groups.color, updateVar, deleteVar, addVar)}
72
+ ${renderFontSection(groups.font, updateVar, deleteVar, addVar)}
73
+ ${renderSizeSection(groups.size, updateVar, deleteVar, addVar, rootStyle, mediaNames)}
74
+ ${groups.other.length > 0
75
+ ? renderOtherSection(groups.other, updateVar, deleteVar, addVar, rootStyle, mediaNames)
76
+ : nothing}
77
+ </div>
78
+ `;
79
+
80
+ litRender(tpl, container);
81
+ }
82
+
83
+ /**
84
+ * @param {[string, any][]} vars
85
+ * @param {Function} updateVar
86
+ * @param {Function} deleteVar
87
+ * @param {Function} addVar
88
+ */
89
+ function renderColorSection(vars, updateVar, deleteVar, addVar) {
90
+ return html`
91
+ <div class="css-vars-group">
92
+ <h4 class="css-vars-group-title">Colors</h4>
93
+ ${vars.map(
94
+ ([name, val]) => html`
95
+ <div class="css-var-row">
96
+ <div class="css-var-swatch" style="background:${val}">
97
+ <input
98
+ type="color"
99
+ .value=${val && val.startsWith("#") ? val : "#007acc"}
100
+ @input=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
101
+ />
102
+ </div>
103
+ <span class="css-var-name">${varDisplayName(name, "--color-")}</span>
104
+ <sp-textfield
105
+ size="s"
106
+ .value=${String(val)}
107
+ @change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
108
+ style="flex:1;max-width:160px"
109
+ ></sp-textfield>
110
+ <sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
111
+ <sp-icon-delete slot="icon"></sp-icon-delete>
112
+ </sp-action-button>
113
+ </div>
114
+ `,
115
+ )}
116
+ ${renderAddRow("--color-", "Primary Blue", "#007acc", addVar)}
117
+ </div>
118
+ `;
119
+ }
120
+
121
+ /**
122
+ * @param {[string, any][]} vars
123
+ * @param {Function} updateVar
124
+ * @param {Function} deleteVar
125
+ * @param {Function} addVar
126
+ */
127
+ function renderFontSection(vars, updateVar, deleteVar, addVar) {
128
+ return html`
129
+ <div class="css-vars-group">
130
+ <h4 class="css-vars-group-title">Fonts</h4>
131
+ ${vars.map(
132
+ ([name, val]) => html`
133
+ <div class="css-var-row">
134
+ <span class="css-var-name">${varDisplayName(name, "--font-")}</span>
135
+ <sp-textfield
136
+ size="s"
137
+ .value=${String(val)}
138
+ @change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
139
+ style="flex:1"
140
+ ></sp-textfield>
141
+ <sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
142
+ <sp-icon-delete slot="icon"></sp-icon-delete>
143
+ </sp-action-button>
144
+ </div>
145
+ <div class="css-var-font-preview" style="font-family:${val}">
146
+ The quick brown fox jumps over the lazy dog
147
+ </div>
148
+ `,
149
+ )}
150
+ ${renderAddRow("--font-", "Body Serif", "'Georgia', serif", addVar)}
151
+ </div>
152
+ `;
153
+ }
154
+
155
+ /**
156
+ * @param {[string, any][]} vars
157
+ * @param {Function} updateVar
158
+ * @param {Function} deleteVar
159
+ * @param {Function} addVar
160
+ * @param {any} rootStyle
161
+ * @param {string[]} mediaNames
162
+ */
163
+ function renderSizeSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
164
+ return html`
165
+ <div class="css-vars-group">
166
+ <h4 class="css-vars-group-title">Sizes &amp; Spacing</h4>
167
+ ${vars.map(
168
+ ([name, val]) => html`
169
+ <div class="css-var-row">
170
+ <span class="css-var-name"
171
+ >${varDisplayName(name, "--size-") ||
172
+ varDisplayName(name, "--spacing-") ||
173
+ varDisplayName(name, "--radius-") ||
174
+ name}</span
175
+ >
176
+ <sp-textfield
177
+ size="s"
178
+ .value=${String(val)}
179
+ @change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
180
+ style="max-width:120px"
181
+ ></sp-textfield>
182
+ <sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
183
+ <sp-icon-delete slot="icon"></sp-icon-delete>
184
+ </sp-action-button>
185
+ </div>
186
+ ${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
187
+ `,
188
+ )}
189
+ ${renderAddRow("--size-", "Spacing Large", "32px", addVar)}
190
+ </div>
191
+ `;
192
+ }
193
+
194
+ /**
195
+ * @param {[string, any][]} vars
196
+ * @param {Function} updateVar
197
+ * @param {Function} deleteVar
198
+ * @param {Function} addVar
199
+ * @param {any} rootStyle
200
+ * @param {string[]} mediaNames
201
+ */
202
+ function renderOtherSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
203
+ return html`
204
+ <div class="css-vars-group">
205
+ <h4 class="css-vars-group-title">Other</h4>
206
+ ${vars.map(
207
+ ([name, val]) => html`
208
+ <div class="css-var-row">
209
+ <span class="css-var-name">${name}</span>
210
+ <sp-textfield
211
+ size="s"
212
+ .value=${String(val)}
213
+ @change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
214
+ style="flex:1"
215
+ ></sp-textfield>
216
+ <sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
217
+ <sp-icon-delete slot="icon"></sp-icon-delete>
218
+ </sp-action-button>
219
+ </div>
220
+ ${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
221
+ `,
222
+ )}
223
+ ${renderAddRow("--", "Custom Var", "value", addVar)}
224
+ </div>
225
+ `;
226
+ }
227
+
228
+ /**
229
+ * @param {string} varName
230
+ * @param {any} rootStyle
231
+ * @param {string[]} mediaNames
232
+ */
233
+ function renderMediaOverrides(varName, rootStyle, mediaNames) {
234
+ const overrides = [];
235
+ for (const mediaName of mediaNames) {
236
+ const mediaKey = `@${mediaName}`;
237
+ const mediaBlock = rootStyle[mediaKey];
238
+ if (mediaBlock && typeof mediaBlock === "object" && mediaBlock[varName]) {
239
+ overrides.push({ mediaName, value: mediaBlock[varName] });
240
+ }
241
+ }
242
+
243
+ if (overrides.length === 0) return nothing;
244
+
245
+ return html`
246
+ <div class="css-var-media-overrides">
247
+ ${overrides.map(
248
+ (o) => html`
249
+ <div class="css-var-media-row">
250
+ <span class="css-var-media-label">@${o.mediaName}</span>
251
+ <sp-textfield
252
+ size="s"
253
+ .value=${String(o.value)}
254
+ @change=${(/** @type {any} */ e) => {
255
+ if (!rootStyle[`@${o.mediaName}`]) rootStyle[`@${o.mediaName}`] = {};
256
+ rootStyle[`@${o.mediaName}`][varName] = e.target.value;
257
+ updateSiteConfig({ style: { ...rootStyle } });
258
+ }}
259
+ style="max-width:120px"
260
+ ></sp-textfield>
261
+ </div>
262
+ `,
263
+ )}
264
+ </div>
265
+ `;
266
+ }
267
+
268
+ /**
269
+ * @param {string} prefix
270
+ * @param {string} placeholder
271
+ * @param {string} valuePlaceholder
272
+ * @param {Function} addVar
273
+ */
274
+ function renderAddRow(prefix, placeholder, valuePlaceholder, addVar) {
275
+ /** @type {any} */
276
+ let nameEl = null;
277
+ /** @type {any} */
278
+ let valEl = null;
279
+
280
+ return html`
281
+ <div class="css-var-add-row">
282
+ <sp-textfield
283
+ size="s"
284
+ placeholder=${placeholder}
285
+ ${ref((el) => {
286
+ if (el) nameEl = el;
287
+ })}
288
+ ></sp-textfield>
289
+ <sp-textfield
290
+ size="s"
291
+ placeholder=${valuePlaceholder}
292
+ ${ref((el) => {
293
+ if (el) valEl = el;
294
+ })}
295
+ ></sp-textfield>
296
+ <sp-action-button
297
+ size="s"
298
+ @click=${() => {
299
+ if (nameEl && valEl) {
300
+ addVar(prefix, nameEl.value, valEl.value);
301
+ }
302
+ }}
303
+ >Add</sp-action-button
304
+ >
305
+ </div>
306
+ `;
307
+ }
@@ -0,0 +1,94 @@
1
+ /** General settings section — favicon, platform adapter, and other site-wide config. */
2
+
3
+ import { html, render as litRender, nothing } from "lit-html";
4
+ import { projectState } from "../store.js";
5
+ import { updateSiteConfig } from "../site-context.js";
6
+ import { getPlatform } from "../platform.js";
7
+ import { openFileInTab } from "../files/files.js";
8
+ import { closeSettingsModal } from "./settings-modal.js";
9
+
10
+ /** @param {HTMLElement} container */
11
+ export function renderGeneralSettings(container) {
12
+ const config = projectState.projectConfig || {};
13
+
14
+ const onFaviconUpload = async () => {
15
+ const input = document.createElement("input");
16
+ input.type = "file";
17
+ input.accept = "image/*,.ico,.svg";
18
+ input.onchange = async () => {
19
+ const file = input.files?.[0];
20
+ if (!file) return;
21
+ const platform = getPlatform();
22
+ await platform.uploadFile("public/favicon.ico", file);
23
+ await updateSiteConfig({ favicon: "/favicon.ico" });
24
+ renderGeneralSettings(container);
25
+ };
26
+ input.click();
27
+ };
28
+
29
+ const onAdapterChange = (/** @type {any} */ e) => {
30
+ updateSiteConfig({ adapter: e.target.value });
31
+ };
32
+
33
+ const onEditGlobalStyles = () => {
34
+ closeSettingsModal();
35
+ openFileInTab("project.json");
36
+ };
37
+
38
+ const currentFavicon = config.favicon;
39
+
40
+ const tpl = html`
41
+ <div class="settings-section">
42
+ <h3 class="settings-section-title">General</h3>
43
+
44
+ <div class="settings-field">
45
+ <label class="settings-field-label">Favicon</label>
46
+ <p class="settings-field-desc">Upload an image to use as the site favicon.</p>
47
+ <div style="display:flex;align-items:center;gap:12px">
48
+ ${currentFavicon
49
+ ? html`<img
50
+ src=${currentFavicon}
51
+ alt="Current favicon"
52
+ style="width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:2px"
53
+ />`
54
+ : html`<div
55
+ style="width:32px;height:32px;border:1px dashed var(--border);border-radius:4px;display:flex;align-items:center;justify-content:center;color:var(--fg-dim);font-size:11px"
56
+ >
57
+
58
+ </div>`}
59
+ <sp-action-button size="s" @click=${onFaviconUpload}> Upload Favicon </sp-action-button>
60
+ ${currentFavicon
61
+ ? html`<span style="font-size:11px;color:var(--fg-dim)">${currentFavicon}</span>`
62
+ : nothing}
63
+ </div>
64
+ </div>
65
+
66
+ <div class="settings-field">
67
+ <label class="settings-field-label">Platform Adapter</label>
68
+ <p class="settings-field-desc">Build adapter for deployment target.</p>
69
+ <sp-picker
70
+ size="s"
71
+ label="Platform Adapter"
72
+ .value=${config.adapter || "static"}
73
+ @change=${onAdapterChange}
74
+ >
75
+ <sp-menu-item value="static">Static</sp-menu-item>
76
+ <sp-menu-item value="bun">Bun</sp-menu-item>
77
+ <sp-menu-item value="node">Node</sp-menu-item>
78
+ <sp-menu-item value="cloudflare-workers">Cloudflare Workers</sp-menu-item>
79
+ <sp-menu-item value="cloudflare-pages">Cloudflare Pages</sp-menu-item>
80
+ </sp-picker>
81
+ </div>
82
+
83
+ <div class="settings-field">
84
+ <label class="settings-field-label">Global Styles</label>
85
+ <p class="settings-field-desc">Edit default element styles that apply across all pages.</p>
86
+ <sp-action-button size="s" @click=${onEditGlobalStyles}>
87
+ Edit Global Styles
88
+ </sp-action-button>
89
+ </div>
90
+ </div>
91
+ `;
92
+
93
+ litRender(tpl, container);
94
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Head editor — structured form for managing $head entries (link, meta, script, style tags) with
3
+ * Monaco editor for script/style bodies.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { projectState } from "../store.js";
8
+ import { updateSiteConfig } from "../site-context.js";
9
+
10
+ /** @param {HTMLElement} container */
11
+ export function renderHeadEditor(container) {
12
+ const config = projectState.projectConfig || {};
13
+ const headEntries = config.$head || [];
14
+
15
+ const save = () => {
16
+ updateSiteConfig({ $head: headEntries });
17
+ };
18
+
19
+ const addEntry = (/** @type {string} */ tag) => {
20
+ /** @type {any} */
21
+ const entry = { tag };
22
+ if (tag === "link") {
23
+ entry.rel = "stylesheet";
24
+ entry.href = "";
25
+ } else if (tag === "meta") {
26
+ entry.name = "";
27
+ entry.content = "";
28
+ } else if (tag === "script") {
29
+ entry.src = "";
30
+ entry.body = "";
31
+ } else if (tag === "style") {
32
+ entry.body = "";
33
+ }
34
+ headEntries.push(entry);
35
+ save();
36
+ renderHeadEditor(container);
37
+ };
38
+
39
+ const removeEntry = (/** @type {number} */ idx) => {
40
+ headEntries.splice(idx, 1);
41
+ save();
42
+ renderHeadEditor(container);
43
+ };
44
+
45
+ const updateEntry = (
46
+ /** @type {number} */ idx,
47
+ /** @type {string} */ key,
48
+ /** @type {string} */ val,
49
+ ) => {
50
+ headEntries[idx][key] = val;
51
+ save();
52
+ };
53
+
54
+ const tpl = html`
55
+ <div class="settings-section">
56
+ <h3 class="settings-section-title">Head</h3>
57
+ <p class="settings-field-desc">
58
+ Manage global &lt;head&gt; tags — stylesheets, meta tags, scripts, and inline styles.
59
+ </p>
60
+
61
+ <div class="head-entries">
62
+ ${headEntries.map(
63
+ (/** @type {any} */ entry, /** @type {number} */ idx) => html`
64
+ <div class="head-entry">
65
+ <div class="head-entry-header">
66
+ <span class="head-entry-tag">&lt;${entry.tag}&gt;</span>
67
+ <sp-action-button quiet size="s" @click=${() => removeEntry(idx)}>
68
+ <sp-icon-delete slot="icon"></sp-icon-delete>
69
+ </sp-action-button>
70
+ </div>
71
+ <div class="head-entry-fields">${renderEntryFields(entry, idx, updateEntry)}</div>
72
+ </div>
73
+ `,
74
+ )}
75
+ </div>
76
+
77
+ <div class="head-add-actions" style="margin-top:12px;display:flex;gap:8px">
78
+ <sp-action-button size="s" @click=${() => addEntry("link")}> + Link </sp-action-button>
79
+ <sp-action-button size="s" @click=${() => addEntry("meta")}> + Meta </sp-action-button>
80
+ <sp-action-button size="s" @click=${() => addEntry("script")}> + Script </sp-action-button>
81
+ <sp-action-button size="s" @click=${() => addEntry("style")}> + Style </sp-action-button>
82
+ </div>
83
+ </div>
84
+ `;
85
+
86
+ litRender(tpl, container);
87
+ }
88
+
89
+ /**
90
+ * @param {any} entry
91
+ * @param {number} idx
92
+ * @param {(idx: number, key: string, val: string) => void} updateEntry
93
+ */
94
+ function renderEntryFields(entry, idx, updateEntry) {
95
+ /** @type {any} */
96
+ let debounce;
97
+ const onFieldChange = (/** @type {string} */ key) => (/** @type {any} */ e) => {
98
+ clearTimeout(debounce);
99
+ debounce = setTimeout(() => {
100
+ updateEntry(idx, key, e.target.value);
101
+ }, 300);
102
+ };
103
+
104
+ switch (entry.tag) {
105
+ case "link":
106
+ return html`
107
+ <div class="settings-field-row">
108
+ <sp-textfield
109
+ size="s"
110
+ label="rel"
111
+ .value=${entry.rel || ""}
112
+ @change=${onFieldChange("rel")}
113
+ ></sp-textfield>
114
+ <sp-textfield
115
+ size="s"
116
+ label="href"
117
+ .value=${entry.href || ""}
118
+ @change=${onFieldChange("href")}
119
+ style="flex:1"
120
+ ></sp-textfield>
121
+ </div>
122
+ `;
123
+ case "meta":
124
+ return html`
125
+ <div class="settings-field-row">
126
+ <sp-textfield
127
+ size="s"
128
+ label="name"
129
+ .value=${entry.name || ""}
130
+ @change=${onFieldChange("name")}
131
+ ></sp-textfield>
132
+ <sp-textfield
133
+ size="s"
134
+ label="content"
135
+ .value=${entry.content || ""}
136
+ @change=${onFieldChange("content")}
137
+ style="flex:1"
138
+ ></sp-textfield>
139
+ </div>
140
+ `;
141
+ case "script":
142
+ return html`
143
+ <div class="settings-field-row">
144
+ <sp-textfield
145
+ size="s"
146
+ label="src"
147
+ .value=${entry.src || ""}
148
+ @change=${onFieldChange("src")}
149
+ placeholder="URL or leave empty for inline"
150
+ style="flex:1"
151
+ ></sp-textfield>
152
+ </div>
153
+ ${!entry.src
154
+ ? html`
155
+ <div class="head-entry-body">
156
+ <label class="settings-field-label">Script body</label>
157
+ <textarea
158
+ class="head-code-editor"
159
+ .value=${entry.body || ""}
160
+ @input=${onFieldChange("body")}
161
+ rows="6"
162
+ spellcheck="false"
163
+ ></textarea>
164
+ </div>
165
+ `
166
+ : nothing}
167
+ `;
168
+ case "style":
169
+ return html`
170
+ <div class="head-entry-body">
171
+ <label class="settings-field-label">Style body</label>
172
+ <textarea
173
+ class="head-code-editor"
174
+ .value=${entry.body || ""}
175
+ @input=${onFieldChange("body")}
176
+ rows="8"
177
+ spellcheck="false"
178
+ ></textarea>
179
+ </div>
180
+ `;
181
+ default:
182
+ return nothing;
183
+ }
184
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Settings modal — site-wide project settings (CSS variables, definitions, content types, head,
3
+ * general). Modeled after VS Code / Obsidian settings panels: left sidebar nav + right content
4
+ * area.
5
+ */
6
+
7
+ import { html, render as litRender } from "lit-html";
8
+ import { ref } from "lit-html/directives/ref.js";
9
+ import { renderDefsEditor } from "./defs-editor.js";
10
+ import { renderContentTypesEditor } from "./content-types-editor.js";
11
+ import { renderCssVarsEditor } from "./css-vars-editor.js";
12
+ import { renderHeadEditor } from "./head-editor.js";
13
+ import { renderGeneralSettings } from "./general-settings.js";
14
+
15
+ /** @type {HTMLElement | null} */
16
+ let _host = null;
17
+
18
+ /** @type {string} */
19
+ let _activeSection = "general";
20
+
21
+ const sections = [
22
+ { key: "general", label: "General", icon: "sp-icon-properties" },
23
+ { key: "head", label: "Head", icon: "sp-icon-file-single-web-page" },
24
+ { key: "cssVars", label: "CSS Variables", icon: "sp-icon-brush" },
25
+ { key: "definitions", label: "Definitions", icon: "sp-icon-data" },
26
+ { key: "contentTypes", label: "Content Types", icon: "sp-icon-view-grid" },
27
+ ];
28
+
29
+ export function openSettingsModal() {
30
+ if (_host) return;
31
+
32
+ _host = document.createElement("div");
33
+ _host.style.display = "contents";
34
+ const themeRoot = document.querySelector("sp-theme") || document.body;
35
+ themeRoot.appendChild(_host);
36
+ _activeSection = "general";
37
+ renderModal();
38
+ }
39
+
40
+ export function closeSettingsModal() {
41
+ if (!_host) return;
42
+ _host.remove();
43
+ _host = null;
44
+ }
45
+
46
+ function renderModal() {
47
+ if (!_host) return;
48
+
49
+ const onNavClick = (/** @type {string} */ key) => {
50
+ _activeSection = key;
51
+ renderModal();
52
+ renderActiveSection();
53
+ };
54
+
55
+ const tpl = html`
56
+ <sp-underlay open @close=${closeSettingsModal}></sp-underlay>
57
+ <div
58
+ class="settings-modal"
59
+ @keydown=${(/** @type {KeyboardEvent} */ e) => {
60
+ if (e.key === "Escape") closeSettingsModal();
61
+ }}
62
+ >
63
+ <div class="settings-modal-header">
64
+ <h2 class="settings-modal-title">Settings</h2>
65
+ <sp-action-button quiet size="s" @click=${closeSettingsModal} title="Close">
66
+ <sp-icon-close slot="icon"></sp-icon-close>
67
+ </sp-action-button>
68
+ </div>
69
+ <div class="settings-modal-body">
70
+ <nav class="settings-modal-nav">
71
+ ${sections.map(
72
+ (s) => html`
73
+ <button
74
+ class="settings-nav-item${_activeSection === s.key ? " active" : ""}"
75
+ @click=${() => onNavClick(s.key)}
76
+ >
77
+ ${s.label}
78
+ </button>
79
+ `,
80
+ )}
81
+ </nav>
82
+ <div
83
+ class="settings-modal-content"
84
+ ${ref((el) => {
85
+ if (el) requestAnimationFrame(() => renderActiveSection());
86
+ })}
87
+ ></div>
88
+ </div>
89
+ </div>
90
+ `;
91
+
92
+ litRender(tpl, _host);
93
+ }
94
+
95
+ function renderActiveSection() {
96
+ if (!_host) return;
97
+ const container = _host.querySelector(".settings-modal-content");
98
+ if (!container) return;
99
+
100
+ switch (_activeSection) {
101
+ case "general":
102
+ renderGeneralSettings(/** @type {HTMLElement} */ (container));
103
+ break;
104
+ case "head":
105
+ renderHeadEditor(/** @type {HTMLElement} */ (container));
106
+ break;
107
+ case "cssVars":
108
+ renderCssVarsEditor(/** @type {HTMLElement} */ (container));
109
+ break;
110
+ case "definitions":
111
+ renderDefsEditor(/** @type {HTMLElement} */ (container));
112
+ break;
113
+ case "contentTypes":
114
+ renderContentTypesEditor(/** @type {HTMLElement} */ (container));
115
+ break;
116
+ }
117
+ }