@takazudo/zudo-doc 6.1.1 → 7.0.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.
@@ -5,7 +5,6 @@ import { hydrationPending } from "./hydration-pending.js";
5
5
  import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
6
6
  import {
7
7
  applyThemePreference,
8
- readColorSchemeFromDom,
9
8
  readThemePreference,
10
9
  subscribeColorSchemeChanged,
11
10
  subscribeThemePreferenceChanged,
@@ -56,7 +55,7 @@ interface AppearanceMenuProps {
56
55
  id: string;
57
56
  labels: ThemeToggleLabels;
58
57
  preference: ReadonlySignal<ThemePreference>;
59
- resolved: ReadonlySignal<ColorSchemeMode>;
58
+ deviceMode: ReadonlySignal<ColorSchemeMode>;
60
59
  activeIndex: ReadonlySignal<number>;
61
60
  triggerRef: Ref<HTMLButtonElement>;
62
61
  rootRef: Ref<HTMLDivElement>;
@@ -65,11 +64,36 @@ interface AppearanceMenuProps {
65
64
  move: (index: number) => void;
66
65
  }
67
66
 
68
- function AppearanceMenu({ id, labels, preference, resolved, activeIndex, triggerRef, rootRef, close, select, move }: AppearanceMenuProps) {
67
+ const MENU_WIDTH_REM = 14.75;
68
+ const VIEWPORT_GAP = 8;
69
+
70
+ function viewportSize() {
71
+ const root = document.documentElement;
72
+ // clientWidth/Height exclude classic scrollbars; innerWidth/Height include them.
73
+ return {
74
+ width: Math.min(window.innerWidth, root.clientWidth || window.innerWidth),
75
+ height: Math.min(window.innerHeight, root.clientHeight || window.innerHeight),
76
+ };
77
+ }
78
+
79
+ function AppearanceMenu({ id, labels, preference, deviceMode, activeIndex, triggerRef, rootRef, close, select, move }: AppearanceMenuProps) {
69
80
  const scope = getScope();
70
81
  const menuRef: Ref<HTMLDivElement> = { current: null };
71
82
  const itemRefs: Ref<HTMLButtonElement>[] = preferences.map(() => ({ current: null }));
72
- const placement = signal({ visibility: "hidden", left: "0px", top: "0px", right: "auto", bottom: "auto", margin: "0", width: "260px", "max-height": "none" });
83
+ const systemDescriptionId = `${id}-system-desc`;
84
+ const placement = signal({ visibility: "hidden", left: "0px", top: "0px", right: "auto", bottom: "auto", margin: "0", width: `${MENU_WIDTH_REM}rem`, "max-height": "none", overflow: "visible" });
85
+
86
+ // preventScroll keeps the document still, so reveal the row inside a last-resort scrolling menu by hand.
87
+ const focusItem = (index: number) => {
88
+ const item = itemRefs[index]?.current;
89
+ const menu = menuRef.current;
90
+ if (!item) return;
91
+ item.focus({ preventScroll: true });
92
+ if (!menu || menu.scrollHeight <= menu.clientHeight) return;
93
+ const bottom = item.offsetTop + item.offsetHeight;
94
+ if (item.offsetTop < menu.scrollTop) menu.scrollTop = item.offsetTop;
95
+ else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
96
+ };
73
97
 
74
98
  scope.onActivate(() => {
75
99
  const menu = menuRef.current;
@@ -78,37 +102,53 @@ function AppearanceMenu({ id, labels, preference, resolved, activeIndex, trigger
78
102
  const position = () => {
79
103
  const rect = triggerRef.current?.getBoundingClientRect();
80
104
  if (!rect) return;
81
- const gap = 8;
82
- const width = Math.max(0, Math.min(260, window.innerWidth - gap * 2));
83
- const desiredHeight = menu.scrollHeight || 280;
84
- const roomBelow = window.innerHeight - rect.bottom - gap * 2;
105
+ const gap = VIEWPORT_GAP;
106
+ const viewport = viewportSize();
107
+ const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
108
+ const width = Math.max(0, Math.min(MENU_WIDTH_REM * rootFontSize, viewport.width - gap * 2));
109
+ // Measure the natural border-box height at the final width before placing. Read it as
110
+ // content + chrome instead of lifting max-height, which would clamp a scrolled menu to 0.
111
+ menu.style.width = `${width}px`;
112
+ const height = menu.scrollHeight + menu.offsetHeight - menu.clientHeight;
113
+ const roomBelow = viewport.height - rect.bottom - gap * 2;
85
114
  const roomAbove = rect.top - gap * 2;
86
- const above = roomBelow < desiredHeight && roomAbove > roomBelow;
87
- const maxHeight = Math.max(80, Math.min(desiredHeight, above ? roomAbove : roomBelow));
88
- const left = Math.max(gap, Math.min(rect.right - width, window.innerWidth - width - gap));
89
- const top = above ? Math.max(gap, rect.top - gap - maxHeight) : rect.bottom + gap;
90
- placement.value = { visibility: "visible", left: `${left}px`, top: `${top}px`, right: "auto", bottom: "auto", margin: "0", width: `${width}px`, "max-height": `${maxHeight}px` };
115
+ const above = height > roomBelow && roomAbove > roomBelow;
116
+ const room = above ? roomAbove : roomBelow;
117
+ const fits = height <= viewport.height - gap * 2;
118
+ const shownHeight = fits ? height : viewport.height - gap * 2;
119
+ const preferredTop = above ? rect.top - gap - Math.min(height, room) : rect.bottom + gap;
120
+ const top = Math.max(gap, Math.min(preferredTop, viewport.height - gap - shownHeight));
121
+ const left = Math.max(gap, Math.min(rect.right - width, viewport.width - width - gap));
122
+ placement.value = {
123
+ visibility: "visible", left: `${left}px`, top: `${top}px`, right: "auto", bottom: "auto", margin: "0", width: `${width}px`,
124
+ // Scrolling is only a last resort when the natural menu is taller than the viewport.
125
+ "max-height": fits ? "none" : `${shownHeight}px`, overflow: fits ? "visible" : "auto",
126
+ };
91
127
  };
92
128
  const onPointerDown = (event: PointerEvent) => {
93
129
  if (!rootRef.current?.contains(event.target as Node) && !menu.contains(event.target as Node)) close();
94
130
  };
95
131
  const onNavigate = () => close();
132
+ const onScroll = (event: Event) => { if (event.target !== menu) position(); };
96
133
  position();
97
134
  // The placement signal commits visibility after this activation callback.
98
135
  // Focus only after the visible popover has reached the browser layout.
99
136
  const focusFrame = requestAnimationFrame(() => {
100
- if (!scope.abortSignal.aborted) itemRefs[activeIndex.value]?.current?.focus();
137
+ if (!scope.abortSignal.aborted) focusItem(activeIndex.value);
101
138
  });
139
+ const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => position());
140
+ resizeObserver?.observe(menu);
102
141
  document.addEventListener("pointerdown", onPointerDown);
103
142
  window.addEventListener("resize", position);
104
- window.addEventListener("scroll", position, true);
143
+ window.addEventListener("scroll", onScroll, true);
105
144
  document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
106
145
  return () => {
107
146
  cancelAnimationFrame(focusFrame);
147
+ resizeObserver?.disconnect();
108
148
  if (menu.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
109
149
  document.removeEventListener("pointerdown", onPointerDown);
110
150
  window.removeEventListener("resize", position);
111
- window.removeEventListener("scroll", position, true);
151
+ window.removeEventListener("scroll", onScroll, true);
112
152
  document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
113
153
  };
114
154
  });
@@ -126,26 +166,29 @@ function AppearanceMenu({ id, labels, preference, resolved, activeIndex, trigger
126
166
  keyboard.preventDefault();
127
167
  const next = keyboard.key === "Home" ? 0 : keyboard.key === "End" ? 2 : activeIndex.value + (keyboard.key === "ArrowDown" ? 1 : -1);
128
168
  move(next);
129
- itemRefs[(next + preferences.length) % preferences.length]?.current?.focus();
169
+ focusItem((next + preferences.length) % preferences.length);
130
170
  }
131
171
  };
132
172
 
133
173
  return <div ref={menuRef} id={id} role="menu" aria-label={labels.appearance}
134
174
  on:keydown={onMenuKeyDown}
135
- class="fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
175
+ class="fixed z-tooltip flex flex-col gap-hsp-2xs rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
136
176
  style={placement} popover="manual">
137
- <div class="px-hsp-sm py-vsp-xs text-small font-semibold" aria-hidden="true">{labels.appearance}</div>
138
177
  {preferences.map((option, index) => <button ref={itemRefs[index]} type="button"
139
- role="menuitemradio" aria-checked={computed(() => preference.value === option)}
178
+ role="menuitemradio" tabindex={-1} aria-checked={computed(() => preference.value === option)}
179
+ aria-describedby={option === "system" ? systemDescriptionId : undefined}
140
180
  on:focus={() => move(index)} on:click={() => select(option)}
141
- class={computed(() => `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference.value === option ? "bg-accent/10" : ""} hover:bg-accent/10 focus-visible:bg-accent/10 focus-visible:outline-2 focus-visible:outline-accent`)}>
181
+ class={computed(() => `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference.value === option ? "bg-accent/10" : "hover:bg-accent/5"} focus-visible:outline-2 focus-visible:outline-accent`)}>
142
182
  <PreferenceIcon preference={option} />
143
- <span class="flex-1">{labels[option]}</span>
144
- <span aria-hidden="true" class="text-accent">{computed(() => preference.value === option ? "✓" : "")}</span>
183
+ <span class="min-w-0 flex-1 break-words">{labels[option]}</span>
184
+ {option === "system"
185
+ ? <span aria-hidden="true" data-zd-device-mode="" class="shrink-0 text-caption text-muted">{computed(() => labels[deviceMode.value])}</span>
186
+ : null}
187
+ <span aria-hidden="true" class="w-[1.25rem] shrink-0 text-center text-accent">{computed(() => preference.value === option ? "✓" : "")}</span>
145
188
  </button>)}
146
- <div class="px-hsp-sm py-vsp-xs text-small text-muted">
147
- {computed(() => labels.systemHelper.replace("{mode}", labels[resolved.value]))}
148
- </div>
189
+ <span id={systemDescriptionId} hidden>
190
+ {computed(() => labels.systemHelper.replace("{mode}", labels[deviceMode.value]))}
191
+ </span>
149
192
  </div>;
150
193
  }
151
194
 
@@ -156,34 +199,35 @@ export function ThemeToggle({ defaultMode = "dark", respectPrefersColorScheme =
156
199
  const rootRef: Ref<HTMLDivElement> = { current: null };
157
200
  const triggerRef: Ref<HTMLButtonElement> = { current: null };
158
201
  const preference = signal<ThemePreference>(respectPrefersColorScheme ? "system" : defaultMode);
159
- const resolved = signal<ColorSchemeMode>(defaultMode);
202
+ const deviceMode = signal<ColorSchemeMode>(defaultMode);
160
203
  const open = signal(false);
161
204
  const activeIndex = signal(0);
162
205
  let menuId = "";
163
206
  const ensureMenuId = () => { if (!menuId) menuId = `zd-appearance-${++menuSequence}`; };
164
207
 
165
208
  scope.onActivate(() => {
166
- const sync = () => {
167
- preference.value = readThemePreference(defaultMode, respectPrefersColorScheme);
168
- resolved.value = readColorSchemeFromDom(defaultMode);
169
- };
209
+ const sync = () => { preference.value = readThemePreference(defaultMode, respectPrefersColorScheme); };
170
210
  sync();
211
+ // The device mode is independent of the page theme: it stays truthful under a manual Light/Dark choice.
212
+ const deviceQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
213
+ const syncDevice = () => { deviceMode.value = deviceQuery?.matches ? "dark" : "light"; };
214
+ if (deviceQuery) syncDevice();
215
+ deviceQuery?.addEventListener?.("change", syncDevice);
171
216
  const unsubscribePreference = subscribeThemePreferenceChanged(() => { sync(); open.value = false; });
172
217
  const unsubscribeScheme = subscribeColorSchemeChanged(sync);
173
- return () => { unsubscribePreference(); unsubscribeScheme(); };
218
+ return () => { unsubscribePreference(); unsubscribeScheme(); deviceQuery?.removeEventListener?.("change", syncDevice); };
174
219
  });
175
220
 
176
221
  const close = (restoreFocus = false) => {
177
222
  open.value = false;
178
223
  if (restoreFocus) requestAnimationFrame(() => {
179
- if (!scope.abortSignal.aborted && !open.value) triggerRef.current?.focus();
224
+ if (!scope.abortSignal.aborted && !open.value) triggerRef.current?.focus({ preventScroll: true });
180
225
  });
181
226
  };
182
227
  const select = (next: ThemePreference) => {
183
228
  close(true);
184
229
  applyThemePreference(next);
185
230
  preference.value = next;
186
- resolved.value = readColorSchemeFromDom(defaultMode);
187
231
  };
188
232
  const move = (index: number) => { activeIndex.value = (index + preferences.length) % preferences.length; };
189
233
  const show = (index: number) => { ensureMenuId(); move(index); open.value = true; };
@@ -210,7 +254,7 @@ export function ThemeToggle({ defaultMode = "dark", respectPrefersColorScheme =
210
254
  <Show when={computed(() => preference.value === "dark")}>{() => <PreferenceIcon preference="dark" />}</Show>
211
255
  <Show when={computed(() => preference.value === "system")}>{() => <PreferenceIcon preference="system" />}</Show>
212
256
  </button>
213
- <Show when={open}>{() => <AppearanceMenu id={menuId} labels={labels} preference={preference} resolved={resolved} activeIndex={activeIndex} triggerRef={triggerRef} rootRef={rootRef} close={close} select={select} move={move} />}</Show>
257
+ <Show when={open}>{() => <AppearanceMenu id={menuId} labels={labels} preference={preference} deviceMode={deviceMode} activeIndex={activeIndex} triggerRef={triggerRef} rootRef={rootRef} close={close} select={select} move={move} />}</Show>
214
258
  </div>;
215
259
  }
216
260
  ThemeToggle.displayName = "ThemeToggle";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "6.1.1",
3
+ "version": "7.0.0",
4
4
  "type": "module",
5
5
  "description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
6
6
  "license": "MIT",
@@ -14,6 +14,9 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/zudolab/zudo-doc/issues"
16
16
  },
17
+ "engines": {
18
+ "node": ">=24 <25"
19
+ },
17
20
  "keywords": [
18
21
  "zudo-doc",
19
22
  "documentation",
@@ -733,12 +736,12 @@
733
736
  "peerDependencies": {
734
737
  "@modelcontextprotocol/sdk": "1.32.1",
735
738
  "@takazudo/zdtp": "^0.5.2 || ^0.6.0 || ^0.7.0 || ^0.8.0",
736
- "@takazudo/zfb": "^4.3.3",
737
- "@takazudo/zfb-md-wasm": "^4.3.3",
738
- "@takazudo/zfb-runtime": "^4.3.3",
739
- "@takazudo/zudo-doc-history-server": "^6.0.0",
739
+ "@takazudo/zfb": "^4.5.2",
740
+ "@takazudo/zfb-md-wasm": "^4.5.2",
741
+ "@takazudo/zfb-runtime": "^4.5.2",
742
+ "@takazudo/zudo-doc-history-server": "^7.0.0",
740
743
  "diff": "^8.0.0",
741
- "katex": "^0.16.0",
744
+ "katex": "^0.18.2 || ^0.19.0",
742
745
  "zod": "^4.3.6"
743
746
  },
744
747
  "peerDependenciesMeta": {
@@ -769,16 +772,16 @@
769
772
  "parse5": "^8.0.1",
770
773
  "picocolors": "^1.1.1",
771
774
  "pluralize": "^8.0.0",
772
- "smol-toml": "^1.8.0",
775
+ "smol-toml": "^1.9.1",
773
776
  "tsx": "^4.21.0",
774
777
  "yaml": "^2.9.0"
775
778
  },
776
779
  "devDependencies": {
777
780
  "@modelcontextprotocol/sdk": "1.32.1",
778
781
  "@takazudo/mdx-formatter": "1.3.0",
779
- "@takazudo/zfb": "4.3.3",
780
- "@takazudo/zfb-md-wasm": "4.3.3",
781
- "@takazudo/zfb-runtime": "4.3.3",
782
+ "@takazudo/zfb": "4.5.2",
783
+ "@takazudo/zfb-md-wasm": "4.5.2",
784
+ "@takazudo/zfb-runtime": "4.5.2",
782
785
  "@types/fs-extra": "^11.0.4",
783
786
  "@types/minimist": "^1.2.5",
784
787
  "@types/node": "^25.3.5",
@@ -788,7 +791,7 @@
788
791
  "typescript": "^5.0.0",
789
792
  "vitest": "^4.1.0",
790
793
  "zod": "^4.3.6",
791
- "@takazudo/zudo-doc-history-server": "6.1.1"
794
+ "@takazudo/zudo-doc-history-server": "7.0.0"
792
795
  },
793
796
  "scripts": {
794
797
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
@@ -23,8 +23,7 @@
23
23
  "strictPropertyInitialization": true,
24
24
  "noImplicitThis": true,
25
25
  "useUnknownInCatchVariables": true,
26
- "alwaysStrict": true,
27
- "baseUrl": "."
26
+ "alwaysStrict": true
28
27
  },
29
28
  "files": ["./zfb-config-shim.d.ts", "./virtual-modules.d.ts"]
30
29
  }