@takazudo/zudo-doc 5.26.5 → 5.27.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.
@@ -1,44 +1,24 @@
1
1
  export type ColorSchemeMode = "light" | "dark";
2
+ export type ThemePreference = ColorSchemeMode | "system";
2
3
  export declare const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
3
- /**
4
- * Read the active color scheme from `<html data-theme>`. Falls back to
5
- * `defaultMode` when the attribute is missing or holds an unexpected
6
- * value (e.g. before the ColorSchemeProvider bootstrap script ran).
7
- */
4
+ export declare const THEME_PREFERENCE_CHANGED_EVENT = "theme-preference-changed";
5
+ export declare const COLOR_SCHEME_RUNTIME_GLOBAL = "__zudoDocColorScheme";
6
+ export declare const COLOR_SCHEME_STORAGE_KEY = "zudo-doc-theme";
7
+ export interface ColorSchemeRuntime {
8
+ choice: ThemePreference | null;
9
+ defaultMode: ColorSchemeMode;
10
+ respectPrefersColorScheme: boolean;
11
+ lastMode?: ColorSchemeMode;
12
+ cleanup?: () => void;
13
+ }
14
+ export declare function normalizeThemePreference(value: unknown): ThemePreference | null;
15
+ export declare function resolveThemePreference(choice: ThemePreference | null, defaultMode: ColorSchemeMode, respectPrefersColorScheme: boolean): ThemePreference;
16
+ export declare function resolveColorScheme(preference: ThemePreference, systemIsDark: boolean): ColorSchemeMode;
17
+ export declare function readThemePreference(defaultMode?: ColorSchemeMode, respectPrefersColorScheme?: boolean): ThemePreference;
8
18
  export declare function readColorSchemeFromDom(defaultMode: ColorSchemeMode): ColorSchemeMode;
9
- /**
10
- * Apply `next` as the active color scheme: mutate the DOM, persist the
11
- * preference, and notify every subscriber (including other mounted
12
- * ThemeToggle instances and the zdtp design-token panel) via the
13
- * `color-scheme-changed` window event.
14
- *
15
- * Tweak-state reconciliation is intentionally NOT done here (#2037). The zdtp
16
- * panel owns its own storage lifecycle and current persisted-state contract;
17
- * its own `color-scheme-changed` listener clears applied inline
18
- * styles and re-seeds the color slice from the newly active scheme. An
19
- * earlier version of this function deleted `zudo-doc-tweak-state` + `-v2` on
20
- * every toggle, which (a) targeted stale keys after zdtp moved to v3 — so it
21
- * no longer did anything — and (b) when it did fire, wiped the whole envelope
22
- * including scheme-independent spacing/typography/size tweaks, contradicting
23
- * the documented carry-over guarantee. So the host no longer touches zdtp's
24
- * private storage keys.
25
- *
26
- * The design-token-panel bootstrap ALSO listens for this event and, on toggle,
27
- * destroys + reconfigures the panel with the new mode's mode-scoped semantic
28
- * DEFAULTS (see `design-token-panel-bootstrap.ts` + the host's
29
- * `buildDesignTokenPanelConfig`, #2610). That keeps the panel's per-mode
30
- * defaults faithful. Saved Color overrides are scheme- and mode-scoped:
31
- * the package-default builder declares separate Default Light / Default Dark
32
- * identities in panelSettings.colorMode, so an edit made in one mode does not
33
- * replace the other mode's default or saved mapping. Returning to a mode
34
- * restores that identity's saved choice. Palette, Spacing, Font, and Size
35
- * overrides remain shared across light/dark within the active theme pack.
36
- * The browser contract lives in e2e/theme-panel-persistence.spec.ts (#3980).
37
- * See zudo-doc#2037 / #2610.
38
- */
19
+ /** Select a preference. Storage is best effort; the live choice survives errors. */
20
+ export declare function applyThemePreference(next: ThemePreference): void;
21
+ /** Existing light/dark callers explicitly select the corresponding preference. */
39
22
  export declare function applyColorScheme(next: ColorSchemeMode): void;
