starlight-theme-bejamas 0.1.18 → 0.2.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
@@ -1,5 +1,15 @@
1
1
  # starlight-theme-bejamas
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#102](https://github.com/bejamas/ui/pull/102) [`25987b8`](https://github.com/bejamas/ui/commit/25987b872d38280dfe47f820019f86c1cb00eb6d) Thanks [@thomkrupa](https://github.com/thomkrupa)! - Improve the Starlight Bejamas theme runtime and expose the shared theme-choice helpers for consumers that need to integrate with theme state directly.
8
+ - Unify light, dark, and auto theme choice handling across the theme picker, page chrome, and preview surfaces so theme state stays in sync more reliably.
9
+ - Refresh browser theme-color metadata and the active theme stylesheet more consistently when theme state or presets change.
10
+ - Sync semantic icon updates as part of the theme runtime so icon-library changes propagate cleanly with preset-driven theme updates.
11
+ - Export `starlight-theme-bejamas/lib/theme-choice` as a public entrypoint for consumers that want to read, apply, or react to the shared theme-choice state.
12
+
3
13
  ## 0.1.18
4
14
 
5
15
  ### Patch Changes
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "starlight-theme-bejamas",
3
3
  "author": "Bejamas",
4
- "version": "0.1.18",
4
+ "version": "0.2.0",
5
5
  "license": "MIT",
6
6
  "description": "A Starlight theme using bejamas/ui",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./config": "./src/config.ts",
11
+ "./lib/theme-choice": "./src/lib/theme-choice.ts",
11
12
  "./styles/theme.css": "./src/styles/theme.css",
12
13
  "./overrides/Header.astro": "./src/overrides/Header.astro",
13
14
  "./overrides/PageFrame.astro": "./src/overrides/PageFrame.astro",
@@ -41,6 +42,7 @@
41
42
  "astro": ">=5.5.0"
42
43
  },
