@takazudo/zudo-doc 6.0.2 → 6.1.1

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
@@ -8,6 +8,22 @@ The format is based on Keep a Changelog, and release notes are generated from th
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## [6.1.1] - 2026-10-12
12
+
13
+ ### Other Changes
14
+
15
+ - Requires zfb 4.3.3: the `@takazudo/zfb`, `zfb-runtime` and `zfb-md-wasm` peer floors move from `^4.3.2` to `^4.3.3`. zfb 4.3.3 moves its YAML frontmatter parser past [RUSTSEC-2026-0333](https://rustsec.org/advisories/RUSTSEC-2026-0333); frontmatter accepted by 4.3.2 is still accepted. Upgrade the zfb family (and `zfb-adapter-cloudflare` when used) to 4.3.3 together with this release. (bbc70837e)
16
+
17
+ ## [6.1.0] - 2026-10-12
18
+
19
+ ### Features
20
+
21
+ - Adds `SidebarDefinition.initialPathMode?: "children" | "node"` (default `"children"`, unchanged behavior). With `"node"`, the node selected by `initialPath` is itself the fresh/Restore baseline: a page-backed section keeps its linked overview row together with its pages, while the complete editorial tree stays available, so Broaden moves to its real parent with no duplicate or invented row. Applies equally to `buildSidebarForSection` and `buildSidebarNavigation`, and to the desktop sidebar and mobile drawer. An unknown mode fails the build. (6fc2200a4)
22
+
23
+ ### Bug Fixes
24
+
25
+ - The sidebar now saves only the reader's explicit expand/collapse choices in `sessionStorage`. Automatically opening the current page's path on soft navigation, Back/Forward or at narrow viewports is no longer persisted, so later hard loads no longer show unrelated branches expanded. The reader can collapse the branch containing the current page; it stays collapsed for that page (hash changes, filtering and same-page reload included) and the next page reveals its own path. Restore clears the selected scope and saved choices, keeps the filter text, and updates the desktop sidebar and mobile drawer together. Expansion saved by earlier versions is discarded once on first load; a saved scope and filter text are kept. (98d1d223b, 1bbaa5f26, fbd694422)
26
+
11
27
  ## [6.0.2] - 2026-10-12
12
28
 
13
29
  ### Other Changes
package/README.md CHANGED
@@ -109,7 +109,7 @@ If your project renders **semi-trusted or user-submitted** HTML in a preview, ov
109
109
 
110
110
  **6.0.0** is the coordinated major release of `@takazudo/zudo-doc`,
111
111
  `@takazudo/zudo-doc-history-server` and `create-zudo-doc`. It targets the
112
- published **zfb 4.3.2** family (peer floor **^4.3.2**).
112
+ published **zfb 4.3.3** family (peer floor **^4.3.3**).
113
113
  [Upstream #4097](https://github.com/Takazudo/zudo-front-builder/issues/4097) is
114
114
  resolved; its strict published 4.3.1 consumer control passed 3/3 with no skips or
115
115
  retries in the [source-bound verification](../../docs/findings/zfb-4.3.1/verification.md).
@@ -555,7 +555,7 @@ function TrayGroupNode({
555
555
  () => group.value.items.some((item) => item.slug === currentSlug.value)
556
556
  );
557
557
  const occurrenceKey = computed(() => `${trayOccurrence.value}#${group.value.key}`);
558
- const open = signal(scopeControls?.expansion.value[occurrenceKey.value] ?? (scopeControls?.defaultGroupOpen(group.value.items) ?? containsCurrent.value));
558
+ const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items)) : containsCurrent.value);
559
559
  const storageKey = computed(
560
560
  () => noteTrayGroupStorageKey(traySlug.value, group.value.key)
561
561
  );
@@ -566,7 +566,7 @@ function TrayGroupNode({
566
566
  if (!scopeControls && getOpenSet().has(storageKey.value)) open.value = true;
567
567
  });
568
568
  scope.effect(() => {
569
- if (containsCurrent.value) open.value = true;
569
+ if (!scopeControls && containsCurrent.value) open.value = true;
570
570
  });
571
571
  scope.effect(() => {
572
572
  if (!scopeControls && open.value) {
@@ -578,20 +578,18 @@ function TrayGroupNode({
578
578
  }
579
579
  });
580
580
  const toggle = () => {
581
- open.value = !open.value;
582
581
  if (scopeControls) {
583
- scopeControls.expansion.value = { ...scopeControls.expansion.value, [occurrenceKey.value]: open.value };
584
- scopeControls.save();
582
+ scopeControls.setOpen(occurrenceKey.value, !open.value);
585
583
  return;
586
584
  }
585
+ open.value = !open.value;
587
586
  const stored = getOpenSet();
588
587
  if (open.value) stored.add(storageKey.value);
589
588
  else stored.delete(storageKey.value);
590
589
  saveOpenSet(stored);
591
590
  };
592
591
  scope.effect(() => {
593
- const stored = scopeControls?.expansion.value[occurrenceKey.value];
594
- if (scopeControls) open.value = stored ?? scopeControls.defaultGroupOpen(group.value.items);
592
+ if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items));
595
593
  });