40
- /**
41
- * Subscribe to color-scheme changes. Returns an unsubscribe function
42
- * (suitable as a `useEffect` cleanup).
43
- */
44
23
  export declare function subscribeColorSchemeChanged(listener: () => void): () => void;
24
+ export declare function subscribeThemePreferenceChanged(listener: () => void): () => void;
@@ -1,22 +1,97 @@
1
1
  const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
2
- const STORAGE_KEY = "zudo-doc-theme";
2
+ const THEME_PREFERENCE_CHANGED_EVENT = "theme-preference-changed";
3
+ const COLOR_SCHEME_RUNTIME_GLOBAL = "__zudoDocColorScheme";
4
+ const COLOR_SCHEME_STORAGE_KEY = "zudo-doc-theme";
5
+ function normalizeThemePreference(value) {
6
+ return value === "light" || value === "dark" || value === "system" ? value : null;
7
+ }
8
+ function resolveThemePreference(choice, defaultMode, respectPrefersColorScheme) {
9
+ return choice ?? (respectPrefersColorScheme ? "system" : defaultMode);
10
+ }
11
+ function resolveColorScheme(preference, systemIsDark) {
12
+ return preference === "system" ? systemIsDark ? "dark" : "light" : preference;
13
+ }
14
+ function runtime() {
15
+ return window[COLOR_SCHEME_RUNTIME_GLOBAL] ?? null;
16
+ }
17
+ function readStorage() {
18
+ try {
19
+ return normalizeThemePreference(localStorage.getItem(COLOR_SCHEME_STORAGE_KEY));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function getRuntime() {
25
+ const existing = runtime();
26
+ if (existing) return existing;
27
+ const initial = {
28
+ choice: readStorage(),
29
+ defaultMode: "dark",
30
+ respectPrefersColorScheme: true
31
+ };
32
+ window[COLOR_SCHEME_RUNTIME_GLOBAL] = initial;
33
+ return initial;
34
+ }
35
+ function readThemePreference(defaultMode = "dark", respectPrefersColorScheme = true) {
36
+ const state = runtime();
37
+ return resolveThemePreference(
38
+ state ? state.choice : readStorage(),
39
+ state?.defaultMode ?? defaultMode,
40
+ state?.respectPrefersColorScheme ?? respectPrefersColorScheme
41
+ );
42
+ }
3
43
  function readColorSchemeFromDom(defaultMode) {
4
44
  const actual = document.documentElement.getAttribute("data-theme");
5
45
  return actual === "light" || actual === "dark" ? actual : defaultMode;
6
46
  }
47
+ function applyThemePreference(next) {
48
+ const state = getRuntime();
49
+ const previousPreference = resolveThemePreference(
50
+ state.choice,
51
+ state.defaultMode,
52
+ state.respectPrefersColorScheme
53
+ );
54
+ const previousMode = state.lastMode ?? document.documentElement.getAttribute("data-theme");
55
+ state.choice = next;
56
+ try {
57
+ localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, next);
58
+ } catch {
59
+ }
60
+ const systemIsDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
61
+ const mode = resolveColorScheme(next, systemIsDark);
62
+ document.documentElement.setAttribute("data-theme", mode);
63
+ document.documentElement.style.colorScheme = mode;
64
+ state.lastMode = mode;
65
+ if (previousMode !== mode) {
66
+ window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
67
+ }
68
+ if (previousPreference !== next) {
69
+ window.dispatchEvent(new CustomEvent(THEME_PREFERENCE_CHANGED_EVENT));
70
+ }
71
+ }
7
72
  function applyColorScheme(next) {
8
- document.documentElement.setAttribute("data-theme", next);
9
- document.documentElement.style.colorScheme = next;
10
- localStorage.setItem(STORAGE_KEY, next);
11
- window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
73
+ applyThemePreference(next);
12
74
  }
13
75
  function subscribeColorSchemeChanged(listener) {
14
76
  window.addEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
15
77
  return () => window.removeEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
16
78
  }
79
+ function subscribeThemePreferenceChanged(listener) {
80
+ window.addEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
81
+ return () => window.removeEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
82
+ }
17
83
  export {
18
84
  COLOR_SCHEME_CHANGED_EVENT,
85
+ COLOR_SCHEME_RUNTIME_GLOBAL,
86
+ COLOR_SCHEME_STORAGE_KEY,
87
+ THEME_PREFERENCE_CHANGED_EVENT,
19
88
  applyColorScheme,
89
+ applyThemePreference,
90
+ normalizeThemePreference,
20
91
  readColorSchemeFromDom,
21
- subscribeColorSchemeChanged
92
+ readThemePreference,
93
+ resolveColorScheme,
94
+ resolveThemePreference,
95
+ subscribeColorSchemeChanged,
96
+ subscribeThemePreferenceChanged
22
97
  };
@@ -1,10 +1,19 @@
1
1
  import { type ColorSchemeMode } from "./color-scheme-sync.js";
2
+ export interface ThemeToggleLabels {
3
+ appearance: string;
4
+ light: string;
5
+ dark: string;
6
+ system: string;
7
+ systemHelper: string;
8
+ }
2
9
  export interface ThemeToggleProps {
3
10
  defaultMode?: ColorSchemeMode;
11
+ respectPrefersColorScheme?: boolean;
12
+ labels?: ThemeToggleLabels;
4
13
  /** Keep activation pending until the first successful mount. @default true */
5
14
  pendingUntilHydrated?: boolean;
6
15
  }
7
- export declare function ThemeToggle({ defaultMode, pendingUntilHydrated, }: ThemeToggleProps): import("preact").JSX.Element;
16
+ export declare function ThemeToggle({ defaultMode, respectPrefersColorScheme, labels, pendingUntilHydrated, }: ThemeToggleProps): import("preact").JSX.Element;
8
17
  export declare namespace ThemeToggle {
9
18
  var displayName: string;
10
19
  }
@@ -1,41 +1,19 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "preact/jsx-runtime";
3
- import { useState, useEffect } from "preact/hooks";
2
+ import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
3
+ import { useState, useEffect, useRef } from "preact/hooks";
4
+ import { createPortal } from "preact/compat";
4
5
  import { useHydrationPending } from "../hydration-pending.js";
6
+ import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
5
7
  import {
6
- applyColorScheme,
8
+ applyThemePreference,
7
9
  readColorSchemeFromDom,
8
- subscribeColorSchemeChanged
10
+ readThemePreference,
11
+ subscribeColorSchemeChanged,
12
+ subscribeThemePreferenceChanged
9
13
  } from "./color-scheme-sync.js";
10
- function SunIcon() {
11
- return /* @__PURE__ */ jsxs(
12
- "svg",
13
- {
14
- "aria-hidden": "true",
15
- xmlns: "http://www.w3.org/2000/svg",
16
- width: "20",
17
- height: "20",
18
- viewBox: "0 0 24 24",
19
- fill: "none",
20
- stroke: "currentColor",
21
- strokeWidth: "2",
22
- strokeLinecap: "round",
23
- strokeLinejoin: "round",
24
- children: [
25
- /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "5" }),
26
- /* @__PURE__ */ jsx("line", { x1: "12", y1: "1", x2: "12", y2: "3" }),
27
- /* @__PURE__ */ jsx("line", { x1: "12", y1: "21", x2: "12", y2: "23" }),
28
- /* @__PURE__ */ jsx("line", { x1: "4.22", y1: "4.22", x2: "5.64", y2: "5.64" }),
29
- /* @__PURE__ */ jsx("line", { x1: "18.36", y1: "18.36", x2: "19.78", y2: "19.78" }),
30
- /* @__PURE__ */ jsx("line", { x1: "1", y1: "12", x2: "3", y2: "12" }),
31
- /* @__PURE__ */ jsx("line", { x1: "21", y1: "12", x2: "23", y2: "12" }),
32
- /* @__PURE__ */ jsx("line", { x1: "4.22", y1: "19.78", x2: "5.64", y2: "18.36" }),
33
- /* @__PURE__ */ jsx("line", { x1: "18.36", y1: "5.64", x2: "19.78", y2: "4.22" })
34
- ]
35
- }
36
- );
37
- }
38
- function MoonIcon() {
14
+ const preferences = ["light", "dark", "system"];
15
+ let menuSequence = 0;
16
+ function PreferenceIcon({ preference }) {
39
17
  return /* @__PURE__ */ jsx(
40
18
  "svg",
41
19
  {
@@ -46,42 +24,223 @@ function MoonIcon() {
46
24
  viewBox: "0 0 24 24",
47
25
  fill: "none",
48
26
  stroke: "currentColor",
49
- strokeWidth: "2",
50
- strokeLinecap: "round",
51
- strokeLinejoin: "round",
52
- children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
27
+ strokeWidth: "1.2",
28
+ strokeLinecap: "square",
29
+ strokeLinejoin: "miter",
30
+ children: preference === "light" ? /* @__PURE__ */ jsxs(Fragment, { children: [
31
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3.6" }),
32
+ /* @__PURE__ */ jsx("path", { d: "M12 2.5V6M12 18V21.5M2.5 12H6M18 12H21.5M5.3 5.3L7.8 7.8M16.2 16.2L18.7 18.7M5.3 18.7L7.8 16.2M16.2 7.8L18.7 5.3" })
33
+ ] }) : preference === "dark" ? /* @__PURE__ */ jsx("path", { d: "M10.2 2.9C5.7 3.9 2.6 7.9 3 12.6C3.4 17.7 7.9 21.5 13 21.1C16.9 20.7 20.1 17.9 21 14.1C18.6 15.7 15.6 15.8 13.2 14.3C9.3 12 8 6.8 10.2 2.9Z" }) : /* @__PURE__ */ jsx("path", { d: "M2.5 3.5H21.5V16.5H2.5ZM12 16.5V20.5M7.5 20.5H16.5" })
53
34
  }
54
35
  );
55
36
  }
37
+ const englishLabels = {
38
+ appearance: "Appearance",
39
+ light: "Light",
40
+ dark: "Dark",
41
+ system: "System",
42
+ systemHelper: "Follows device \xB7 currently {mode}"
43
+ };
56
44
  function ThemeToggle({
57
45
  defaultMode = "dark",
46
+ respectPrefersColorScheme = true,
47
+ labels = englishLabels,
58
48
  pendingUntilHydrated = true
59
49
  }) {
60
50
  const pending = useHydrationPending(pendingUntilHydrated);
61
- const [mode, setMode] = useState(defaultMode);
51
+ const menuId = useRef("");
52
+ const ensureMenuId = () => {
53
+ if (!menuId.current) menuId.current = `zd-appearance-${++menuSequence}`;
54
+ };
55
+ const rootRef = useRef(null);
56
+ const menuRef = useRef(null);
57
+ const triggerRef = useRef(null);
58
+ const itemRefs = useRef([]);
59
+ const restoreFocusRef = useRef(false);
60
+ const [preference, setPreference] = useState(
61
+ respectPrefersColorScheme ? "system" : defaultMode
62
+ );
63
+ const [resolved, setResolved] = useState(defaultMode);
64
+ const [open, setOpen] = useState(false);
65
+ const [activeIndex, setActiveIndex] = useState(0);
66
+ const [placement, setPlacement] = useState(null);
62
67
  useEffect(() => {
63
- const sync = () => setMode(readColorSchemeFromDom(defaultMode));
68
+ const sync = () => {
69
+ setPreference(readThemePreference(defaultMode, respectPrefersColorScheme));
70
+ setResolved(readColorSchemeFromDom(defaultMode));
71
+ };
64
72
  sync();
65
- return subscribeColorSchemeChanged(sync);
66
- }, []);
67
- function toggle() {
68
- if (pending) return;
69
- const next = mode === "dark" ? "light" : "dark";
70
- setMode(next);
71
- applyColorScheme(next);
72
- }
73
- const nextMode = mode === "dark" ? "light" : "dark";
74
- return /* @__PURE__ */ jsx(
75
- "button",
76
- {
77
- onClick: toggle,
78
- "aria-label": `Switch to ${nextMode} mode`,
79
- "aria-disabled": pending ? "true" : void 0,
80
- "data-zd-pending": pending ? "" : void 0,
81
- className: "text-muted hover:text-fg transition-colors p-hsp-sm focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
82
- children: mode === "dark" ? /* @__PURE__ */ jsx(SunIcon, {}) : /* @__PURE__ */ jsx(MoonIcon, {})
73
+ const unsubscribePreference = subscribeThemePreferenceChanged(() => {
74
+ sync();
75
+ setOpen(false);
76
+ });
77
+ const unsubscribeScheme = subscribeColorSchemeChanged(sync);
78
+ return () => {
79
+ unsubscribePreference();
80
+ unsubscribeScheme();
81
+ };
82
+ }, [defaultMode, respectPrefersColorScheme]);
83
+ useEffect(() => {
84
+ if (!open) return;
85
+ const menu = menuRef.current;
86
+ menu?.showPopover?.();
87
+ const position = () => {
88
+ const rect = triggerRef.current?.getBoundingClientRect();
89
+ if (!rect) return;
90
+ const gap = 8;
91
+ const width = Math.min(260, window.innerWidth - gap * 2);
92
+ const desiredHeight = menu?.scrollHeight ?? 280;
93
+ const roomBelow = window.innerHeight - rect.bottom - gap * 2;
94
+ const roomAbove = rect.top - gap * 2;
95
+ const above = roomBelow < desiredHeight && roomAbove > roomBelow;
96
+ const maxHeight = Math.max(80, Math.min(desiredHeight, above ? roomAbove : roomBelow));
97
+ setPlacement({
98
+ left: Math.max(gap, Math.min(rect.right - width, window.innerWidth - width - gap)),
99
+ top: above ? Math.max(gap, rect.top - gap - maxHeight) : rect.bottom + gap,
100
+ width,
101
+ maxHeight
102
+ });
103
+ };
104
+ const onPointerDown = (event) => {
105
+ if (!rootRef.current?.contains(event.target) && !menuRef.current?.contains(event.target)) setOpen(false);
106
+ };
107
+ const onNavigate = () => setOpen(false);
108
+ position();
109
+ document.addEventListener("pointerdown", onPointerDown);
110
+ window.addEventListener("resize", position);
111
+ window.addEventListener("scroll", position, true);
112
+ document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
113
+ return () => {
114
+ if (menu?.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
115
+ document.removeEventListener("pointerdown", onPointerDown);
116
+ window.removeEventListener("resize", position);
117
+ window.removeEventListener("scroll", position, true);
118
+ document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
119
+ };
120
+ }, [open]);
121
+ useEffect(() => {
122
+ if (open && placement) itemRefs.current[activeIndex]?.focus();
123
+ }, [open, placement]);
124
+ useEffect(() => {
125
+ if (open || !restoreFocusRef.current) return;
126
+ restoreFocusRef.current = false;
127
+ requestAnimationFrame(() => triggerRef.current?.focus());
128
+ }, [open]);
129
+ const close = (restoreFocus = false) => {
130
+ restoreFocusRef.current = restoreFocus;
131
+ setOpen(false);
132
+ };
133
+ const select = (next) => {
134
+ close(true);
135
+ applyThemePreference(next);
136
+ setPreference(next);
137
+ setResolved(readColorSchemeFromDom(defaultMode));
138
+ };
139
+ const move = (index) => {
140
+ const next = (index + preferences.length) % preferences.length;
141
+ setActiveIndex(next);
142
+ itemRefs.current[next]?.focus();
143
+ };
144
+ const onMenuKeyDown = (event) => {
145
+ if (event.key === "Escape") {
146
+ event.preventDefault();
147
+ close(true);
148
+ } else if (event.key === "Tab") {
149
+ window.setTimeout(() => close(), 0);
150
+ } else if (event.key === "ArrowDown") {
151
+ event.preventDefault();
152
+ move(activeIndex + 1);
153
+ } else if (event.key === "ArrowUp") {
154
+ event.preventDefault();
155
+ move(activeIndex - 1);
156
+ } else if (event.key === "Home") {
157
+ event.preventDefault();
158
+ move(0);
159
+ } else if (event.key === "End") {
160
+ event.preventDefault();
161
+ move(2);
83
162
  }
84
- );
163
+ };
164
+ return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: "relative inline-flex", "data-zd-theme-menu": "", children: [
165
+ /* @__PURE__ */ jsx(
166
+ "button",
167
+ {
168
+ ref: triggerRef,
169
+ type: "button",
170
+ "aria-haspopup": "menu",
171
+ "aria-expanded": open,
172
+ "aria-controls": open ? menuId.current : void 0,
173
+ "aria-label": `${labels.appearance}: ${labels[preference]}`,
174
+ "aria-disabled": pending ? "true" : void 0,
175
+ "data-zd-pending": pending ? "" : void 0,
176
+ onClick: () => {
177
+ if (pending) return;
178
+ if (open) close(true);
179
+ else {
180
+ setPlacement(null);
181
+ ensureMenuId();
182
+ setActiveIndex(preferences.indexOf(preference));
183
+ setOpen(true);
184
+ }
185
+ },
186
+ onKeyDown: (event) => {
187
+ if (pending) {
188
+ if (event.key === "Enter" || event.key === " ") event.preventDefault();
189
+ return;
190
+ }
191
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
192
+ event.preventDefault();
193
+ setPlacement(null);
194
+ ensureMenuId();
195
+ setActiveIndex(event.key === "ArrowDown" ? 0 : 2);
196
+ setOpen(true);
197
+ } else if (event.key === "Escape" && open) {
198
+ event.preventDefault();
199
+ close(true);
200
+ }
201
+ },
202
+ className: "inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center text-muted hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
203
+ children: /* @__PURE__ */ jsx(PreferenceIcon, { preference })
204
+ }
205
+ ),
206
+ open && createPortal(/* @__PURE__ */ jsxs(
207
+ "div",
208
+ {
209
+ ref: menuRef,
210
+ id: menuId.current,
211
+ role: "menu",
212
+ "aria-label": labels.appearance,
213
+ popover: typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype ? "manual" : void 0,
214
+ onKeyDown: onMenuKeyDown,
215
+ className: "fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg",
216
+ style: placement ? { left: placement.left, top: placement.top, right: "auto", bottom: "auto", margin: 0, width: placement.width, maxHeight: placement.maxHeight } : { visibility: "hidden", left: 0, top: 0, right: "auto", bottom: "auto", margin: 0, width: Math.min(260, window.innerWidth - 16) },
217
+ children: [
218
+ /* @__PURE__ */ jsx("div", { className: "px-hsp-sm py-vsp-xs text-small font-semibold", "aria-hidden": "true", children: labels.appearance }),
219
+ preferences.map((option, index) => /* @__PURE__ */ jsxs(
220
+ "button",
221
+ {
222
+ ref: (node) => {
223
+ itemRefs.current[index] = node;
224
+ },
225
+ type: "button",
226
+ role: "menuitemradio",
227
+ "aria-checked": preference === option,
228
+ onFocus: () => setActiveIndex(index),
229
+ onClick: () => select(option),
230
+ className: `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference === option ? "bg-accent/10" : ""} hover:bg-accent/10 focus-visible:bg-accent/10 focus-visible:outline-2 focus-visible:outline-accent`,
231
+ children: [
232
+ /* @__PURE__ */ jsx(PreferenceIcon, { preference: option }),
233
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: labels[option] }),
234
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "text-accent", children: preference === option ? "\u2713" : "" })
235
+ ]
236
+ },
237
+ option
238
+ )),
239
+ /* @__PURE__ */ jsx("div", { className: "px-hsp-sm py-vsp-xs text-small text-muted", children: labels.systemHelper.replace("{mode}", labels[resolved]) })
240
+ ]
241
+ }
242
+ ), document.body)
243
+ ] });
85
244
  }