43
44
  "dependencies": {
45
+ "@bejamas/semantic-icons": "workspace:*",
44
46
  "@expressive-code/plugin-line-numbers": "^0.41.3",
45
47
  "@fontsource-variable/inter": "^5.2.8",
46
48
  "@fontsource/inter": "^5.2.8",
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { StarlightThemeBejamasConfigSchema } from "./config";
4
+
5
+ describe("starlight-theme-bejamas config schema", () => {
6
+ test("parses nav and component maps under zod v4", () => {
7
+ const result = StarlightThemeBejamasConfigSchema.safeParse({
8
+ nav: [
9
+ {
10
+ label: {
11
+ en: "Docs",
12
+ fr: "Documentation",
13
+ },
14
+ href: "/docs/introduction",
15
+ attrs: {
16
+ rel: "prefetch",
17
+ "data-track": "docs-link",
18
+ },
19
+ },
20
+ ],
21
+ components: {
22
+ ThemeSelect: "./src/components/ThemeSwitcher.astro",
23
+ PageTitle: "./src/components/PageTitle.astro",
24
+ },
25
+ });
26
+
27
+ expect(result.success).toBe(true);
28
+ if (!result.success) {
29
+ throw result.error;
30
+ }
31
+
32
+ expect(result.data.nav?.[0]?.label).toEqual({
33
+ en: "Docs",
34
+ fr: "Documentation",
35
+ });
36
+ expect(result.data.components?.ThemeSelect).toBe(
37
+ "./src/components/ThemeSwitcher.astro",
38
+ );
39
+ });
40
+ });
package/src/config.ts CHANGED
@@ -3,7 +3,8 @@ import type { HTMLAttributes } from "astro/types";
3
3
  import { z } from "astro/zod";
4
4
 
5
5
  const linkHTMLAttributesSchema = z.record(
6
- z.union([z.string(), z.number(), z.boolean(), z.undefined()])
6
+ z.string(),
7
+ z.union([z.string(), z.number(), z.boolean(), z.undefined()]),
7
8
  ) as z.Schema<
8
9
  Omit<HTMLAttributes<"a">, keyof AstroBuiltinAttributes | "children">
9
10
  >;
@@ -25,7 +26,7 @@ const navLinkSchema = z.object({
25
26
  * The value can be a string, or for multilingual sites, an object with values for each different locale. When using
26
27
  * the object form, the keys must be BCP-47 tags (e.g. en, fr, or zh-CN).
27
28
  */
28
- label: z.union([z.string(), z.record(z.string())]),
29
+ label: z.union([z.string(), z.record(z.string(), z.string())]),
29
30
  /**
30
31
  * The link to the topic’s content which an be a relative link to local files or the full URL of an external page.
31
32
  *
@@ -39,7 +40,7 @@ const navLinkSchema = z.object({
39
40
 
40
41
  export const StarlightThemeBejamasConfigSchema = z.object({
41
42
  nav: z.array(navLinkSchema).optional(),
42
- components: z.record(z.string()).optional(),
43
+ components: z.record(z.string(), z.string()).optional(),
43
44
  });
44
45
 
45
46
  export type StarlightThemeBejamasUserConfig = z.input<
package/src/global.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  declare global {
2
2
  interface Window {
3
3
  StarlightThemeProvider?: {
4
- updatePickers: (theme?: string) => void;
4
+ refreshThemeColors?: (theme?: string) => void;
5
+ updatePickers?: (theme?: string) => void;
5
6
  };
6
7
  }
7
8
 
8
9
  const StarlightThemeProvider: {
9
- updatePickers: (theme?: string) => void;
10
+ refreshThemeColors?: (theme?: string) => void;
11
+ updatePickers?: (theme?: string) => void;
10
12
  };
11
13
 
12
14
  namespace App {
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ STARLIGHT_THEME_STORAGE_KEY,
4
+ THEME_TOGGLE_CHANGED_EVENT,
5
+ applyThemeModeToRoot,
6
+ buildThemeChoiceBootstrapInlineScript,
7
+ getThemeChoiceFromRoot,
8
+ getThemeModeFromRoot,
9
+ loadStoredThemeChoice,
10
+ parseStoredThemeChoice,
11
+ resolveThemeMode,
12
+ setThemeChoice,
13
+ storeThemeChoice,
14
+ syncThemeChoiceControls,
15
+ } from "./theme-choice";
16
+
17
+ function createThemeRoot() {
18
+ const classes = new Set<string>();
19
+
20
+ return {
21
+ classes,
22
+ root: {
23
+ dataset: {} as Record<string, string | undefined>,
24
+ classList: {
25
+ add: (...tokens: string[]) => {
26
+ for (const token of tokens) {
27
+ classes.add(token);
28
+ }
29
+ },
30
+ remove: (...tokens: string[]) => {
31
+ for (const token of tokens) {
32
+ classes.delete(token);
33
+ }
34
+ },
35
+ contains: (token: string) => classes.has(token),
36
+ },
37
+ },
38
+ };
39
+ }
40
+
41
+ describe("theme choice runtime", () => {
42
+ it("parses only supported stored theme choices", () => {
43
+ expect(parseStoredThemeChoice("light")).toBe("light");
44
+ expect(parseStoredThemeChoice("dark")).toBe("dark");
45
+ expect(parseStoredThemeChoice("auto")).toBe("auto");
46
+ expect(parseStoredThemeChoice("")).toBe("auto");
47
+ expect(parseStoredThemeChoice("system")).toBe("auto");
48
+ expect(parseStoredThemeChoice(undefined)).toBe("auto");
49
+ });
50
+
51
+ it("loads the stored theme choice from localStorage-compatible storage", () => {
52
+ expect(
53
+ loadStoredThemeChoice({
54
+ getItem: (key) =>
55
+ key === STARLIGHT_THEME_STORAGE_KEY ? "dark" : null,
56
+ }),
57
+ ).toBe("dark");
58
+
59
+ expect(
60
+ loadStoredThemeChoice({
61
+ getItem: () => "unexpected",
62
+ }),
63
+ ).toBe("auto");
64
+
65
+ expect(
66
+ loadStoredThemeChoice({
67
+ getItem: () => {
68
+ throw new Error("storage unavailable");
69
+ },
70
+ }),
71
+ ).toBe("auto");
72
+ });
73
+
74
+ it("resolves auto against the preferred color scheme", () => {
75
+ expect(resolveThemeMode("light", false)).toBe("light");
76
+ expect(resolveThemeMode("dark", true)).toBe("dark");
77
+ expect(resolveThemeMode("auto", true)).toBe("light");
78
+ expect(resolveThemeMode("auto", false)).toBe("dark");
79
+ });
80
+
81
+ it("applies theme attributes and class state to the target root", () => {
82
+ const { root, classes } = createThemeRoot();
83
+
84
+ classes.add("dark");
85
+ applyThemeModeToRoot(root, "auto", "light");
86
+
87
+ expect(root.dataset.theme).toBe("light");
88
+ expect(root.dataset.themeChoice).toBe("auto");
89
+ expect(classes.has("light")).toBe(true);
90
+ expect(classes.has("dark")).toBe(false);
91
+ });
92
+
93
+ it("stores explicit theme choices and clears auto to the empty localStorage value", () => {
94
+ const stored = new Map<string, string>();
95
+ const storage = {
96
+ setItem: (key: string, value: string) => {
97
+ stored.set(key, value);
98
+ },
99
+ };
100
+
101
+ storeThemeChoice(storage, "dark");
102
+ storeThemeChoice(storage, "auto");
103
+
104
+ expect(stored.get(STARLIGHT_THEME_STORAGE_KEY)).toBe("");
105
+ });
106
+
107
+ it("reads theme choice and effective mode from the document root", () => {
108
+ const { root, classes } = createThemeRoot();
109
+
110
+ root.dataset.themeChoice = "auto";
111
+ root.dataset.theme = "";
112
+ classes.add("dark");
113
+
114
+ expect(getThemeChoiceFromRoot(root)).toBe("auto");
115
+ expect(getThemeModeFromRoot(root, "light")).toBe("dark");
116
+ });
117
+
118
+ it("syncs tabs-based and legacy select-based controls to the requested choice", () => {
119
+ let tabsValue = "";
120
+ let selectValue = "";
121
+ const nativeSelect = { value: "" };
122
+ const tabsRoot = {
123
+ dataset: {} as Record<string, string | undefined>,
124
+ dispatchEvent: (event: Event) => {
125
+ tabsValue =
126
+ (event as CustomEvent<{ value?: string }>).detail?.value ?? "";
127
+ return true;
128
+ },
129
+ };
130
+ const selectRoot = {
131
+ dataset: {} as Record<string, string | undefined>,
132
+ dispatchEvent: (event: Event) => {
133
+ selectValue =
134
+ (event as CustomEvent<{ value?: string }>).detail?.value ?? "";
135
+ return true;
136
+ },
137
+ };
138
+ const picker = {
139
+ querySelector: (selector: string) => {
140
+ if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
141
+ return tabsRoot;
142
+ }
143
+
144
+ if (selector === "select") {
145
+ return nativeSelect;
146
+ }
147
+
148
+ if (selector === '#starlight-theme-select, [data-slot="select"]') {
149
+ return selectRoot;
150
+ }
151
+
152
+ return null;
153
+ },
154
+ querySelectorAll: () => [] as unknown as NodeListOf<Element>,
155
+ };
156
+ const root = {
157
+ querySelectorAll: (selector: string) => {
158
+ if (selector === "starlight-theme-select") {
159
+ return [picker] as unknown as NodeListOf<Element>;
160
+ }
161
+
162
+ if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
163
+ return [tabsRoot] as unknown as NodeListOf<Element>;
164
+ }
165
+
166
+ return [] as unknown as NodeListOf<Element>;
167
+ },
168
+ };
169
+
170
+ syncThemeChoiceControls("light", root as unknown as ParentNode);
171
+
172
+ expect(tabsRoot.dataset.defaultValue).toBe("light");
173
+ expect(tabsRoot.dataset.value).toBe("light");
174
+ expect(tabsValue).toBe("light");
175
+ expect(nativeSelect.value).toBe("light");
176
+ expect(selectRoot.dataset.defaultValue).toBe("light");
177
+ expect(selectRoot.dataset.value).toBe("light");
178
+ expect(selectValue).toBe("light");
179
+ });
180
+
181
+ it("applies the shared global theme choice path including storage, controls, and event dispatch", () => {
182
+ const { root, classes } = createThemeRoot();
183
+ const stored = new Map<string, string>();
184
+ const dispatched: Array<{ theme: string; themeChoice: string }> = [];
185
+ let tabsValue = "";
186
+ const tabsRoot = {
187
+ dataset: {} as Record<string, string | undefined>,
188
+ dispatchEvent: (event: Event) => {
189
+ tabsValue =
190
+ (event as CustomEvent<{ value?: string }>).detail?.value ?? "";
191
+ return true;
192
+ },
193
+ };
194
+ const controlsRoot = {
195
+ querySelectorAll: (selector: string) => {
196
+ if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
197
+ return [tabsRoot] as unknown as NodeListOf<Element>;
198
+ }
199
+
200
+ return [] as unknown as NodeListOf<Element>;
201
+ },
202
+ };
203
+ const dispatchTarget = {
204
+ dispatchEvent: (event: Event) => {
205
+ const customEvent = event as CustomEvent<{
206
+ theme?: string;
207
+ themeChoice?: string;
208
+ }>;
209
+
210
+ if (event.type === THEME_TOGGLE_CHANGED_EVENT) {
211
+ dispatched.push({
212
+ theme: customEvent.detail?.theme ?? "",
213
+ themeChoice: customEvent.detail?.themeChoice ?? "",
214
+ });
215
+ }
216
+
217
+ return true;
218
+ },
219
+ };
220
+
221
+ const effectiveTheme = setThemeChoice("dark", {
222
+ root,
223
+ prefersLight: true,
224
+ storage: {
225
+ setItem: (key, value) => {
226
+ stored.set(key, value);
227
+ },
228
+ },
229
+ controlsRoot: controlsRoot as unknown as ParentNode,
230
+ dispatchTarget: dispatchTarget as unknown as EventTarget,
231
+ });
232
+
233
+ expect(effectiveTheme).toBe("dark");
234
+ expect(root.dataset.themeChoice).toBe("dark");
235
+ expect(root.dataset.theme).toBe("dark");
236
+ expect(classes.has("dark")).toBe(true);
237
+ expect(stored.get(STARLIGHT_THEME_STORAGE_KEY)).toBe("dark");
238
+ expect(tabsValue).toBe("dark");
239
+ expect(dispatched).toEqual([{ theme: "dark", themeChoice: "dark" }]);
240
+ });
241
+
242
+ it("builds an inline bootstrap script that owns root state and control sync", () => {
243
+ const script = buildThemeChoiceBootstrapInlineScript();
244
+
245
+ expect(script).toContain(STARLIGHT_THEME_STORAGE_KEY);
246
+ expect(script).toContain("dataset.themeChoice");
247
+ expect(script).toContain('[data-slot="tabs"][data-theme-choice-tabs]');
248
+ expect(script).toContain(THEME_TOGGLE_CHANGED_EVENT);
249
+ expect(script).toContain("prefers-color-scheme: light");
250
+ });
251
+ });
@@ -0,0 +1,346 @@
1
+ export const STARLIGHT_THEME_STORAGE_KEY = "starlight-theme";
2
+ export const THEME_TOGGLE_CHANGED_EVENT = "theme-toggle-changed";
3
+
4
+ export type ThemeChoice = "auto" | "dark" | "light";
5
+ export type ThemeMode = "dark" | "light";
6
+
7
+ export interface ThemeRoot {
8
+ dataset: Record<string, string | undefined>;
9
+ classList: {
10
+ add: (...tokens: string[]) => void;
11
+ remove: (...tokens: string[]) => void;
12
+ };
13
+ }
14
+
15
+ type ThemeClassList = ThemeRoot["classList"] & {
16
+ contains?: (token: string) => boolean;
17
+ };
18
+
19
+ type ThemeRootElement = ThemeRoot & {
20
+ classList: ThemeClassList;
21
+ };
22
+
23
+ type ThemeStorageReader = Pick<Storage, "getItem"> | null | undefined;
24
+ type ThemeStorageWriter = Pick<Storage, "setItem"> | null | undefined;
25
+
26
+ const THEME_CHOICE_TAB_SELECTOR = '[data-slot="tabs"][data-theme-choice-tabs]';
27
+
28
+ function getElementsWithSelf<T extends Element>(
29
+ root: ParentNode,
30
+ selector: string,
31
+ ): T[] {
32
+ const matches: T[] = [];
33
+
34
+ if (
35
+ typeof Element !== "undefined" &&
36
+ root instanceof Element &&
37
+ root.matches(selector)
38
+ ) {
39
+ matches.push(root as T);
40
+ }
41
+
42
+ matches.push(...Array.from(root.querySelectorAll<T>(selector)));
43
+
44
+ return matches;
45
+ }
46
+
47
+ export function parseStoredThemeChoice(value: unknown): ThemeChoice {
48
+ return value === "auto" || value === "dark" || value === "light"
49
+ ? value
50
+ : "auto";
51
+ }
52
+
53
+ export function loadStoredThemeChoice(storage: ThemeStorageReader): ThemeChoice {
54
+ try {
55
+ return parseStoredThemeChoice(
56
+ storage?.getItem(STARLIGHT_THEME_STORAGE_KEY),
57
+ );
58
+ } catch {
59
+ return "auto";
60
+ }
61
+ }
62
+
63
+ export function resolveThemeMode(
64
+ themeChoice: ThemeChoice,
65
+ prefersLight: boolean,
66
+ ): ThemeMode {
67
+ if (themeChoice === "light" || themeChoice === "dark") {
68
+ return themeChoice;
69
+ }
70
+
71
+ return prefersLight ? "light" : "dark";
72
+ }
73
+
74
+ export function applyThemeModeToRoot(
75
+ root: ThemeRoot,
76
+ themeChoice: ThemeChoice,
77
+ themeMode: ThemeMode,
78
+ ): void {
79
+ root.dataset.theme = themeMode;
80
+ root.dataset.themeChoice = themeChoice;
81
+ root.classList.remove("light", "dark");
82
+ root.classList.add(themeMode);
83
+ }
84
+
85
+ export function storeThemeChoice(
86
+ storage: ThemeStorageWriter,
87
+ themeChoice: ThemeChoice,
88
+ ): void {
89
+ try {
90
+ storage?.setItem(
91
+ STARLIGHT_THEME_STORAGE_KEY,
92
+ themeChoice === "light" || themeChoice === "dark" ? themeChoice : "",
93
+ );
94
+ } catch {}
95
+ }
96
+
97
+ export function getThemeChoiceFromRoot(
98
+ root: Pick<ThemeRoot, "dataset">,
99
+ ): ThemeChoice {
100
+ return parseStoredThemeChoice(root.dataset.themeChoice);
101
+ }
102
+
103
+ export function getThemeModeFromRoot(
104
+ root: ThemeRootElement,
105
+ fallbackMode: ThemeMode,
106
+ ): ThemeMode {
107
+ if (root.dataset.theme === "light" || root.dataset.theme === "dark") {
108
+ return root.dataset.theme;
109
+ }
110
+
111
+ if (root.classList.contains?.("dark")) {
112
+ return "dark";
113
+ }
114
+
115
+ if (root.classList.contains?.("light")) {
116
+ return "light";
117
+ }
118
+
119
+ return fallbackMode;
120
+ }
121
+
122
+ function syncThemeChoiceTabControls(
123
+ themeChoice: ThemeChoice,
124
+ root: ParentNode,
125
+ ): void {
126
+ const tabsRoots = new Set<HTMLElement>();
127
+
128
+ for (const tabsRoot of getElementsWithSelf<HTMLElement>(
129
+ root,
130
+ THEME_CHOICE_TAB_SELECTOR,
131
+ )) {
132
+ tabsRoots.add(tabsRoot);
133
+ }
134
+
135
+ for (const picker of getElementsWithSelf<HTMLElement>(
136
+ root,
137
+ "starlight-theme-select",
138
+ )) {
139
+ const tabsRoot = picker.querySelector<HTMLElement>(THEME_CHOICE_TAB_SELECTOR);
140
+ if (tabsRoot) {
141
+ tabsRoots.add(tabsRoot);
142
+ }
143
+ }
144
+
145
+ for (const tabsRoot of tabsRoots) {
146
+ tabsRoot.dataset.defaultValue = themeChoice;
147
+ tabsRoot.dataset.value = themeChoice;
148
+ tabsRoot.dispatchEvent(
149
+ new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
150
+ );
151
+ }
152
+ }
153
+
154
+ function syncLegacyThemeChoiceSelectControls(
155
+ themeChoice: ThemeChoice,
156
+ root: ParentNode,
157
+ ): void {
158
+ for (const picker of getElementsWithSelf<HTMLElement>(
159
+ root,
160
+ "starlight-theme-select",
161
+ )) {
162
+ const nativeSelect = picker.querySelector<HTMLSelectElement>("select");
163
+ if (nativeSelect) {
164
+ nativeSelect.value = themeChoice;
165
+ }
166
+
167
+ const selectRoot = picker.querySelector<HTMLElement>(
168
+ '#starlight-theme-select, [data-slot="select"]',
169
+ );
170
+ if (!selectRoot) {
171
+ continue;
172
+ }
173
+
174
+ selectRoot.dataset.defaultValue = themeChoice;
175
+ selectRoot.dataset.value = themeChoice;
176
+ selectRoot.dispatchEvent(
177
+ new CustomEvent("select:set", { detail: { value: themeChoice } }),
178
+ );
179
+ }
180
+ }
181
+
182
+ export function syncThemeChoiceControls(
183
+ themeChoice: ThemeChoice,
184
+ root: ParentNode = document,
185
+ ): void {
186
+ syncThemeChoiceTabControls(themeChoice, root);
187
+ syncLegacyThemeChoiceSelectControls(themeChoice, root);
188
+ }
189
+
190
+ export function dispatchThemeToggleChange(
191
+ target: EventTarget,
192
+ themeMode: ThemeMode,
193
+ themeChoice: ThemeChoice,
194
+ ): void {
195
+ target.dispatchEvent(
196
+ new CustomEvent(THEME_TOGGLE_CHANGED_EVENT, {
197
+ detail: {
198
+ theme: themeMode,
199
+ themeChoice,
200
+ },
201
+ }),
202
+ );
203
+ }
204
+
205
+ export function setThemeChoice(
206
+ themeChoice: ThemeChoice,
207
+ options: {
208
+ storage?: ThemeStorageWriter;
209
+ root?: ThemeRootElement;
210
+ prefersLight?: boolean;
211
+ controlsRoot?: ParentNode;
212
+ dispatchTarget?: EventTarget;
213
+ emitEvent?: boolean;
214
+ } = {},
215
+ ): ThemeMode {
216
+ const root = options.root ?? (document.documentElement as HTMLElement);
217
+ const effectiveTheme = resolveThemeMode(
218
+ themeChoice,
219
+ options.prefersLight ??
220
+ window.matchMedia("(prefers-color-scheme: light)").matches,
221
+ );
222
+
223
+ applyThemeModeToRoot(root, themeChoice, effectiveTheme);
224
+ storeThemeChoice(options.storage ?? localStorage, themeChoice);
225
+ syncThemeChoiceControls(themeChoice, options.controlsRoot ?? document);
226
+
227
+ if (options.emitEvent !== false) {
228
+ dispatchThemeToggleChange(
229
+ options.dispatchTarget ?? window,
230
+ effectiveTheme,
231
+ themeChoice,
232
+ );
233
+ }
234
+
235
+ return effectiveTheme;
236
+ }
237
+
238
+ export function buildThemeChoiceBootstrapInlineScript(): string {
239
+ return `(function () {
240
+ const storageKey = ${JSON.stringify(STARLIGHT_THEME_STORAGE_KEY)};
241
+ const eventName = ${JSON.stringify(THEME_TOGGLE_CHANGED_EVENT)};
242
+ const parseStoredThemeChoice = (value) =>
243
+ value === "auto" || value === "dark" || value === "light" ? value : "auto";
244
+ const loadStoredThemeChoice = () => {
245
+ try {
246
+ return parseStoredThemeChoice(
247
+ typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
248
+ );
249
+ } catch {
250
+ return "auto";
251
+ }
252
+ };
253
+ const resolveThemeMode = (themeChoice, prefersLight) => {
254
+ if (themeChoice === "light" || themeChoice === "dark") {
255
+ return themeChoice;
256
+ }
257
+
258
+ return prefersLight ? "light" : "dark";
259
+ };
260
+ const applyThemeModeToRoot = (themeChoice) => {
261
+ const themeMode = resolveThemeMode(
262
+ themeChoice,
263
+ matchMedia("(prefers-color-scheme: light)").matches,
264
+ );
265
+ const root = document.documentElement;
266
+
267
+ root.dataset.theme = themeMode;
268
+ root.dataset.themeChoice = themeChoice;
269
+ root.classList.remove("light", "dark");
270
+ root.classList.add(themeMode);
271
+
272
+ return themeMode;
273
+ };
274
+ const syncThemeChoiceControls = (themeChoice) => {
275
+ document
276
+ .querySelectorAll('[data-slot="tabs"][data-theme-choice-tabs]')
277
+ .forEach((tabsRoot) => {
278
+ tabsRoot.dataset.defaultValue = themeChoice;
279
+ tabsRoot.dataset.value = themeChoice;
280
+ tabsRoot.dispatchEvent(
281
+ new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
282
+ );
283
+ });
284
+
285
+ document.querySelectorAll("starlight-theme-select").forEach((picker) => {
286
+ const nativeSelect = picker.querySelector("select");
287
+ if (nativeSelect instanceof HTMLSelectElement) {
288
+ nativeSelect.value = themeChoice;
289
+ }
290
+
291
+ const selectRoot = picker.querySelector(
292
+ '#starlight-theme-select, [data-slot="select"]',
293
+ );
294
+ if (!(selectRoot instanceof HTMLElement)) {
295
+ return;
296
+ }
297
+
298
+ selectRoot.dataset.defaultValue = themeChoice;
299
+ selectRoot.dataset.value = themeChoice;
300
+ selectRoot.dispatchEvent(
301
+ new CustomEvent("select:set", { detail: { value: themeChoice } }),
302
+ );
303
+ });
304
+ };
305
+ const dispatchThemeToggleChange = (themeMode, themeChoice) => {
306
+ window.dispatchEvent(
307
+ new CustomEvent(eventName, {
308
+ detail: {
309
+ theme: themeMode,
310
+ themeChoice,
311
+ },
312
+ }),
313
+ );
314
+ };
315
+ const syncThemeChoice = (themeChoice, emitEvent) => {
316
+ const themeMode = applyThemeModeToRoot(themeChoice);
317
+ syncThemeChoiceControls(themeChoice);
318
+ if (emitEvent) {
319
+ dispatchThemeToggleChange(themeMode, themeChoice);
320
+ }
321
+ };
322
+ const syncStoredThemeChoice = (emitEvent) => {
323
+ syncThemeChoice(loadStoredThemeChoice(), emitEvent);
324
+ };
325
+ const mediaQuery = matchMedia("(prefers-color-scheme: light)");
326
+ const onMediaChange = () => {
327
+ if (loadStoredThemeChoice() === "auto") {
328
+ syncThemeChoice("auto", true);
329
+ }
330
+ };
331
+
332
+ syncStoredThemeChoice(false);
333
+
334
+ window.addEventListener("storage", (event) => {
335
+ if (event.key === storageKey) {
336
+ syncStoredThemeChoice(true);
337
+ }
338
+ });
339
+
340
+ if (typeof mediaQuery.addEventListener === "function") {
341
+ mediaQuery.addEventListener("change", onMediaChange);
342
+ } else if (typeof mediaQuery.addListener === "function") {
343
+ mediaQuery.addListener(onMediaChange);
344
+ }
345
+ })();`;
346
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildCurrentThemeStylesheetHref } from "./theme-stylesheet";
3
+
4
+ describe("current theme stylesheet helpers", () => {
5
+ test("adds a cache-busting query param to relative hrefs", () => {
6
+ expect(
7
+ buildCurrentThemeStylesheetHref("/r/themes/current-theme.css", 123),
8
+ ).toBe("/r/themes/current-theme.css?v=123");
9
+ });
10
+
11
+ test("preserves existing query params when cache-busting", () => {
12
+ expect(
13
+ buildCurrentThemeStylesheetHref("/r/themes/current-theme.css?foo=bar", 123),
14
+ ).toBe("/r/themes/current-theme.css?foo=bar&v=123");
15
+ });
16
+ });
@@ -0,0 +1,13 @@
1
+ export const CURRENT_THEME_STYLESHEET_SELECTOR =
2
+ 'link[data-current-theme-stylesheet]';
3
+
4
+ export function buildCurrentThemeStylesheetHref(
5
+ href: string,
6
+ version: string | number,
7
+ ) {
8
+ const url = new URL(href, "https://ui.bejamas.com");
9
+ url.searchParams.set("v", String(version));
10
+
11
+ const isAbsolute = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
12
+ return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
13
+ }
@@ -87,12 +87,12 @@ const { toc } = Astro.locals.starlightRoute;
87
87
  align-items: center;
88
88
  justify-content: space-between;
89
89
  border: 1px solid var(--sl-color-gray-5);
90
- border-radius: 0.5rem;
90
+ border-radius: calc(var(--radius) - 2px);
91
91
  padding-block: 0.5rem;
92
92
  padding-inline-start: 0.75rem;
93
93
  padding-inline-end: 0.5rem;
94
94
  line-height: 1;
95
- background-color: var(--sl-color-black);
95
+ background-color: var(--background);
96
96
  user-select: none;
97
97
  cursor: pointer;
98
98
  font-weight: 500;
@@ -133,11 +133,11 @@ const { toc } = Astro.locals.starlightRoute;
133
133
  transition: background 200ms ease-out;
134
134
  }
135
135
  details[open] .toggle {
136
- color: var(--sl-color-white);
136
+ color: var(--foreground);
137
137
  border-color: var(--sl-color-accent);
138
138
  }
139
139
  details .toggle:hover {
140
- color: var(--sl-color-white);
140
+ color: var(--foreground);
141
141
  border-color: var(--sl-color-gray-2);
142
142
  }
143
143
 
@@ -152,7 +152,7 @@ const { toc } = Astro.locals.starlightRoute;
152
152
  white-space: nowrap;
153
153
  text-overflow: ellipsis;
154
154
  overflow: hidden;
155
- color: var(--sl-color-white);
155
+ color: var(--foreground);
156
156
  }
157
157
 
158
158
  .dropdown {
@@ -164,7 +164,7 @@ const { toc } = Astro.locals.starlightRoute;
164
164
  85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height)
165
165
  );
166
166
  overflow-y: auto;
167
- background-color: var(--sl-color-black);
167
+ background-color: var(--popover);
168
168
  box-shadow: var(--sl-shadow-md);
169
169
  overscroll-behavior: contain;
170
170
  }
@@ -129,7 +129,7 @@ const isRtl = dir === "rtl";
129
129
  font-size: 1.875rem;
130
130
  line-height: 1.2;
131
131
  font-weight: 500;
132
- color: var(--sl-color-white);
132
+ color: var(--foreground);
133
133
  }
134
134
 
135
135
  @media (min-width: 50rem) {
@@ -70,7 +70,7 @@ const { toc, isMobile = false, depth = 0 } = Astro.props;
70
70
  .isMobile a[aria-current="true"],
71
71
  .isMobile a[aria-current="true"]:hover,
72
72
  .isMobile a[aria-current="true"]:focus {
73
- color: var(--sl-color-white);
73
+ color: var(--foreground);
74
74
  background-color: unset;
75
75
  }
76
76
  .isMobile a[aria-current="true"]::after {
@@ -1,7 +1,14 @@
1
+ ---
2
+ import { buildThemeChoiceBootstrapInlineScript } from "../lib/theme-choice";
3
+
4
+ const themeChoiceBootstrapScript = buildThemeChoiceBootstrapInlineScript();
5
+ ---
6
+
7
+ <script is:inline set:html={themeChoiceBootstrapScript} />
8
+
1
9
  {/* This is intentionally inlined to avoid FOUC. */}
2
10
  <script is:inline>
3
11
  window.StarlightThemeProvider = (() => {
4
- const storageKey = "starlight-theme";
5
12
  const themeMetaMedias = [
6
13
  "(prefers-color-scheme: light)",
7
14
  "(prefers-color-scheme: dark)",
@@ -14,12 +21,12 @@
14
21
  theme === "auto" || theme === "dark" || theme === "light"
15
22
  ? theme
16
23
  : "auto";
17
- const getStoredThemeChoice = () =>
18
- parseTheme(
19
- typeof localStorage !== "undefined" && localStorage.getItem(storageKey),
20
- );
24
+ const getPreferredColorScheme = () =>
25
+ window.matchMedia("(prefers-color-scheme: light)").matches
26
+ ? "light"
27
+ : "dark";
21
28
 
22
- const themeColors = (() => {
29
+ const readThemeColors = () => {
23
30
  const html = document.documentElement;
24
31
  const prevTheme = html.dataset.theme;
25
32
  const prevDark = html.classList.contains("dark");
@@ -45,7 +52,7 @@
45
52
  }
46
53
 
47
54
  return { light, dark };
48
- })();
55
+ };
49
56
 
50
57
  function ensureThemeMeta(head, media) {
51
58
  const baseSelector = `meta[name="theme-color"][media="${media}"]`;
@@ -67,6 +74,7 @@
67
74
  }
68
75
 
69
76
  function syncThemeColor(theme) {
77
+ const themeColors = readThemeColors();
70
78
  const color = themeColors[theme];
71
79
  const head = document.head;
72
80
  if (!color || !head) return;
@@ -77,23 +85,25 @@
77
85
  });
78
86
  }
79
87
 
80
- const themeChoice = getStoredThemeChoice();
81
88
  const effectiveTheme =
82
- themeChoice === "auto"
83
- ? window.matchMedia("(prefers-color-scheme: light)").matches
84
- ? "light"
85
- : "dark"
86
- : themeChoice;
87
- document.documentElement.dataset.theme = effectiveTheme;
88
- document.documentElement.dataset.themeChoice = themeChoice;
89
- document.documentElement.classList.toggle(
90
- "dark",
91
- effectiveTheme === "dark",
92
- );
89
+ document.documentElement.dataset.theme === "light" ||
90
+ document.documentElement.dataset.theme === "dark"
91
+ ? document.documentElement.dataset.theme
92
+ : getPreferredColorScheme();
93
93
  syncThemeColor(effectiveTheme);
94
94
  return {
95
+ refreshThemeColors(theme) {
96
+ const themeChoice = parseTheme(
97
+ theme ?? document.documentElement.dataset.themeChoice,
98
+ );
99
+ const effectiveTheme =
100
+ themeChoice === "auto" ? getPreferredColorScheme() : themeChoice;
101
+ syncThemeColor(effectiveTheme);
102
+ },
95
103
  updatePickers(theme) {
96
- const themeChoice = parseTheme(theme ?? getStoredThemeChoice());
104
+ const themeChoice = parseTheme(
105
+ theme ?? document.documentElement.dataset.themeChoice,
106
+ );
97
107
  document
98
108
  .querySelectorAll("starlight-theme-select")
99
109
  .forEach((picker) => {
@@ -110,19 +120,130 @@
110
120
  new CustomEvent("select:set", { detail: { value: themeChoice } }),
111
121
  );
112
122
  }
113
-
114
- /** @type {HTMLTemplateElement | null} */
115
- const tmpl = document.querySelector(`#theme-icons`);
116
- const newIcon =
117
- tmpl && tmpl.content.querySelector("." + themeChoice);
118
- if (newIcon) {
119
- const oldIcon = picker.querySelector("svg.label-icon");
120
- if (oldIcon) {
121
- oldIcon.replaceChildren(...newIcon.cloneNode(true).childNodes);
122
- }
123
- }
124
123
  });
125
124
  },
126
125
  };