596
594
  const expanded = computed(() => forceOpen.value || open.value);
597
595
  const items = computed(() => group.value.items);
@@ -711,15 +709,13 @@ function CategoryNode({
711
709
  );
712
710
  const active = computed(() => node.value.slug === currentSlug.value);
713
711
  const occurrenceKey = () => node.value.occurrenceId ?? node.value.slug;
714
- const open = signal(scopeControls?.expansion.value[occurrenceKey()] ?? (scopeControls?.defaultOpen(node.value) ?? (containsCurrent.value || !node.value.collapsed)));
712
+ const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value)) : containsCurrent.value || !node.value.collapsed);
715
713
  scope.onActivate(() => {
716
714
  if (!scopeControls && getOpenSet().has(node.value.slug)) open.value = true;
717
715
  });
718
716
  scope.effect(() => {
719
717
  const slug = currentSlug.value;
720
- const revision = scopeControls?.reveal.value;
721
- if (slug !== void 0 && subtreeContainsSlug(node.value, slug)) open.value = true;
722
- void revision;
718
+ if (!scopeControls && slug !== void 0 && subtreeContainsSlug(node.value, slug)) open.value = true;
723
719
  });
724
720
  scope.effect(() => {
725
721
  if (!scopeControls && open.value) {
@@ -731,20 +727,18 @@ function CategoryNode({
731
727
  }
732
728
  });
733
729
  const toggle = () => {
734
- open.value = !open.value;
735
730
  if (scopeControls) {
736
- scopeControls.expansion.value = { ...scopeControls.expansion.value, [occurrenceKey()]: open.value };
737
- scopeControls.save();
731
+ scopeControls.setOpen(occurrenceKey(), !open.value);
738
732
  return;
739
733
  }
734
+ open.value = !open.value;
740
735
  const stored = getOpenSet();
741
736
  if (open.value) stored.add(node.value.slug);
742
737
  else stored.delete(node.value.slug);
743
738
  saveOpenSet(stored);
744
739
  };
745
740
  scope.effect(() => {
746
- const stored = scopeControls?.expansion.value[occurrenceKey()];
747
- if (scopeControls) open.value = stored ?? scopeControls.defaultOpen(node.value);
741
+ if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value));
748
742
  });
749
743
  const expanded = computed(() => forceOpen.value || open.value);