86
245
  ThemeToggle.displayName = "ThemeToggle";
87
246
  export {
@@ -0,0 +1,3 @@
1
+ import type { ThemeToggleLabels } from "./index.js";
2
+ /** Resolve labels before island serialization so host translation overrides apply. */
3
+ export declare function themeToggleLabels(t: (key: string, locale: string) => string, locale: string): ThemeToggleLabels;
@@ -0,0 +1,12 @@
1
+ function themeToggleLabels(t, locale) {
2
+ return {
3
+ appearance: t("appearance.title", locale),
4
+ light: t("appearance.light", locale),
5
+ dark: t("appearance.dark", locale),
6
+ system: t("appearance.system", locale),
7
+ systemHelper: t("appearance.systemHelper", locale)
8
+ };
9
+ }
10
+ export {
11
+ themeToggleLabels
12
+ };
@@ -294,6 +294,49 @@ export function Header(props: HeaderProps): JSX.Element {
294
294
 
295
295
  const activeNavPath = computeActiveNavPath(headerNav, matchPath);
296
296
  const rightItemDispatch = createRightItemDispatch(headerRightComponents);
297
+ const rightItems = headerRightItems
298
+ .map((item, i) => ({
299
+ item,
300
+ node: renderRightItem(
301
+ item,
302
+ i,
303
+ {
304
+ lang,
305
+ githubRepoUrl,
306
+ githubLabel,
307
+ themeToggle,
308
+ languageSwitcher,
309
+ versionSwitcher,
310
+ search,
311
+ colorModeEnabled,
312
+ hasLocales,
313
+ },
314
+ rightItemDispatch,
315
+ ),
316
+ }))
317
+ .filter((entry): entry is typeof entry & { node: VNode } => entry.node !== null);
318
+ const rightGroups: VNode[] = [];
319
+ let iconGroup: VNode[] = [];
320
+
321
+ const flushIconGroup = () => {
322
+ if (iconGroup.length === 0) return;
323
+ rightGroups.push(
324
+ <div class="flex items-center" data-header-icon-group>
325
+ {iconGroup}
326
+ </div>,
327
+ );
328
+ iconGroup = [];
329
+ };
330
+
331
+ for (const { item, node } of rightItems) {
332
+ if (isHeaderIconItem(item)) {
333
+ iconGroup.push(node);
334
+ } else {
335
+ flushIconGroup();
336
+ rightGroups.push(node);
337
+ }
338
+ }
339
+ flushIconGroup();
297
340
 
298
341
  return (
299
342
  <header
@@ -412,22 +455,7 @@ export function Header(props: HeaderProps): JSX.Element {
412
455
  class="ml-auto flex shrink-0 items-center gap-x-hsp-md"
413
456
  data-header-right
414
457
  >
415
- {headerRightItems.map((item, i) => renderRightItem(
416
- item,
417
- i,
418
- {
419
- lang,
420
- githubRepoUrl,
421
- githubLabel,
422
- themeToggle,
423
- languageSwitcher,
424
- versionSwitcher,
425
- search,
426
- colorModeEnabled,
427
- hasLocales,
428
- },
429
- rightItemDispatch,
430
- ))}
458
+ {rightGroups}
431
459
  </div>
432
460
 
433
461
  <script dangerouslySetInnerHTML={{ __html: NAV_OVERFLOW_SCRIPT }} />
@@ -592,6 +620,13 @@ function stripCurrentVersionPrefix(path: string, currentVersion?: string): strin
592
620
 
593
621
  type RightItemContext = Omit<HeaderRightComponentProps, "item" | "index">;
594
622
 
623
+ function isHeaderIconItem(item: HeaderRightItem): boolean {
624
+ if (item.type === "trigger") return true;
625
+ if (item.type === "link") return item.icon === "github";
626
+ if (item.type !== "component") return false;
627
+ return ["github-link", "theme-toggle", "search"].includes(item.component);
628
+ }
629
+
595
630
  /**
596
631
  * Shared trigger-button shell for header-right items that dispatch a
597
632
  * CustomEvent on click. The legacy template used an inline `onclick`
@@ -620,7 +655,7 @@ function TriggerButton({
620
655
  key={`right-${index}`}
621
656
  id={id}
622
657
  type="button"
623
- class="flex items-center justify-center text-muted transition-colors hover:text-fg"
658
+ class="flex h-[40px] w-[40px] shrink-0 items-center justify-center text-muted transition-colors hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
624
659
  aria-label={ariaLabel}
625
660
  {...inlineOnclick}
626
661
  >
@@ -730,7 +765,7 @@ const BASE_RIGHT_ITEM_DISPATCH: Readonly<Record<string, RightItemHandler>> = {
730
765
  href={ctx.githubRepoUrl}
731
766
  target="_blank"
732
767
  rel="noopener noreferrer"
733
- class="flex items-center justify-center text-muted transition-colors hover:text-fg"
768
+ class="flex h-[40px] w-[40px] shrink-0 items-center justify-center text-muted transition-colors hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
734
769
  aria-label={ctx.githubLabel}
735
770
  title={ctx.githubLabel}
736
771
  >
@@ -782,7 +817,9 @@ const BASE_RIGHT_ITEM_DISPATCH: Readonly<Record<string, RightItemHandler>> = {
782
817
  href={item.href}
783
818
  target={isExternal ? "_blank" : undefined}
784
819
  rel={isExternal ? "noopener noreferrer" : undefined}
785
- class="flex items-center justify-center text-muted transition-colors hover:text-fg"
820
+ class={item.icon === "github"
821
+ ? "flex h-[40px] w-[40px] shrink-0 items-center justify-center text-muted transition-colors hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
822
+ : "flex items-center justify-center text-muted transition-colors hover:text-fg"}
786
823
  aria-label={item.ariaLabel}
787
824
  title={label}
788
825
  >
@@ -14,6 +14,7 @@ import { useState, useEffect, useRef } from "preact/hooks";
14
14
  // collapse into duplicate import statements in an ejected copy.
15
15
  import { AFTER_NAVIGATE_EVENT, ensureNestedIslandPropsRefresh } from "../transitions/index.js";
16
16
  import { SidebarTree } from "../sidebar-tree-island/index.js";
17
+ import type { ThemeToggleLabels } from "../theme-toggle/index.js";
17
18
  import type { SidebarNavNode, SidebarRootMenuItem, SidebarLocaleLink } from "../sidebar/types.js";
18
19
  import type { ResolvedDateFormats } from "../settings.js";
19
20
 
@@ -57,6 +58,8 @@ export interface SidebarToggleProps {
57
58
  locale?: string;
58
59
  localeLinks?: SidebarLocaleLink[];
59
60
  themeDefaultMode?: "light" | "dark";
61
+ themeLabels?: ThemeToggleLabels;
62
+ themeRespectSystem?: boolean;
60
63
  /**
61
64
  * Forwarded verbatim to the hosted `<SidebarTree>`. This island is the one
62
65
  * nested under the persisted `<header>`, so on a same-locale soft navigation
@@ -79,6 +82,8 @@ export function SidebarToggle({
79
82
  locale,
80
83
  localeLinks,
81
84
  themeDefaultMode,
85
+ themeLabels,
86
+ themeRespectSystem,
82
87
  dateFormats,
83
88
  }: SidebarToggleProps) {
84
89
  // Initial state must match SSR (`open=false`) so the hydration DOM
@@ -255,6 +260,8 @@ export function SidebarToggle({
255
260
  locale={locale}
256
261
  localeLinks={localeLinks}
257
262
  themeDefaultMode={themeDefaultMode}
263
+ themeLabels={themeLabels}
264
+ themeRespectSystem={themeRespectSystem}
258
265
  dateFormats={dateFormats}
259
266
  />
260
267
  </div>