127
126
  })();
128
127
  </script>
128
+
129
+ <script>
130
+ import { syncSemanticIconsInRoot } from "@bejamas/semantic-icons/browser";
131
+ import {
132
+ buildCurrentThemeStylesheetHref,
133
+ CURRENT_THEME_STYLESHEET_SELECTOR,
134
+ } from "../lib/theme-stylesheet";
135
+
136
+ const PRESET_CHANGE_EVENT = "bejamas:preset-change";
137
+ const SETUP_KEY = "__bejamasCurrentThemeStylesheetSync";
138
+ let reloadFrame = 0;
139
+ let reloadVersion = 0;
140
+ let pendingIconLibrary = null;
141
+
142
+ function syncDocumentIconLibrary() {
143
+ const renderedIcon = document.querySelector("[data-icon-library]");
144
+ const iconLibrary =
145
+ renderedIcon instanceof SVGElement
146
+ ? renderedIcon.dataset.iconLibrary
147
+ : document.documentElement.dataset.iconLibrary;
148
+
149
+ if (iconLibrary) {
150
+ document.documentElement.dataset.iconLibrary = iconLibrary;
151
+ syncSemanticIconsInRoot(document, iconLibrary);
152
+ }
153
+ }
154
+
155
+ function refreshCurrentThemeStylesheet() {
156
+ const links = Array.from(
157
+ document.head.querySelectorAll(CURRENT_THEME_STYLESHEET_SELECTOR),
158
+ ).filter(
159
+ (link): link is HTMLLinkElement => link instanceof HTMLLinkElement,
160
+ );
161
+ const currentLink = links.at(-1);
162
+
163
+ if (!currentLink) {
164
+ window.StarlightThemeProvider?.refreshThemeColors?.();
165
+ return;
166
+ }
167
+
168
+ reloadVersion += 1;
169
+
170
+ const replacement = currentLink.cloneNode(false) as HTMLLinkElement;
171
+ const sourceHref = currentLink.getAttribute("href") ?? currentLink.href;
172
+ replacement.href = buildCurrentThemeStylesheetHref(sourceHref, `${Date.now()}-${reloadVersion}`);
173
+
174
+ replacement.addEventListener(
175
+ "load",
176
+ () => {
177
+ links.forEach((link) => {
178
+ if (link !== replacement) {
179
+ link.remove();
180
+ }
181
+ });
182
+ if (pendingIconLibrary) {
183
+ document.documentElement.dataset.iconLibrary = pendingIconLibrary;
184
+ syncSemanticIconsInRoot(document, pendingIconLibrary);
185
+ pendingIconLibrary = null;
186
+ }
187
+ window.StarlightThemeProvider?.refreshThemeColors?.();
188
+ },
189
+ { once: true },
190
+ );
191
+
192
+ replacement.addEventListener(
193
+ "error",
194
+ () => {
195
+ replacement.remove();
196
+ if (pendingIconLibrary) {
197
+ document.documentElement.dataset.iconLibrary = pendingIconLibrary;
198
+ syncSemanticIconsInRoot(document, pendingIconLibrary);
199
+ pendingIconLibrary = null;
200
+ }
201
+ window.StarlightThemeProvider?.refreshThemeColors?.();
202
+ },
203
+ { once: true },
204
+ );
205
+
206
+ currentLink.after(replacement);
207
+ }
208
+
209
+ const scheduleCurrentThemeStylesheetRefresh = () => {
210
+ if (reloadFrame) {
211
+ return;
212
+ }
213
+
214
+ reloadFrame = requestAnimationFrame(() => {
215
+ reloadFrame = 0;
216
+ refreshCurrentThemeStylesheet();
217
+ });
218
+ };
219
+
220
+ if (!(SETUP_KEY in window)) {
221
+ window[SETUP_KEY] = true;
222
+ syncDocumentIconLibrary();
223
+
224
+ window.addEventListener("theme-toggle-changed", (event) => {
225
+ const themeChoice =
226
+ event instanceof CustomEvent && typeof event.detail?.themeChoice === "string"
227
+ ? event.detail.themeChoice
228
+ : undefined;
229
+
230
+ window.StarlightThemeProvider?.refreshThemeColors?.(themeChoice);
231
+ });
232
+
233
+ window.addEventListener(PRESET_CHANGE_EVENT, (event) => {
234
+ const presetChange = event;
235
+
236
+ pendingIconLibrary =
237
+ presetChange instanceof CustomEvent &&
238
+ typeof presetChange.detail?.iconLibrary === "string"
239
+ ? presetChange.detail.iconLibrary
240
+ : null;
241
+
242
+ scheduleCurrentThemeStylesheetRefresh();
243
+ });
244
+ document.addEventListener("astro:after-swap", () => {
245
+ window.StarlightThemeProvider?.refreshThemeColors?.();
246
+ syncDocumentIconLibrary();
247
+ });
248
+ }
249
+ </script>
@@ -36,61 +36,46 @@ import { SunIcon, MoonIcon, LaptopIcon } from "@lucide/astro";
36
36
  </div>
