@huaqiu/dsh-sidebar-entry 0.0.0-stage → 0.5.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 深圳华秋智联股份有限公司
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # `@huaqiu/dsh-sidebar-entry`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One shared sidebar toolbutton row for the Huaqiu DSH plugin family.
4
+
5
+ DSH's sidebar shell exposes no slot a third-party plugin can register into at
6
+ the position between **New Session** and the workspace browser, so this package
7
+ injects a plain-DOM row there and keeps it there:
8
+
9
+ - **DOM-level idempotency** — a second `mountSidebarEntry` for the same row
10
+ attribute is a no-op, so HMR / duplicated `apply()` never double-mount.
11
+ - **Self-healing** — a `MutationObserver` re-inserts the row in the same frame
12
+ whenever a React re-render displaces it (no flicker), and re-queries from
13
+ scratch when the shell tears the whole pane down.
14
+ - **Family ordering** — every participating plugin passes the same
15
+ `familySelectors` list, so rows keep a stable relative order regardless of
16
+ observer callback order.
17
+ - **Clean disposal** — observers, subscriptions and the row itself are removed.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { mountSidebarEntry, observeHostLocale, detectHostLocale } from '@huaqiu/dsh-sidebar-entry'
23
+
24
+ let lang = detectHostLocale()
25
+ const stopLocale = observeHostLocale((next) => { lang = next; /* announce */ })
26
+
27
+ const dispose = mountSidebarEntry({
28
+ rowAttribute: 'data-hqcg-schematic-entry',
29
+ rowSelector: '[data-hqcg-schematic-entry]',
30
+ plugin: 'huaqiu-schematic-gen',
31
+ icon: '<svg …/>',
32
+ css: { entry: '…', entryIcon: '…', entryLabel: '…' },
33
+ label: () => (lang === 'en' ? 'Schematic Gen' : '原理图生成'),
34
+ tooltip: () => (lang === 'en' ? 'Insert a schematic-generation reference' : '插入原理图生成引用'),
35
+ refresh: { subscribe: (fn) => { /* … */ return () => {} } },
36
+ onToggle: () => { /* your action */ },
37
+ position: 'after',
38
+ familySelectors: ['[data-hqcg-footprint-entry]', '[data-hqcg-symbol-entry]'],
39
+ })
40
+ ```
41
+
42
+ ## Notes
43
+
44
+ - The row renders `data-dsh-plugin="<plugin>"` and `data-dsh-part="sidebar-entry"`
45
+ when `plugin` is set (L2 semantic attributes).
46
+ - Do **not** swap this for a `sidebar.panellist` entry unless you also register
47
+ a matching `main` central panel: `SidebarRoot`'s `PanelRow` calls
48
+ `layout.selectPanel(id)`, which throws when no main panel uses that id.
@@ -0,0 +1,98 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * `@huaqiu/dsh-sidebar-entry` — one shared sidebar entry row for the Huaqiu
4
+ * DSH plugin family.
5
+ *
6
+ * Adapted from dsh-web's `shared/client/sidebar-entry-core.ts` (used by
7
+ * dsh-task-board / dsh-skill-explorer / dsh-ssh). It lives in its own package
8
+ * so every Huaqiu plugin that needs a sidebar toolbutton shares ONE copy of
9
+ * the injection policy instead of forking it.
10
+ *
11
+ * dsh's sidebar shell exposes no slot an external plugin can register into at
12
+ * this position (between the New Session button and the workspace browser), so
13
+ * the entry row is injected into the live DOM. The injection self-heals: a
14
+ * MutationObserver watches the sidebar root and re-inserts the row whenever a
15
+ * React re-render displaces it (re-insertion happens in the same frame, before
16
+ * paint, so no flicker).
17
+ *
18
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
19
+ * reconciliation. What the row DOES on click is entirely the caller's: open a
20
+ * `shell.overlay` workspace (component-gen) or act on the DSH composer
21
+ * (schematic-gen).
22
+ *
23
+ * @module @huaqiu/dsh-sidebar-entry
24
+ */
25
+ /** Per-package configuration for one sidebar entry row. */
26
+ interface SidebarEntryOptions {
27
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-hqcg-symbol-entry'. */
28
+ rowAttribute: string;
29
+ /** CSS selector matching the injected row, e.g. '[data-hqcg-symbol-entry]'. */
30
+ rowSelector: string;
31
+ /**
32
+ * L2 semantic-attribute plugin id (issue #506). When set, the row also
33
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
34
+ * leaves the row without semantic attributes.
35
+ */
36
+ plugin?: string;
37
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
38
+ icon: string;
39
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
40
+ css: Record<string, string>;
41
+ /** Localized row label (aria-label + visible text). */
42
+ label(): string;
43
+ /** Optional localized tooltip (title attribute). */
44
+ tooltip?(): string;
45
+ /**
46
+ * Optional locale-change subscription: re-applies label / aria-label /
47
+ * tooltip whenever the active locale changes.
48
+ */
49
+ refresh?: {
50
+ subscribe(listener: () => void): () => void;
51
+ };
52
+ /** Click action (open/toggle the owning panel). */
53
+ onToggle(): void;
54
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
55
+ position: 'before' | 'after';
56
+ /**
57
+ * Selectors of the sibling plugin entry rows this package orders against
58
+ * (its own row included). Each package passes the same list so the rendered
59
+ * order stays stable across re-renders.
60
+ */
61
+ familySelectors: readonly string[];
62
+ /** Optional active-state bridge; highlights the row while the panel is open. */
63
+ active?: {
64
+ subscribe(listener: () => void): () => void;
65
+ isOpen(): boolean;
66
+ };
67
+ }
68
+ /**
69
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
70
+ * on later React re-renders.
71
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
72
+ * @returns disposer removing the entry and its observers.
73
+ */
74
+ declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
75
+ /**
76
+ * Every row selector in the Huaqiu sidebar family, in intended visual order.
77
+ *
78
+ * Family ordering is only stable when every participating plugin passes the
79
+ * SAME list to {@link mountSidebarEntry} — the core positions a row relative to
80
+ * the family block, never relative to transient shell geometry. Adding a new
81
+ * family row means adding its selector here (and to no plugin-local list).
82
+ */
83
+ declare const HUAQIU_SIDEBAR_ENTRY_FAMILY: readonly string[];
84
+ /** Host UI languages this family localizes into. */
85
+ type HostLocale = 'zh' | 'en';
86
+ /**
87
+ * Read the host UI language from `<html lang>`.
88
+ *
89
+ * `dsh-client-locale` rewrites the attribute on every language change (dsh
90
+ * web) and HQ Edge mirrors it, so this is the one source every family plugin
91
+ * can read without importing a `locale` service.
92
+ * @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
93
+ * (callers fall back to their own default).
94
+ */
95
+ declare function detectHostLocale(): HostLocale | undefined;
96
+ declare function observeHostLocale(listener: (lang: HostLocale | undefined) => void): () => void;
97
+ //#endregion
98
+ export { HUAQIU_SIDEBAR_ENTRY_FAMILY, HostLocale, SidebarEntryOptions, detectHostLocale, mountSidebarEntry, observeHostLocale };
package/lib/index.mjs ADDED
@@ -0,0 +1,191 @@
1
+ //#region src/index.ts
2
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
3
+ function sidebarRoot() {
4
+ const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
5
+ if (column === null) return void 0;
6
+ return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
7
+ }
8
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
9
+ function newSessionButton(root) {
10
+ const nested = root.querySelector("button[class*=\"newSession\"]");
11
+ if (nested !== null) return nested;
12
+ for (const child of root.children) if (child.tagName === "BUTTON") return child;
13
+ }
14
+ /** Build the entry row (a detached button; insert once the shell is up). */
15
+ function createEntry(options) {
16
+ const entry = document.createElement("button");
17
+ entry.type = "button";
18
+ entry.setAttribute(options.rowAttribute, "");
19
+ if (options.plugin !== void 0) {
20
+ entry.setAttribute("data-dsh-plugin", options.plugin);
21
+ entry.setAttribute("data-dsh-part", "sidebar-entry");
22
+ }
23
+ entry.className = options.css["entry"] ?? "";
24
+ const labelSpan = document.createElement("span");
25
+ labelSpan.className = options.css["entryLabel"] ?? "";
26
+ const iconSpan = document.createElement("span");
27
+ iconSpan.className = options.css["entryIcon"] ?? "";
28
+ iconSpan.innerHTML = options.icon;
29
+ entry.append(iconSpan, labelSpan);
30
+ const applyLabel = () => {
31
+ entry.setAttribute("aria-label", options.label());
32
+ if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
33
+ labelSpan.textContent = options.label();
34
+ };
35
+ applyLabel();
36
+ entry.addEventListener("click", options.onToggle);
37
+ return {
38
+ entry,
39
+ applyLabel
40
+ };
41
+ }
42
+ /** Re-insert the entry after the New Session row (before the browser region). */
43
+ function placeEntry(root, entry, options) {
44
+ const button = newSessionButton(root);
45
+ if (button === void 0) return false;
46
+ if (entry.parentElement !== root) {
47
+ const row = button.closest("[class*=\"logoRow\"]");
48
+ const base = row !== null && row.parentElement === root ? row : button;
49
+ const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(options.familySelectors.join(", ")));
50
+ const anchor = options.position === "before" ? family.length > 0 ? family[0] : base.nextElementSibling : family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
51
+ root.insertBefore(entry, anchor ?? null);
52
+ }
53
+ return true;
54
+ }
55
+ /**
56
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
57
+ * on later React re-renders.
58
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
59
+ * @returns disposer removing the entry and its observers.
60
+ */
61
+ function mountSidebarEntry(options) {
62
+ if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
63
+ const { entry, applyLabel } = createEntry(options);
64
+ let root;
65
+ let placed = false;
66
+ let unsubscribeRefresh;
67
+ if (options.refresh !== void 0) try {
68
+ unsubscribeRefresh = options.refresh.subscribe(applyLabel);
69
+ } catch {}
70
+ const tryPlace = () => {
71
+ if (root !== void 0 && !root.isConnected) {
72
+ rootObserver.disconnect();
73
+ root = void 0;
74
+ placed = false;
75
+ }
76
+ if (placed) {
77
+ if (document.body.contains(entry)) return;
78
+ rootObserver.disconnect();
79
+ root = void 0;
80
+ placed = false;
81
+ }
82
+ root ??= sidebarRoot();
83
+ if (root === void 0) return;
84
+ placed = placeEntry(root, entry, options);
85
+ if (placed) rootObserver.observe(root, {
86
+ childList: true,
87
+ subtree: true
88
+ });
89
+ };
90
+ const waitObserver = new MutationObserver(() => {
91
+ tryPlace();
92
+ });
93
+ waitObserver.observe(document.body, {
94
+ childList: true,
95
+ subtree: true
96
+ });
97
+ const rootObserver = new MutationObserver(() => {
98
+ if (root === void 0 || !root.isConnected) {
99
+ placed = false;
100
+ tryPlace();
101
+ return;
102
+ }
103
+ if (!root.contains(entry)) placed = placeEntry(root, entry, options);
104
+ });
105
+ const unsubscribeActive = options.active === void 0 ? void 0 : (() => {
106
+ const syncActive = () => {
107
+ if (options.active.isOpen()) entry.dataset.active = "true";
108
+ else delete entry.dataset.active;
109
+ };
110
+ const unsubscribe = options.active.subscribe(syncActive);
111
+ syncActive();
112
+ return unsubscribe;
113
+ })();
114
+ tryPlace();
115
+ return () => {
116
+ waitObserver.disconnect();
117
+ rootObserver.disconnect();
118
+ unsubscribeRefresh?.();
119
+ unsubscribeActive?.();
120
+ entry.remove();
121
+ };
122
+ }
123
+ /**
124
+ * Every row selector in the Huaqiu sidebar family, in intended visual order.
125
+ *
126
+ * Family ordering is only stable when every participating plugin passes the
127
+ * SAME list to {@link mountSidebarEntry} — the core positions a row relative to
128
+ * the family block, never relative to transient shell geometry. Adding a new
129
+ * family row means adding its selector here (and to no plugin-local list).
130
+ */
131
+ const HUAQIU_SIDEBAR_ENTRY_FAMILY = [
132
+ "[data-hqcg-footprint-entry]",
133
+ "[data-hqcg-symbol-entry]",
134
+ "[data-hqcg-schematic-entry]"
135
+ ];
136
+ /**
137
+ * Read the host UI language from `<html lang>`.
138
+ *
139
+ * `dsh-client-locale` rewrites the attribute on every language change (dsh
140
+ * web) and HQ Edge mirrors it, so this is the one source every family plugin
141
+ * can read without importing a `locale` service.
142
+ * @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
143
+ * (callers fall back to their own default).
144
+ */
145
+ function detectHostLocale() {
146
+ if (typeof document === "undefined") return void 0;
147
+ const tag = document.documentElement?.getAttribute("lang");
148
+ if (tag === null || tag === void 0 || tag === "") return void 0;
149
+ const primary = tag.toLowerCase().split("-")[0];
150
+ return primary === "zh" || primary === "en" ? primary : void 0;
151
+ }
152
+ /**
153
+ * Watch `<html lang>` and report every resolved change.
154
+ *
155
+ * Idempotent-safe: repeated calls share one observer and each caller gets its
156
+ * own disposer; the observer disconnects when the last listener leaves.
157
+ * @param listener - called with the newly resolved language.
158
+ * @returns disposer removing this listener.
159
+ */
160
+ /** Shared observer/listener set backing {@link observeHostLocale}. */
161
+ const localeListeners = /* @__PURE__ */ new Set();
162
+ let localeObserver = null;
163
+ function observeHostLocale(listener) {
164
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined" || document.documentElement === null) return () => {};
165
+ let current = detectHostLocale();
166
+ const listeners = localeListeners;
167
+ listeners.add(listener);
168
+ if (localeObserver === null) {
169
+ localeObserver = new MutationObserver(() => {
170
+ const next = detectHostLocale();
171
+ if (next === current) return;
172
+ current = next;
173
+ for (const fn of [...listeners]) try {
174
+ fn(next);
175
+ } catch {}
176
+ });
177
+ localeObserver.observe(document.documentElement, {
178
+ attributes: true,
179
+ attributeFilter: ["lang"]
180
+ });
181
+ }
182
+ return () => {
183
+ listeners.delete(listener);
184
+ if (listeners.size === 0 && localeObserver !== null) {
185
+ localeObserver.disconnect();
186
+ localeObserver = null;
187
+ }
188
+ };
189
+ }
190
+ //#endregion
191
+ export { HUAQIU_SIDEBAR_ENTRY_FAMILY, detectHostLocale, mountSidebarEntry, observeHostLocale };
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-sidebar-entry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.7",
4
+ "type": "module",
5
+ "main": "./lib/index.mjs",
6
+ "types": "./lib/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.mts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "devDependencies": {
22
+ "jsdom": "^26.0.0"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "build": "tsdown",
27
+ "test": "vitest run"
28
+ }
6
29
  }
package/src/index.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * `@huaqiu/dsh-sidebar-entry` — one shared sidebar entry row for the Huaqiu
3
+ * DSH plugin family.
4
+ *
5
+ * Adapted from dsh-web's `shared/client/sidebar-entry-core.ts` (used by
6
+ * dsh-task-board / dsh-skill-explorer / dsh-ssh). It lives in its own package
7
+ * so every Huaqiu plugin that needs a sidebar toolbutton shares ONE copy of
8
+ * the injection policy instead of forking it.
9
+ *
10
+ * dsh's sidebar shell exposes no slot an external plugin can register into at
11
+ * this position (between the New Session button and the workspace browser), so
12
+ * the entry row is injected into the live DOM. The injection self-heals: a
13
+ * MutationObserver watches the sidebar root and re-inserts the row whenever a
14
+ * React re-render displaces it (re-insertion happens in the same frame, before
15
+ * paint, so no flicker).
16
+ *
17
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
18
+ * reconciliation. What the row DOES on click is entirely the caller's: open a
19
+ * `shell.overlay` workspace (component-gen) or act on the DSH composer
20
+ * (schematic-gen).
21
+ *
22
+ * @module @huaqiu/dsh-sidebar-entry
23
+ */
24
+
25
+ /** Per-package configuration for one sidebar entry row. */
26
+ export interface SidebarEntryOptions {
27
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-hqcg-symbol-entry'. */
28
+ rowAttribute: string
29
+ /** CSS selector matching the injected row, e.g. '[data-hqcg-symbol-entry]'. */
30
+ rowSelector: string
31
+ /**
32
+ * L2 semantic-attribute plugin id (issue #506). When set, the row also
33
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
34
+ * leaves the row without semantic attributes.
35
+ */
36
+ plugin?: string
37
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
38
+ icon: string
39
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
40
+ css: Record<string, string>
41
+ /** Localized row label (aria-label + visible text). */
42
+ label(): string
43
+ /** Optional localized tooltip (title attribute). */
44
+ tooltip?(): string
45
+ /**
46
+ * Optional locale-change subscription: re-applies label / aria-label /
47
+ * tooltip whenever the active locale changes.
48
+ */
49
+ refresh?: { subscribe(listener: () => void): () => void }
50
+ /** Click action (open/toggle the owning panel). */
51
+ onToggle(): void
52
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
53
+ position: 'before' | 'after'
54
+ /**
55
+ * Selectors of the sibling plugin entry rows this package orders against
56
+ * (its own row included). Each package passes the same list so the rendered
57
+ * order stays stable across re-renders.
58
+ */
59
+ familySelectors: readonly string[]
60
+ /** Optional active-state bridge; highlights the row while the panel is open. */
61
+ active?: {
62
+ subscribe(listener: () => void): () => void
63
+ isOpen(): boolean
64
+ }
65
+ }
66
+
67
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
68
+ function sidebarRoot(): HTMLElement | undefined {
69
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
70
+ if (column === null) return undefined
71
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
72
+ // Prefer the element that owns the logo row — the real sidebar UI root —
73
+ // and fall back to the column's first child for legacy shells.
74
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
75
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
76
+ }
77
+
78
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
79
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
80
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
81
+ if (nested !== null) return nested
82
+ for (const child of root.children) {
83
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /** Build the entry row (a detached button; insert once the shell is up). */
89
+ function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
90
+ const entry = document.createElement('button')
91
+ entry.type = 'button'
92
+ entry.setAttribute(options.rowAttribute, '')
93
+ if (options.plugin !== undefined) {
94
+ entry.setAttribute('data-dsh-plugin', options.plugin)
95
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
96
+ }
97
+ entry.className = options.css['entry'] ?? ''
98
+ const labelSpan = document.createElement('span')
99
+ labelSpan.className = options.css['entryLabel'] ?? ''
100
+ const iconSpan = document.createElement('span')
101
+ iconSpan.className = options.css['entryIcon'] ?? ''
102
+ iconSpan.innerHTML = options.icon
103
+ entry.append(iconSpan, labelSpan)
104
+ const applyLabel = (): void => {
105
+ entry.setAttribute('aria-label', options.label())
106
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
107
+ labelSpan.textContent = options.label()
108
+ }
109
+ applyLabel()
110
+ entry.addEventListener('click', options.onToggle)
111
+ return { entry, applyLabel }
112
+ }
113
+
114
+ /** Re-insert the entry after the New Session row (before the browser region). */
115
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
116
+ const button = newSessionButton(root)
117
+ if (button === undefined) return false
118
+ if (entry.parentElement !== root) {
119
+ // Position relative to the family block (entries injected by sibling
120
+ // plugins), never relative to transient logoRow geometry: every family
121
+ // plugin that self-heals during a re-render then lands in the same
122
+ // relative order, so the entries cannot swap positions regardless of
123
+ // observer callback order or of shell wrapper changes. There is no
124
+ // append-to-end fallback: appending at the end would randomly reorder
125
+ // the block after a shell re-render.
126
+ const row = button.closest('[class*="logoRow"]')
127
+ const base = (row !== null && row.parentElement === root) ? row : button
128
+ const family = Array.from(root.children).filter(
129
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
130
+ )
131
+ const anchor = options.position === 'before'
132
+ ? (family.length > 0 ? family[0] : base.nextElementSibling)
133
+ : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
134
+ root.insertBefore(entry, anchor ?? null)
135
+ }
136
+ return true
137
+ }
138
+
139
+ /**
140
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
141
+ * on later React re-renders.
142
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
143
+ * @returns disposer removing the entry and its observers.
144
+ */
145
+ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
146
+ // DOM-level idempotency: whatever path mounted an entry row before this
147
+ // call (a duplicated apply, an HMR re-injection, a stale module still
148
+ // alive), never mount a second one. The existing row keeps working; a full
149
+ // page reload is the ultimate reset.
150
+ if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
151
+ return () => {}
152
+ }
153
+ const { entry, applyLabel } = createEntry(options)
154
+ let root: HTMLElement | undefined
155
+ let placed = false
156
+ let unsubscribeRefresh: (() => void) | undefined
157
+ if (options.refresh !== undefined) {
158
+ try {
159
+ unsubscribeRefresh = options.refresh.subscribe(applyLabel)
160
+ } catch {
161
+ // A throwing subscription must not break the mount; the label stays at
162
+ // its initial value and the next reload resolves it again.
163
+ }
164
+ }
165
+
166
+ const tryPlace = (): void => {
167
+ if (root !== undefined && !root.isConnected) {
168
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
169
+ // observer is gone with the old tree, so detach it and re-query from
170
+ // scratch. The new pane is later noticed by the body-level watcher.
171
+ rootObserver.disconnect()
172
+ root = undefined
173
+ placed = false
174
+ }
175
+ if (placed) {
176
+ // Cheap short-circuit: entry still lives in a mountable subtree.
177
+ if (document.body.contains(entry)) return
178
+ // Entry was torn down together with the old tree; reset and re-place.
179
+ rootObserver.disconnect()
180
+ root = undefined
181
+ placed = false
182
+ }
183
+ root ??= sidebarRoot()
184
+ if (root === undefined) return
185
+ placed = placeEntry(root, entry, options)
186
+ if (placed) {
187
+ rootObserver.observe(root, { childList: true, subtree: true })
188
+ }
189
+ }
190
+
191
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
192
+ // tears down the whole sidebar pane, the root observer is gone with it and
193
+ // only this body observation can notice the new pane mounting. It is no
194
+ // longer disconnected after placement; the placed-and-still-mounted case
195
+ // short-circuits through the cheap document.body.contains(entry) check, so
196
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
197
+ // instead of churning the full re-query.
198
+ const waitObserver = new MutationObserver(() => { tryPlace() })
199
+ waitObserver.observe(document.body, { childList: true, subtree: true })
200
+
201
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
202
+ // same frame (microtask before paint -> no visible flicker).
203
+ const rootObserver = new MutationObserver(() => {
204
+ if (root === undefined || !root.isConnected) {
205
+ placed = false
206
+ tryPlace()
207
+ return
208
+ }
209
+ if (!root.contains(entry)) {
210
+ placed = placeEntry(root, entry, options)
211
+ }
212
+ })
213
+
214
+ // Reflect the panel's open state on the row (active highlight). Note: assigning
215
+ // undefined to dataset.active materializes data-active="undefined" and keeps the
216
+ // row permanently highlighted — delete the attribute instead.
217
+ const unsubscribeActive = options.active === undefined ? undefined : (() => {
218
+ const syncActive = (): void => {
219
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
220
+ else delete entry.dataset.active
221
+ }
222
+ const unsubscribe = options.active.subscribe(syncActive)
223
+ syncActive()
224
+ return unsubscribe
225
+ })()
226
+
227
+ tryPlace()
228
+
229
+ return () => {
230
+ waitObserver.disconnect()
231
+ rootObserver.disconnect()
232
+ unsubscribeRefresh?.()
233
+ unsubscribeActive?.()
234
+ entry.remove()
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Every row selector in the Huaqiu sidebar family, in intended visual order.
240
+ *
241
+ * Family ordering is only stable when every participating plugin passes the
242
+ * SAME list to {@link mountSidebarEntry} — the core positions a row relative to
243
+ * the family block, never relative to transient shell geometry. Adding a new
244
+ * family row means adding its selector here (and to no plugin-local list).
245
+ */
246
+ export const HUAQIU_SIDEBAR_ENTRY_FAMILY: readonly string[] = [
247
+ '[data-hqcg-footprint-entry]',
248
+ '[data-hqcg-symbol-entry]',
249
+ '[data-hqcg-schematic-entry]',
250
+ ]
251
+
252
+ // ---- host locale detection (shared by every family sidebar row) ----
253
+
254
+ /** Host UI languages this family localizes into. */
255
+ export type HostLocale = 'zh' | 'en'
256
+
257
+ /**
258
+ * Read the host UI language from `<html lang>`.
259
+ *
260
+ * `dsh-client-locale` rewrites the attribute on every language change (dsh
261
+ * web) and HQ Edge mirrors it, so this is the one source every family plugin
262
+ * can read without importing a `locale` service.
263
+ * @returns 'zh' / 'en', or undefined when the tag is absent or unsupported
264
+ * (callers fall back to their own default).
265
+ */
266
+ export function detectHostLocale(): HostLocale | undefined {
267
+ if (typeof document === 'undefined') return undefined
268
+ const tag = document.documentElement?.getAttribute('lang')
269
+ if (tag === null || tag === undefined || tag === '') return undefined
270
+ const primary = tag.toLowerCase().split('-')[0]
271
+ return primary === 'zh' || primary === 'en' ? primary : undefined
272
+ }
273
+
274
+ /**
275
+ * Watch `<html lang>` and report every resolved change.
276
+ *
277
+ * Idempotent-safe: repeated calls share one observer and each caller gets its
278
+ * own disposer; the observer disconnects when the last listener leaves.
279
+ * @param listener - called with the newly resolved language.
280
+ * @returns disposer removing this listener.
281
+ */
282
+ /** Shared observer/listener set backing {@link observeHostLocale}. */
283
+ const localeListeners = new Set<(lang: HostLocale | undefined) => void>()
284
+ let localeObserver: MutationObserver | null = null
285
+
286
+ export function observeHostLocale(listener: (lang: HostLocale | undefined) => void): () => void {
287
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined' || document.documentElement === null) {
288
+ return () => {}
289
+ }
290
+ let current = detectHostLocale()
291
+ const listeners = localeListeners
292
+ listeners.add(listener)
293
+ if (localeObserver === null) {
294
+ localeObserver = new MutationObserver(() => {
295
+ const next = detectHostLocale()
296
+ if (next === current) return
297
+ current = next
298
+ for (const fn of [...listeners]) {
299
+ try { fn(next) } catch { /* one bad subscriber must not strand the rest */ }
300
+ }
301
+ })
302
+ localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
303
+ }
304
+ return () => {
305
+ listeners.delete(listener)
306
+ if (listeners.size === 0 && localeObserver !== null) {
307
+ localeObserver.disconnect()
308
+ localeObserver = null
309
+ }
310
+ }
311
+ }