750
744
  const focusButton = scopeControls ? /* @__PURE__ */ jsx(
@@ -3,16 +3,19 @@ import type { SidebarNavNode, SidebarNavigationContext } from "../sidebar/types.
3
3
  export interface ScopeControls {
4
4
  focus: (node: SidebarNavNode) => void;
5
5
  focusLabel: string;
6
- expansion: {
7
- value: Record<string, boolean>;
8
- };
9
- reveal: ReadonlySignal<number>;
10
- save: () => void;
6
+ /** Effective disclosure: the user's explicit choice, else revealed/scope root, else `baseline`. */
7
+ isOpen: (key: string, baseline: boolean) => boolean;
8
+ /** Records an explicit user disclosure choice. */
9
+ setOpen: (key: string, open: boolean) => void;
11
10
  defaultOpen: (node: SidebarNavNode) => boolean;
12
11
  defaultGroupOpen: (items: SidebarNavNode[]) => boolean;
13
12
  }
14
13
  /** One controller shared by the real desktop tree and mobile drawer. SSR always
15
- * starts with configured roots; session state is applied only on activation. */
14
+ * starts with configured roots; session state is applied only on activation.
15
+ * `expansion` holds explicit user choices only. The active path and the
16
+ * visible scope's sole root are derived, other scope-change reveal stays in
17
+ * memory, and a reveal removes conflicting explicit collapses instead of
18
+ * writing automatic entries. */
16
19
  export declare function useSidebarScope({ nodes, navigation, activeSlug, query, locale }: {
17
20
  nodes: SidebarNavNode[];
18
21
  navigation?: SidebarNavigationContext;
@@ -32,7 +35,7 @@ export declare function useSidebarScope({ nodes, navigation, activeSlug, query,
32
35
  };
33
36
  canRestore: ReadonlySignal<boolean>;
34
37
  changeScope: (id: string | null) => void;
35
- save: () => void;
38
+ save: (broadcast?: boolean) => void;
36
39
  navRef: Ref<HTMLElement>;
37
40
  scopeControls: ScopeControls | undefined;
38
41
  };
@@ -2,10 +2,12 @@ import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
2
2
  import { broaderSidebarScope, sidebarScopeNodes, reconcileSidebarScope, indexSidebarOccurrences, sidebarInitialExpansion, sidebarBaselineOpen, SIDEBAR_FOREST_SCOPE } from "../sidebar-utils/index.js";
3
3
  import { getNoteTrayItems, groupItems } from "../note-tray-model/index.js";
4
4
  import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
5
+ const STATE_VERSION = 2;
6
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
7
  function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
6
8
  const selected = signal(null);
7
9
  const expansion = signal({});
8
- const reveal = signal(0);
10
+ const revealed = signal(/* @__PURE__ */ new Set());
9
11
  let lastPage = activeSlug.value;
10
12
  const navRef = { current: null };
11
13
  const stateKey = "zd-sidebar-scope";
@@ -30,47 +32,59 @@ function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
30
32
  const groupKeys = new Set(trayGroups.map((group) => group.key));
31
33
  const defaultOpen = (node) => sidebarBaselineOpen(node, navigation ? sidebarInitialExpansion(navigation, activeSlug.value) : void 0, activeSlug.value);
32
34
  const defaultGroupOpen = (items) => items.some((item) => item.slug === activeSlug.value) || !!navigation && sidebarInitialExpansion(navigation, activeSlug.value) === "expanded";
35
+ const baselineOf = (key) => {
36
+ const node = occurrenceIndex.get(key);
37
+ if (node) return node.children.length ? defaultOpen(node) : void 0;
38
+ const group = trayGroups.find((candidate) => candidate.key === key);
39
+ return group ? defaultGroupOpen(group.items) : void 0;
40
+ };
41
+ const scopeRoot = computed(() => selectedNodes.value.length === 1 ? selectedNodes.value[0].occurrenceId : void 0);
42
+ const automaticOpen = (key, baseline) => revealed.value.has(key) || key === scopeRoot.value || baseline;
33
43
  const canRestore = computed(() => {
34
44
  if (JSON.stringify(selectedNodes.value) !== JSON.stringify(nodes)) return true;
35
- for (const [id, node] of occurrenceIndex) {
36
- if (node.children.length && (expansion.value[id] ?? defaultOpen(node)) !== defaultOpen(node)) return true;
37
- }
38
- return trayGroups.some((group) => (expansion.value[group.key] ?? defaultGroupOpen(group.items)) !== defaultGroupOpen(group.items));
45
+ const differs = Object.entries(expansion.value).some(([key, open]) => {
46
+ const baseline = baselineOf(key);
47
+ return baseline !== void 0 && open !== automaticOpen(key, baseline);
48
+ });
49
+ return differs || [...revealed.value].some((key) => expansion.value[key] === void 0 && baselineOf(key) === false);
39
50
  });
40
- const revealPath = (id) => {
41
- if (!navigation) return;
51
+ const canonicalScope = (id) => id !== null && navigation && (nodes.length === 1 && nodes[0].occurrenceId === id || JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes)) ? null : id;
52
+ const withAncestors = (id) => {
53
+ const chain = [];
54
+ for (let ancestor = id; ancestor && ancestor !== SIDEBAR_FOREST_SCOPE; ancestor = navigation?.parents[ancestor] ?? null) chain.push(ancestor);
55
+ return chain;
56
+ };
57
+ const withoutCollapses = (keys) => {
42
58
  const next = { ...expansion.value };
43
- for (const [key, node] of occurrenceIndex) {
44
- if (node.slug !== activeSlug.value && key !== id) continue;
45
- let ancestor = key;
46
- while (ancestor) {
47
- next[ancestor] = true;
48
- ancestor = navigation.parents[ancestor];
49
- }
50
- }
51
- for (const group of trayGroups) {
52
- if (group.items.some((item) => item.slug === activeSlug.value)) next[group.key] = true;
53
- }
59
+ for (const key of keys) if (next[key] === false) delete next[key];
54
60
  expansion.value = next;
55
- reveal.value++;
56
61
  };
57
- const save = () => {
62
+ const arrive = () => {
63
+ revealed.value = /* @__PURE__ */ new Set();
64
+ withoutCollapses([
65
+ ...[...occurrenceIndex].filter(([, node]) => node.slug === activeSlug.value).flatMap(([key]) => withAncestors(key)),
66
+ ...trayGroups.filter((group) => group.items.some((item) => item.slug === activeSlug.value)).map((group) => group.key),
67
+ ...scopeRoot.value ? [scopeRoot.value] : []
68
+ ]);
69
+ };
70
+ const save = (broadcast = true) => {
58
71
  if (!navigation) return;
72
+ const state = { version: STATE_VERSION, context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value };
59
73
  try {
60
- sessionStorage.setItem(stateKey, JSON.stringify({ context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value }));
74
+ sessionStorage.setItem(stateKey, JSON.stringify(state));
61
75
  } catch {
62
76
  }
63
- document.dispatchEvent(new CustomEvent(syncEvent, { detail: { context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value } }));
77
+ if (broadcast) document.dispatchEvent(new CustomEvent(syncEvent, { detail: { ...state, revealed: [...revealed.value] } }));
64
78
  };
65
79
  const changeScope = (id) => {
66
80
  const previous = selected.value;
67
- selected.value = navigation && JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes) ? null : id;
81
+ selected.value = canonicalScope(id);
68
82
  if (id === null) {
69
83
  expansion.value = {};
70
- revealPath();
84
+ revealed.value = /* @__PURE__ */ new Set();
71
85
  } else {
72
- revealPath(previous);
73
- revealPath(id);
86
+ withoutCollapses(withAncestors(id));
87
+ revealed.value = /* @__PURE__ */ new Set([...revealed.value, ...withAncestors(previous), ...withAncestors(id)]);
74
88
  }
75
89
  save();
76
90
  queueMicrotask(() => {
@@ -101,40 +115,47 @@ function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
101
115
  if (node.occurrenceId) changeScope(node.occurrenceId);
102
116
  },
103
117
  focusLabel: labels.focus,
104
- expansion,
105
- reveal,
106
118
  defaultOpen,
107
119
  defaultGroupOpen,
108
- save
120
+ isOpen: (key, baseline) => expansion.value[key] ?? automaticOpen(key, baseline),
121
+ setOpen: (key, open) => {
122
+ expansion.value = { ...expansion.value, [key]: open };
123
+ save();
124
+ }
109
125
  } : void 0;
110
126
  getScope().onActivate(() => {
111
127
  if (!navigation) return;
112
128
  lastPage = activeSlug.value;
129
+ let samePage = false;
113
130
  try {
114
131
  const stored = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
115
- if (stored?.context === navigation.id) {
132
+ if (isRecord(stored) && stored.context === navigation.id) {
116
133
  const candidate = typeof stored.selected === "string" ? stored.selected : null;
117
134
  const valid = candidate === SIDEBAR_FOREST_SCOPE || candidate !== null && occurrenceIndex.has(candidate) ? candidate : null;
118
- selected.value = stored.page === activeSlug.value ? valid : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value);
135
+ samePage = stored.page === activeSlug.value;
136
+ selected.value = canonicalScope(samePage ? valid : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value));
119
137
  query.value = typeof stored.query === "string" ? stored.query : "";
120
- if (stored.expansion && typeof stored.expansion === "object") expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean"));
121
- if (stored.page !== activeSlug.value) revealPath(selected.value);
138
+ if (stored.version === STATE_VERSION && isRecord(stored.expansion)) {
139
+ expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean"));
140
+ }
122
141
  }
123
142
  } catch {
124
143
  }
125
- save();
144
+ if (!samePage) arrive();
145
+ save(false);
126
146
  const sync = (event) => {
127
147
  const state = event.detail;
128
148
  if (state?.context !== navigation.id || state.page !== activeSlug.value) return;
129
149
  selected.value = state.selected;
130
150
  query.value = state.query;
131
151
  expansion.value = state.expansion;
152
+ revealed.value = new Set(state.revealed);
132
153
  };
133
154
  const navigate = () => {
134
155
  if (lastPage === activeSlug.value) return;
135
156
  lastPage = activeSlug.value;
136
- selected.value = reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value);
137
- revealPath(selected.value);
157
+ selected.value = canonicalScope(reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value));
158
+ arrive();
138
159
  save();
139
160
  };
140
161
  document.addEventListener(syncEvent, sync);
@@ -30,6 +30,12 @@ export interface SidebarDefinition {
30
30
  items: SidebarItem[];
31
31
  /** Resolved occurrence indexes from the forest to a category (not URL segments). */
32
32
  initialPath?: number[];
33
+ /**
34
+ * What `initialPath` selects as the fresh/Restore baseline: the target's
35
+ * `"children"`, or the target `"node"` itself (its row, link and children).
36
+ * @default "children"
37
+ */
38
+ initialPathMode?: "children" | "node";
33
39
  /** Share across sections using the same editorial tree. Locale/version are added by chrome. */
34
40
  contextId?: string;
35
41
  }
@@ -82,12 +82,19 @@ function buildSidebarForSection(docs, lang, categoryMatch, categoryMeta, sidebar
82
82
  return filterByCategory(tree, categoryMatch, explicitCategoryPrefixes);
83
83
  }
84
84
  if (Array.isArray(config)) return resolveItems(config, tree, lang);
85
- let nodes = resolveItems(config.items, tree, lang);
86
- for (const index of config.initialPath ?? []) {
87
- if (!Number.isInteger(index) || index < 0 || !nodes[index]) throw new Error(`Invalid sidebar initialPath for ${sectionKey}`);
88
- nodes = nodes[index].children;
85
+ return followInitialPath(resolveItems(config.items, tree, lang), config, sectionKey).nodes;
86
+ }
87
+ function followInitialPath(forest, definition, sectionKey) {
88
+ const mode = definition?.initialPathMode;
89
+ if (mode !== void 0 && mode !== "children" && mode !== "node") throw new Error(`Invalid sidebar initialPathMode for ${sectionKey}`);
90
+ let nodes = forest;
91
+ let target;
92
+ for (const index of definition?.initialPath ?? []) {
93
+ target = nodes[index];
94
+ if (!Number.isInteger(index) || index < 0 || !target) throw new Error(`Invalid sidebar initialPath for ${sectionKey}`);
95
+ nodes = target.children;
89
96
  }
90
- return nodes;
97
+ return { nodes: target && definition?.initialPathMode === "node" ? [target] : nodes, target };
91
98
  }
92
99
  function buildSidebarNavigation(...args) {
93
100
  const [docs, lang, categoryMatch, meta, sidebars, buildTree, prefixes] = args;
@@ -158,13 +165,9 @@ function buildSidebarNavigation(...args) {
158
165
  roots = result.canonical;
159
166
  nodes = result.visible;
160
167
  }
161
- let localParentId;
162
- for (const index of definition?.initialPath ?? []) {
163
- const node = nodes[index];
164
- if (!Number.isInteger(index) || index < 0 || !node) throw new Error(`Invalid sidebar initialPath for ${categoryMatch ?? "default"}`);
165
- localParentId = node.occurrenceId;
166
- nodes = node.children;
167
- }
168
+ const { nodes: local, target } = followInitialPath(nodes, definition, categoryMatch ?? "default");
169
+ nodes = local;
170
+ const localParentId = definition?.initialPathMode === "node" && target?.occurrenceId ? parents[target.occurrenceId] ?? void 0 : target?.occurrenceId;
168
171
  let hash = 2166136261;
169
172
  const serialized = JSON.stringify(Object.keys(initialExpansion).length ? [roots, initialExpansion] : roots);
170
173
  for (let i = 0; i < serialized.length; i++) hash = Math.imul(hash ^ serialized.charCodeAt(i), 16777619);
@@ -659,7 +659,7 @@ function TrayGroupNode({
659
659
  group.value.items.some((item) => item.slug === currentSlug.value),
660
660
  );
661
661
  const occurrenceKey = computed(() => `${trayOccurrence.value}#${group.value.key}`);
662
- const open = signal(scopeControls?.expansion.value[occurrenceKey.value] ?? (scopeControls?.defaultGroupOpen(group.value.items) ?? containsCurrent.value));
662
+ const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items)) : containsCurrent.value);
663
663
  const storageKey = computed(() =>
664
664
  noteTrayGroupStorageKey(traySlug.value, group.value.key),
665
665
  );
@@ -672,7 +672,7 @@ function TrayGroupNode({
672
672
  if (!scopeControls && getOpenSet().has(storageKey.value)) open.value = true;
673
673
  });
674
674
  scope.effect(() => {
675
- if (containsCurrent.value) open.value = true;
675
+ if (!scopeControls && containsCurrent.value) open.value = true;
676
676
  });
677
677
  scope.effect(() => {
678
678
  if (!scopeControls && open.value) {
@@ -684,20 +684,18 @@ function TrayGroupNode({
684
684
  }
685
685
  });
686
686
  const toggle = () => {
687
- open.value = !open.value;
688
687
  if (scopeControls) {
689
- scopeControls.expansion.value = { ...scopeControls.expansion.value, [occurrenceKey.value]: open.value };
690
- scopeControls.save();
688
+ scopeControls.setOpen(occurrenceKey.value, !open.value);
691
689
  return;
692
690
  }
691
+ open.value = !open.value;
693
692
  const stored = getOpenSet();
694
693
  if (open.value) stored.add(storageKey.value);
695
694
  else stored.delete(storageKey.value);
696
695
  saveOpenSet(stored);
697
696
  };
698
697
  scope.effect(() => {
699
- const stored = scopeControls?.expansion.value[occurrenceKey.value];
700
- if (scopeControls) open.value = stored ?? scopeControls.defaultGroupOpen(group.value.items);
698
+ if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items));
701
699
  });
702
700
  const expanded = computed(() => forceOpen.value || open.value);
703
701
  const items = computed(() => group.value.items);
@@ -827,15 +825,13 @@ function CategoryNode({
827
825
  );
828
826
  const active = computed(() => node.value.slug === currentSlug.value);
829
827
  const occurrenceKey = () => node.value.occurrenceId ?? node.value.slug;
830
- const open = signal(scopeControls?.expansion.value[occurrenceKey()] ?? (scopeControls?.defaultOpen(node.value) ?? (containsCurrent.value || !node.value.collapsed)));
828
+ const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value)) : containsCurrent.value || !node.value.collapsed);
831
829
  scope.onActivate(() => {
832
830
  if (!scopeControls && getOpenSet().has(node.value.slug)) open.value = true;
833
831
  });