37
37
  </starlight-theme-select>
38
38
 
39
- <script is:inline>
40
- StarlightThemeProvider.updatePickers();
41
- </script>
42
-
43
39
  <script>
44
- type Theme = "auto" | "dark" | "light";
45
- const storageKey = "starlight-theme";
46
-
47
- const parseTheme = (theme: unknown): Theme =>
48
- theme === "auto" || theme === "dark" || theme === "light" ? theme : "auto";
49
-
50
- const loadTheme = (): Theme =>
51
- parseTheme(
52
- typeof localStorage !== "undefined" && localStorage.getItem(storageKey),
53
- );
54
-
55
- function storeTheme(theme: Theme): void {
56
- if (typeof localStorage !== "undefined") {
57
- localStorage.setItem(
58
- storageKey,
59
- theme === "light" || theme === "dark" ? theme : "",
60
- );
61
- }
62
- }
40
+ import {
41
+ getThemeChoiceFromRoot,
42
+ parseStoredThemeChoice,
43
+ setThemeChoice,
44
+ syncThemeChoiceControls,
45
+ } from "../lib/theme-choice";
63
46
 
64
- const getPreferredColorScheme = (): Theme =>
65
- matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
47
+ class StarlightThemeSelect extends HTMLElement {
48
+ private selectRoot: HTMLElement | null = null;
66
49
 
67
- function onThemeChange(theme: Theme): void {
68
- const resolved = theme === "auto" ? getPreferredColorScheme() : theme;
69
- StarlightThemeProvider.updatePickers(theme);
70
- document.documentElement.dataset.theme = resolved;
71
- document.documentElement.dataset.themeChoice = theme;
72
- document.documentElement.classList.toggle("dark", resolved === "dark");
73
- storeTheme(theme);
74
- }
50
+ private onSelectChange = (event: Event) => {
51
+ const value = (event as CustomEvent<{ value?: unknown }>).detail?.value;
52
+ if (typeof value !== "string") return;
75
53
 
76
- matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
77
- if (loadTheme() === "auto") onThemeChange("auto");
78
- });
54
+ const nextTheme = parseStoredThemeChoice(value);
55
+ const currentTheme = getThemeChoiceFromRoot(document.documentElement);
56
+ if (nextTheme === currentTheme) return;
79
57
 
80
- class StarlightThemeSelect extends HTMLElement {
81
- constructor() {
82
- super();
83
- onThemeChange(loadTheme());
84
- const select = this.querySelector("#starlight-theme-select");
85
- select?.addEventListener("select:change", (e) => {
86
- if (e.detail.value) {
87
- onThemeChange(parseTheme(e.detail.value));
88
- }
89
- });
58
+ setThemeChoice(nextTheme);
59
+ };
60
+
61
+ connectedCallback() {
62
+ this.selectRoot = this.querySelector<HTMLElement>("#starlight-theme-select");
63
+ syncThemeChoiceControls(
64
+ getThemeChoiceFromRoot(document.documentElement),
65
+ this,
66
+ );
67
+ this.selectRoot?.addEventListener("select:change", this.onSelectChange);
68
+ }
69
+
70
+ disconnectedCallback() {
71
+ this.selectRoot?.removeEventListener("select:change", this.onSelectChange);
72
+ this.selectRoot = null;
90
73
  }
91
74
  }
92
75
 
93
- customElements.define("starlight-theme-select", StarlightThemeSelect);
76
+ if (!customElements.get("starlight-theme-select")) {
77
+ customElements.define("starlight-theme-select", StarlightThemeSelect);
78
+ }
94
79
  </script>