834
832
  scope.effect(() => {
835
833
  const slug = currentSlug.value;
836
- const revision = scopeControls?.reveal.value;
837
- if (slug !== undefined && subtreeContainsSlug(node.value, slug)) open.value = true;
838
- void revision;
834
+ if (!scopeControls && slug !== undefined && subtreeContainsSlug(node.value, slug)) open.value = true;
839
835
  });
840
836
  scope.effect(() => {
841
837
  if (!scopeControls && open.value) {
@@ -847,20 +843,18 @@ function CategoryNode({
847
843
  }
848
844
  });
849
845
  const toggle = () => {
850
- open.value = !open.value;
851
846
  if (scopeControls) {
852
- scopeControls.expansion.value = { ...scopeControls.expansion.value, [occurrenceKey()]: open.value };
853
- scopeControls.save();
847
+ scopeControls.setOpen(occurrenceKey(), !open.value);
854
848
  return;
855
849
  }
850
+ open.value = !open.value;
856
851
  const stored = getOpenSet();
857
852
  if (open.value) stored.add(node.value.slug);
858
853
  else stored.delete(node.value.slug);
859
854
  saveOpenSet(stored);
860
855
  };
861
856
  scope.effect(() => {
862
- const stored = scopeControls?.expansion.value[occurrenceKey()];
863
- if (scopeControls) open.value = stored ?? scopeControls.defaultOpen(node.value);
857
+ if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value));
864
858
  });