95
80
 
96
81
  <style>
@@ -49,10 +49,8 @@
49
49
  }
50
50
 
51
51
  :root {
52
- --sl-font: "Inter Variable", sans-serif;
53
- --sl-font-mono: "Geist Mono", monospace;
54
-
55
- --radius: 0.5rem;
52
+ --sl-font: var(--font-sans);
53
+ --sl-font-mono: var(--font-mono);
56
54
 
57
55
  --sl-color-text-accent: var(--accent-foreground);
58
56
 
@@ -74,8 +72,7 @@
74
72
 
75
73
  --sl-color-gray-2: var(--input);
76
74
 
77
- --default-font-family: "Inter Variable", sans-serif;
78
- --font-sans: "Inter Variable", sans-serif;
75
+ --default-font-family: var(--font-sans);
79
76
 
80
77
  --sl-color-bg: var(--background);
81
78
  --sl-color-fg: var(--foreground);
@@ -107,6 +104,7 @@
107
104
  --ec-frm-inlBtnBgHoverOrFocusOpa: 1;
108
105
  --ec-frm-trmIcon: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBMaWNlbnNlOiBNSVQuIE1hZGUgYnkgcGhvc3Bob3I6IGh0dHBzOi8vZ2l0aHViLmNvbS9waG9zcGhvci1pY29ucy9waG9zcGhvci1pY29ucyAtLT4KPHN2ZyBmaWxsPSIjMDAwMDAwIiB3aWR0aD0iMThweCIgaGVpZ2h0PSIxOHB4IiB2aWV3Qm94PSIwIDAgMjU2IDI1NiIgaWQ9IkZsYXQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTExNy4zMTQ5NCwxMzMuOTc5NDlsLTcyLDY0YTguMDAwMTgsOC4wMDAxOCwwLDAsMS0xMC42Mjk4OC0xMS45NTlMOTkuOTU4NSwxMjgsMzQuNjg1MDYsNjkuOTc5NDlhOC4wMDAxOCw4LjAwMDE4LDAsMCwxLDEwLjYyOTg4LTExLjk1OWw3Miw2NGE4LjAwMDU0LDguMDAwNTQsMCwwLDEsMCwxMS45NTlaTTIxNS45OTQxNCwxODRoLTk2YTgsOCwwLDAsMCwwLDE2aDk2YTgsOCwwLDEsMCwwLTE2WiIvPgo8L3N2Zz4=);
109
106
  --ecGtrBrdWd: 5px;
107
+ --sl-nav-gap: calc(var(--spacing) * 2);
110
108
  }
111
109
 
112
110
  @media (min-width: 50em) {
@@ -284,15 +282,6 @@
284
282
  padding-inline: 0.5rem;
285
283
  }
286
284
 
287
- site-search button {
288
- --tw-shadow: var(--shadow-sm);
289
- box-shadow:
290
- var(--tw-inset-shadow), var(--tw-inset-ring-shadow),
291
- var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
292
- border-radius: calc(var(--radius) - 2px);
293
- max-width: calc(var(--spacing) * 50);
294
- }
295
-
296
285
  .expressive-code .frame {
297
286
  box-shadow: none;
298
287
  }
@@ -421,21 +410,6 @@
421
410
  text-underline-offset: 4px;
422
411
  }
423
412
 
424
- button[data-open-modal] {
425
- border-color: var(--border);
426
- color: var(--foreground);
427
- box-shadow: none;
428
- background-color: var(--background);
429
- height: calc(var(--spacing) * 9);
430
- }
431
-
432
- button[data-open-modal] kbd {
433
- background-color: var(--muted);
434
- color: var(--muted-foreground);
435
- border-radius: calc(var(--radius) - 4px);
436
- gap: 0;
437
- }
438
-
439
413
  .tablist-wrapper ~ [role="tabpanel"] {
440
414
  margin-top: calc(var(--spacing) * 2.5);
441
415
  }