865
859
  const expanded = computed(() => forceOpen.value || open.value);
866
860
  const focusButton = scopeControls ? (
@@ -7,15 +7,27 @@ import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
7
7
  export interface ScopeControls {
8
8
  focus: (node: SidebarNavNode) => void;
9
9
  focusLabel: string;
10
- expansion: { value: Record<string, boolean> };
11
- reveal: ReadonlySignal<number>;
12
- save: () => void;
10
+ /** Effective disclosure: the user's explicit choice, else revealed/scope root, else `baseline`. */
11
+ isOpen: (key: string, baseline: boolean) => boolean;
12
+ /** Records an explicit user disclosure choice. */
13
+ setOpen: (key: string, open: boolean) => void;
13
14
  defaultOpen: (node: SidebarNavNode) => boolean;
14
15
  defaultGroupOpen: (items: SidebarNavNode[]) => boolean;
15
16
  }
16
17
 
18
+ // Version 1 records (no `version`) mixed automatic reveal into `expansion`
19
+ // without provenance, so their expansion is discarded once on read.
20
+ const STATE_VERSION = 2;
21
+
22
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
23
+ typeof value === "object" && value !== null && !Array.isArray(value);
24
+
17
25
  /** One controller shared by the real desktop tree and mobile drawer. SSR always
18
- * starts with configured roots; session state is applied only on activation. */
26
+ * starts with configured roots; session state is applied only on activation.
27
+ * `expansion` holds explicit user choices only. The active path and the
28
+ * visible scope's sole root are derived, other scope-change reveal stays in
29
+ * memory, and a reveal removes conflicting explicit collapses instead of
30
+ * writing automatic entries. */
19
31
  export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }: {
20
32
  nodes: SidebarNavNode[];
21
33
  navigation?: SidebarNavigationContext;
@@ -25,7 +37,7 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
25
37
  }) {
26
38
  const selected = signal<string | null>(null);
27
39
  const expansion = signal<Record<string, boolean>>({});
28
- const reveal = signal(0);
40
+ const revealed = signal<ReadonlySet<string>>(new Set());
29
41
  let lastPage = activeSlug.value;
30
42
  const navRef: Ref<HTMLElement> = { current: null };
31
43
  const stateKey = "zd-sidebar-scope";
@@ -47,42 +59,63 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
47
59
  const groupKeys = new Set(trayGroups.map((group) => group.key));
48
60
  const defaultOpen = (node: SidebarNavNode) => sidebarBaselineOpen(node, navigation ? sidebarInitialExpansion(navigation, activeSlug.value) : undefined, activeSlug.value);
49
61
  const defaultGroupOpen = (items: SidebarNavNode[]) => items.some((item) => item.slug === activeSlug.value) || (!!navigation && sidebarInitialExpansion(navigation, activeSlug.value) === "expanded");
62
+ const baselineOf = (key: string) => {
63
+ const node = occurrenceIndex.get(key);
64
+ if (node) return node.children.length ? defaultOpen(node) : undefined;
65
+ const group = trayGroups.find((candidate) => candidate.key === key);
66
+ return group ? defaultGroupOpen(group.items) : undefined;
67
+ };
68
+ const scopeRoot = computed(() => selectedNodes.value.length === 1 ? selectedNodes.value[0]!.occurrenceId : undefined);
69
+ const automaticOpen = (key: string, baseline: boolean) => revealed.value.has(key) || key === scopeRoot.value || baseline;
50
70
  const canRestore = computed(() => {
51
71
  if (JSON.stringify(selectedNodes.value) !== JSON.stringify(nodes)) return true;
52
- for (const [id, node] of occurrenceIndex) {
53
- if (node.children.length && (expansion.value[id] ?? defaultOpen(node)) !== defaultOpen(node)) return true;
54
- }
55
- return trayGroups.some((group) => (expansion.value[group.key] ?? defaultGroupOpen(group.items)) !== defaultGroupOpen(group.items));
72
+ const differs = Object.entries(expansion.value).some(([key, open]) => {
73
+ const baseline = baselineOf(key);
74
+ return baseline !== undefined && open !== automaticOpen(key, baseline);
75
+ });
76
+ return differs || [...revealed.value].some((key) => expansion.value[key] === undefined && baselineOf(key) === false);
56
77
  });
57
- const revealPath = (id?: string | null) => {
58
- if (!navigation) return;
78
+ // A stored or chosen scope whose view equals the destination baseline is the baseline.
79
+ const canonicalScope = (id: string | null) =>
80
+ id !== null && navigation && ((nodes.length === 1 && nodes[0]!.occurrenceId === id)
81
+ || JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes)) ? null : id;
82
+ const withAncestors = (id: string | null) => {
83
+ const chain: string[] = [];
84
+ for (let ancestor = id; ancestor && ancestor !== SIDEBAR_FOREST_SCOPE; ancestor = navigation?.parents[ancestor] ?? null) chain.push(ancestor);
85
+ return chain;
86
+ };
87
+ const withoutCollapses = (keys: Iterable<string>) => {
59
88
  const next = { ...expansion.value };
60
- for (const [key, node] of occurrenceIndex) {
61
- if (node.slug !== activeSlug.value && key !== id) continue;
62
- let ancestor: string | null | undefined = key;
63
- while (ancestor) { next[ancestor] = true; ancestor = navigation.parents[ancestor]; }
64
- }
65
- for (const group of trayGroups) {
66
- if (group.items.some((item) => item.slug === activeSlug.value)) next[group.key] = true;
67
- }
89
+ for (const key of keys) if (next[key] === false) delete next[key];
68
90
  expansion.value = next;
69
- reveal.value++;
70
91
  };
71
- const save = () => {
92
+ // Destination arrival reveals the active path and scope root by dropping
93
+ // explicit collapses there. Deterministic, so every controller converges.
94
+ const arrive = () => {
95
+ revealed.value = new Set();
96
+ withoutCollapses([
97
+ ...[...occurrenceIndex].filter(([, node]) => node.slug === activeSlug.value).flatMap(([key]) => withAncestors(key)),
98
+ ...trayGroups.filter((group) => group.items.some((item) => item.slug === activeSlug.value)).map((group) => group.key),
99
+ ...(scopeRoot.value ? [scopeRoot.value] : []),
100
+ ]);
101
+ };
102
+ const save = (broadcast = true) => {
72
103
  if (!navigation) return;
73
- try { sessionStorage.setItem(stateKey, JSON.stringify({ context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value })); } catch { /* Optional persistence. */ }
74
- document.dispatchEvent(new CustomEvent(syncEvent, { detail: { context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value } }));
104
+ const state = { version: STATE_VERSION, context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value };
105
+ try { sessionStorage.setItem(stateKey, JSON.stringify(state)); } catch { /* Optional persistence. */ }
106
+ if (broadcast) document.dispatchEvent(new CustomEvent(syncEvent, { detail: { ...state, revealed: [...revealed.value] } }));
75
107
  };
76
108
  const changeScope = (id: string | null) => {
77
109
  const previous = selected.value;
78
- selected.value = navigation && JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes) ? null : id;
110
+ selected.value = canonicalScope(id);
79
111
  if (id === null) {
80
- // Restore the authored collapse defaults, then reveal the active path.
112
+ // Restore the configured baseline; the active path is open by default.
81
113
  expansion.value = {};
82
- revealPath();
114
+ revealed.value = new Set();
83
115
  } else {
84
- revealPath(previous);
85
- revealPath(id);
116
+ // Focus opens its target; the scope being left keeps explicit choices.
117
+ withoutCollapses(withAncestors(id));
118
+ revealed.value = new Set([...revealed.value, ...withAncestors(previous), ...withAncestors(id)]);
86
119
  }
87
120
  save();
88
121
  // Scroll only the sidebar's own viewport; never scrollIntoView, which also
@@ -112,38 +145,48 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
112
145
  };
113
146
  const scopeControls: ScopeControls | undefined = navigation ? {
114
147
  focus: (node) => { if (node.occurrenceId) changeScope(node.occurrenceId); },
115
- focusLabel: labels.focus, expansion, reveal, defaultOpen, defaultGroupOpen,
116
- save,
148
+ focusLabel: labels.focus, defaultOpen, defaultGroupOpen,
149
+ isOpen: (key, baseline) => expansion.value[key] ?? automaticOpen(key, baseline),
150
+ setOpen: (key, open) => {
151
+ expansion.value = { ...expansion.value, [key]: open };
152
+ save();
153
+ },
117
154
  } : undefined;
118
155
  getScope().onActivate(() => {
119
156
  if (!navigation) return;
120
157
  lastPage = activeSlug.value;
158
+ let samePage = false;
121
159
  try {
122
- const stored = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
123
- if (stored?.context === navigation.id) {
160
+ const stored: unknown = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
161
+ if (isRecord(stored) && stored.context === navigation.id) {
124
162
  const candidate = typeof stored.selected === "string" ? stored.selected : null;
125
163
  const valid = candidate === SIDEBAR_FOREST_SCOPE || (candidate !== null && occurrenceIndex.has(candidate)) ? candidate : null;
126
- selected.value = stored.page === activeSlug.value
127
- ? valid
128
- : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value);
164
+ samePage = stored.page === activeSlug.value;
165
+ selected.value = canonicalScope(samePage ? valid : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value));
129
166
  query.value = typeof stored.query === "string" ? stored.query : "";
130
- if (stored.expansion && typeof stored.expansion === "object") expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean")) as Record<string, boolean>;
131
- if (stored.page !== activeSlug.value) revealPath(selected.value);
167
+ if (stored.version === STATE_VERSION && isRecord(stored.expansion)) {
168
+ expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean")) as Record<string, boolean>;
169
+ }
132
170
  }
133
171
  } catch { /* Stale or unavailable storage uses the configured baseline. */ }
134
- save();
172
+ // Same-page remount/reload keeps explicit choices, including an active-path collapse.
173
+ if (!samePage) arrive();
174
+ // Persist without broadcasting: a late-activating controller must not reset
175
+ // a live peer's in-memory reveal; peers handle arrival themselves.
176
+ save(false);
135
177
  const sync = (event: Event) => {
136
178
  const state = (event as CustomEvent).detail;
137
179
  if (state?.context !== navigation.id || state.page !== activeSlug.value) return;
138
180
  selected.value = state.selected;
139
181
  query.value = state.query;
140
182
  expansion.value = state.expansion;
183
+ revealed.value = new Set(state.revealed);
141
184
  };
142
185
  const navigate = () => {
143
186
  if (lastPage === activeSlug.value) return;
144
187
  lastPage = activeSlug.value;
145
- selected.value = reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value);
146
- revealPath(selected.value);
188
+ selected.value = canonicalScope(reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value));
189
+ arrive();
147
190
  save();
148
191
  };
149
192
  document.addEventListener(syncEvent, sync);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "6.0.2",
3
+ "version": "6.1.1",
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",
@@ -733,9 +733,9 @@
733
733
  "peerDependencies": {
734
734
  "@modelcontextprotocol/sdk": "1.32.1",
735
735
  "@takazudo/zdtp": "^0.5.2 || ^0.6.0 || ^0.7.0 || ^0.8.0",
736
- "@takazudo/zfb": "^4.3.2",
737
- "@takazudo/zfb-md-wasm": "^4.3.2",
738
- "@takazudo/zfb-runtime": "^4.3.2",
736
+ "@takazudo/zfb": "^4.3.3",
737
+ "@takazudo/zfb-md-wasm": "^4.3.3",
738
+ "@takazudo/zfb-runtime": "^4.3.3",
739
739
  "@takazudo/zudo-doc-history-server": "^6.0.0",
740
740
  "diff": "^8.0.0",
741
741
  "katex": "^0.16.0",
@@ -776,9 +776,9 @@
776
776
  "devDependencies": {
777
777
  "@modelcontextprotocol/sdk": "1.32.1",
778
778
  "@takazudo/mdx-formatter": "1.3.0",
779
- "@takazudo/zfb": "4.3.2",
780
- "@takazudo/zfb-md-wasm": "4.3.2",
781
- "@takazudo/zfb-runtime": "4.3.2",
779
+ "@takazudo/zfb": "4.3.3",
780
+ "@takazudo/zfb-md-wasm": "4.3.3",
781
+ "@takazudo/zfb-runtime": "4.3.3",
782
782
  "@types/fs-extra": "^11.0.4",
783
783
  "@types/minimist": "^1.2.5",
784
784
  "@types/node": "^25.3.5",
@@ -788,7 +788,7 @@
788
788
  "typescript": "^5.0.0",
789
789
  "vitest": "^4.1.0",
790
790
  "zod": "^4.3.6",
791
- "@takazudo/zudo-doc-history-server": "6.0.2"
791
+ "@takazudo/zudo-doc-history-server": "6.1.1"
792
792
  },
793
793
  "scripts": {
794
794
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",