dsh-mini-utility-dock 0.3.0 → 0.5.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.en.md CHANGED
@@ -3,6 +3,19 @@
3
3
  Release notes are generated from the matching version section; newest first.
4
4
  For Chinese, see [CHANGELOG.md](CHANGELOG.md).
5
5
 
6
+ ## 0.5.1 - 2026-09-25
7
+
8
+ - Fix the launcher disappearing after a hot reload: the claim is released with its owner, which wakes the other copies to register again instead of requiring a full page reload.
9
+
10
+ ## 0.5.0 - 2026-09-24
11
+
12
+ - New `dist/launcher.js` (marker `dsh-utility-launcher`): the family's shared entry point — one icon at the bottom-left that opens a menu of the family's panels. It goes through slots rather than a page-local protocol: one copy of the assembly runs per page (the first plugin to load wins the `window` mutex and declares the menu seat `createhelper.utility.item`), and every other plugin contributes one row to that seat. Consumers maintain it with `launcher:sync` / `launcher:check`, so the three client halves no longer each carry their own copy.
13
+
14
+ ## 0.4.0 - 2026-09-24
15
+
16
+ - Remove `dist/bootstrap.js` (the dock bootstrap fragment) and its `FRAGMENTS` row. It made every consumer append a `position:fixed; z-index:9997` container to `document.body` and locate it by measuring the sidebar — so it necessarily floated above the page and covered the composer's own controls. The three consumers now register the host's `sidebar.footer.action` seat instead (an icon button directly above Settings), embed no fragment in `lib/client.js` at all, and drop their `dock:sync` / `dock:check` scripts.
17
+ - Consumers stay pinned to `0.3.0`: the two remaining fragments (loopback predicates, host guard) are unchanged, so `loopback:check` / `guard:check` keep comparing against that version byte for byte.
18
+
6
19
  ## 0.3.0 - 2026-09-22
7
20
 
8
21
  - New `dsh-plugin-docs`: a bilingual docs structure check, adopted from the `check-docs.mjs` copy each plugin repository carried. Same interface: no arguments checks the README and CHANGELOG pairs for structural alignment, `--base <revision>` requires bilingual pairs to change together. This package's own bilingual docs are now checked by it too.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  Release Notes 由对应版本段生成;最新版本在前。
4
4
  英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
5
5
 
6
+ ## 0.5.1 - 2026-09-25
7
+
8
+ - 修复 launcher 热重载后消失:归属从一次性闩锁改为可释放的认领,owner 被销毁时唤醒其余副本重新注册,不再需要刷新整页。
9
+
10
+ ## 0.5.0 - 2026-09-24
11
+
12
+ - 新增 `dist/launcher.js`(片段标记 `dsh-utility-launcher`):族的共享入口——左下角一个图标,点开一个列出各插件面板的菜单。它走槽位而不是页面级协议:每页只有一份装配(先加载的插件赢得 `window` 互斥并声明菜单座位 `createhelper.utility.item`),其余插件各往那个座位投一行。消费插件用 `launcher:sync` / `launcher:check` 维护,三个 client 半从此不再各写一份。
13
+
14
+ ## 0.4.0 - 2026-09-24
15
+
16
+ - 移除 `dist/bootstrap.js`(dock 引导片段)与对应的 `FRAGMENTS` 条目。它让每个消费插件在 `document.body` 上挂一个 `position:fixed; z-index:9997` 的容器,位置靠自己量侧边栏算出来——于是它必然悬在页面上方,会压住 composer 自己的控件。三个消费插件已改为注册宿主槽位 `sidebar.footer.action`(图标按钮,就在设置上方),`lib/client.js` 不再嵌任何片段,各仓的 `dock:sync` / `dock:check` 随之删除。
17
+ - 消费仓仍 pin `0.3.0`:本仓剩下两个片段(loopback 判定、host 守卫)未变,`loopback:check` / `guard:check` 继续按该版本逐字节比对。
18
+
6
19
  ## 0.3.0 - 2026-09-22
7
20
 
8
21
  - 新增 `dsh-plugin-docs`:双语文档结构校验,收编自插件仓里各自维护的 `check-docs.mjs`。接口不变:无参数查 README 与 CHANGELOG 的中英结构对齐,`--base <revision>` 检查双语对同改。本包自身的双语文档也由此接入校验。
package/README.en.md CHANGED
@@ -15,9 +15,9 @@ The DSH plugin family's shared assets: source fragments, an embedding CLI, and t
15
15
 
16
16
  | Fragment | Marker | Target file | Exports |
17
17
  | --- | --- | --- | --- |
18
- | dock bootstrap | `dsh-mini-utility-dock` | `lib/client.js` | — |
19
18
  | loopback predicates | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`, `normalizeHostValue`, `hostHostname`, `isLoopbackName`, `isLoopbackAddress` |
20
19
  | host request guard | `dsh-host-guard` | `lib/shared.js` | `portOf`, `GUARD_REASONS`, `DEFAULT_GUARD_POLICY` |
20
+ | utility launcher | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`, `UTILITY_ITEM_SLOT` |
21
21
 
22
22
  ## Usage
23
23
 
@@ -33,11 +33,11 @@ The CLI maintains every marked fragment in the file, applied bottom-up in `FRAGM
33
33
  Equivalent entry point from this repository (target passed by the caller):
34
34
 
35
35
  ```sh
36
- npm run dock:embed -- check path/to/client.js
37
- npm run dock:embed -- sync path/to/client.js
36
+ npm run dock:embed -- check path/to/shared.js
37
+ npm run dock:embed -- sync path/to/shared.js
38
38
  ```
39
39
 
40
- Consumers invoke the same CLI as `loopback:sync` / `guard:sync`, with the target fixed to their own `lib/shared.js`.
40
+ Consumers invoke the same CLI as `loopback:sync` / `guard:sync` with the target fixed to their own `lib/shared.js`, and as `launcher:sync` for the `dsh-utility-launcher` block in their own `lib/client.js`.
41
41
 
42
42
  ## Constraints
43
43
 
@@ -45,7 +45,6 @@ Consumers invoke the same CLI as `loopback:sync` / `guard:sync`, with the target
45
45
  - The two fragments in `lib/shared.js` have a fixed order, `dsh-loopback-helpers` first: `dsh-host-guard` uses the module-scope names the preceding fragment exports, and neither redeclares nor imports them.
46
46
  - A fragment must not contain `import` or `require`; a consumer must publish standalone.
47
47
  - `bindGuard` is not exported — a consumer declares its own guard export in the same file. Each plugin passes its own error codes and wording through `policy`; the enforcement is shared.
48
- - A missing, blank, or non-string `label` falls back to `id`.
49
48
 
50
49
  ## Cross-repo consistency
51
50
 
package/README.md CHANGED
@@ -15,9 +15,9 @@ DSH 插件族的共享资产:源码片段、嵌入 CLI,以及围绕它们的
15
15
 
16
16
  | 片段 | 标记 | 目标文件 | 导出 |
17
17
  | --- | --- | --- | --- |
18
- | dock 引导 | `dsh-mini-utility-dock` | `lib/client.js` | — |
19
18
  | loopback 判定 | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`、`normalizeHostValue`、`hostHostname`、`isLoopbackName`、`isLoopbackAddress` |
20
19
  | host 请求守卫 | `dsh-host-guard` | `lib/shared.js` | `portOf`、`GUARD_REASONS`、`DEFAULT_GUARD_POLICY` |
20
+ | utility 入口 | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`、`UTILITY_ITEM_SLOT` |
21
21
 
22
22
  ## 使用
23
23
 
@@ -33,11 +33,11 @@ CLI 处理文件中所有已标记的片段,按 `FRAGMENTS` 顺序自下而上
33
33
  本仓库等价入口(目标由调用方传入):
34
34
 
35
35
  ```sh
36
- npm run dock:embed -- check path/to/client.js
37
- npm run dock:embed -- sync path/to/client.js
36
+ npm run dock:embed -- check path/to/shared.js
37
+ npm run dock:embed -- sync path/to/shared.js
38
38
  ```
39
39
 
40
- 消费插件以 `loopback:sync` / `guard:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`。
40
+ 消费插件以 `loopback:sync` / `guard:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`;client 半以 `launcher:sync` 维护 `dsh-utility-launcher` 块,目标固定为自身 `lib/client.js`。
41
41
 
42
42
  ## 约束
43
43
 
@@ -45,7 +45,6 @@ npm run dock:embed -- sync path/to/client.js
45
45
  - `lib/shared.js` 中两个片段顺序固定,`dsh-loopback-helpers` 在前:`dsh-host-guard` 直接使用前一片段导出的模块级名字,既不重新声明也不 import。
46
46
  - 片段不得包含 `import` 或 `require`;消费插件必须能独立发布。
47
47
  - `bindGuard` 不从片段导出——消费插件在同一文件中声明自己的 guard 导出。各插件通过 `policy` 传入自身错误码与文案,判定逻辑共用。
48
- - `label` 缺省、空白或非字符串时回退为 `id`。
49
48
 
50
49
  ## 跨仓一致性
51
50
 
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Embed a canonical fragment from this package into a consumer file.
3
3
  //
4
- // Two fragments are maintained the same way, because both have the same shape
5
- // of problem: the consumer must ship the code standalone (a browser classic
4
+ // Three fragments are maintained the same way, because all three have the same
5
+ // shape of problem: the consumer must ship the code standalone (a browser classic
6
6
  // script cannot `import`, and a host half must not depend on the dock being
7
7
  // installed), so the code is embedded at build time and drift is a build error.
8
8
  //
9
- // dsh-mini-utility-dock -> <dsh-mini-utility-dock> ... </dsh-mini-utility-dock>
10
- // the Mini Utility Dock bootstrap, embedded into a plugin's lib/client.js
9
+ // dsh-utility-launcher -> <dsh-utility-launcher> ... </dsh-utility-launcher>
10
+ // the family launcher (one icon plus the menu of family panels it opens),
11
+ // embedded into a plugin's lib/client.js
11
12
  //
12
13
  // dsh-loopback-helpers -> <dsh-loopback-helpers> ... </dsh-loopback-helpers>
13
14
  // the loopback predicates, embedded into a plugin's lib/shared.js
@@ -21,8 +22,9 @@
21
22
  // embed just those.
22
23
  //
23
24
  // The fragment is selected by the marker already present in the target file, so
24
- // one command serves either target. A consumer calls this through its own
25
- // `dock:sync` / `guard:sync` script; the paired `check` mode is what CI runs.
25
+ // one command serves every target and only a named block is ever touched. A
26
+ // consumer calls this through its own `launcher:sync` / `guard:sync` /
27
+ // `loopback:sync` script; the paired `check` mode is what CI runs.
26
28
 
27
29
  import { readFile, writeFile, stat } from 'node:fs/promises'
28
30
  import { resolve } from 'node:path'
@@ -30,11 +32,12 @@ import { fileURLToPath } from 'node:url'
30
32
 
31
33
  // Listed in dependency order: the guard fragment reads the predicates the
32
34
  // loopback fragment declares, so a consumer that wants the guard syncs both, in
33
- // this order, into the same file.
35
+ // this order, into the same file. The launcher is independent of both and lands
36
+ // in the client half instead, so it sits last.
34
37
  const FRAGMENTS = [
35
- { name: 'dsh-mini-utility-dock', source: 'bootstrap.js' },
36
38
  { name: 'dsh-loopback-helpers', source: 'loopback.js' },
37
- { name: 'dsh-host-guard', source: 'guard.js' }
39
+ { name: 'dsh-host-guard', source: 'guard.js' },
40
+ { name: 'dsh-utility-launcher', source: 'launcher.js' }
38
41
  ]
39
42
 
40
43
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
@@ -0,0 +1,206 @@
1
+ // Family utility launcher shared by the browser halves of the DSH plugins.
2
+ //
3
+ // This fragment has ONE source of truth: dsh-mini-utility-dock/dist/launcher.js.
4
+ // DSH client artifacts are self-contained classic scripts, so the fragment is
5
+ // embedded into lib/client.js at build time by
6
+ // npm run launcher:sync (write it)
7
+ // npm run launcher:check (fail on drift)
8
+ // instead of being imported: a bare import would put a runtime dependency on the
9
+ // dock into every plugin, and the whole point of the dock is that a plugin ships
10
+ // standalone, with nothing else required.
11
+ //
12
+ // The launcher is one icon that opens a menu of family panels. It is contributed
13
+ // through slots, not through a page-local protocol: exactly one copy of this
14
+ // assembly runs per page (the first plugin to load wins the window mutex and
15
+ // declares the menu seat), and every plugin adds one row to that seat.
16
+ const UTILITY_ITEM_SLOT = 'createhelper.utility.item'
17
+ const UTILITY_MUTEX_KEY = '__CREATEHELPER_DSH_UTILITY_LAUNCHER_V1__'
18
+ const UTILITY_CSS_ID = 'createhelper-utility-launcher'
19
+ const UTILITY_FALLBACK_LEFT_PX = 80
20
+ const UTILITY_MEASURE_TRIES = 120
21
+ const UTILITY_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="2"></rect><rect x="14" y="3" width="7" height="7" rx="2"></rect><rect x="3" y="14" width="7" height="7" rx="2"></rect><rect x="14" y="14" width="7" height="7" rx="2"></rect></svg>'
22
+
23
+ // The launcher's own chrome, injected once by whichever copy wins the mutex.
24
+ // The rows belong to other plugins, so the menu styles its own buttons by
25
+ // position instead of asking every contributor for a class name.
26
+ function ensureUtilityStyles() {
27
+ if (typeof document === 'undefined') return
28
+ if (document.querySelector('style[data-plugin-css="' + UTILITY_CSS_ID + '"]') !== null) return
29
+ const styleEl = document.createElement('style')
30
+ styleEl.setAttribute('data-plugin-css', UTILITY_CSS_ID)
31
+ styleEl.textContent =
32
+ '.createhelper-utility-anchor{position:fixed;bottom:16px;z-index:9997;pointer-events:auto}' +
33
+ '.createhelper-utility-launcher{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-secondary);cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.22)}' +
34
+ '.createhelper-utility-launcher:hover,.createhelper-utility-launcher[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}' +
35
+ '.createhelper-utility-launcher svg{display:block}' +
36
+ '.createhelper-utility-menu{display:flex;flex-direction:column;gap:2px;min-width:172px;margin-bottom:6px;padding:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-overlay);box-shadow:0 10px 30px rgba(0,0,0,.28)}' +
37
+ '.createhelper-utility-menu[hidden]{display:none}' +
38
+ '.createhelper-utility-menu button{display:flex;align-items:center;gap:8px;width:100%;height:30px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px;text-align:left;white-space:nowrap}' +
39
+ '.createhelper-utility-menu button:hover,.createhelper-utility-menu button[aria-pressed="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}' +
40
+ '.createhelper-utility-menu button svg{display:block;flex:none}'
41
+ document.head.appendChild(styleEl)
42
+ }
43
+
44
+ // The position rule the retired page dock used: right of the sidebar, 16px
45
+ // in, and 80px when the shell has not laid the column out yet.
46
+ const measureUtilityLeft = () => {
47
+ if (typeof document === 'undefined') return UTILITY_FALLBACK_LEFT_PX
48
+ const overlay = document.querySelector('[data-shell-overlay]')
49
+ const frame = overlay && overlay.parentElement
50
+ const sidebar = frame && frame.firstElementChild
51
+ const rect = sidebar && typeof sidebar.getBoundingClientRect === 'function'
52
+ ? sidebar.getBoundingClientRect()
53
+ : null
54
+ if (!rect || !rect.right) return UTILITY_FALLBACK_LEFT_PX
55
+ return Math.max(16, Math.round(rect.right + 16))
56
+ }
57
+ // The overlay layer commits before the frame's columns are laid out, so the
58
+ // first read answers the fallback; keep re-reading for ~2s and then stop.
59
+ const useUtilityLeft = () => {
60
+ const [left, setLeft] = React.useState(UTILITY_FALLBACK_LEFT_PX)
61
+ React.useEffect(() => {
62
+ let stopped = false
63
+ let tries = 0
64
+ let frameId = 0
65
+ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : null
66
+ const sync = () => {
67
+ if (stopped) return
68
+ const next = measureUtilityLeft()
69
+ setLeft(next)
70
+ if (next === UTILITY_FALLBACK_LEFT_PX && raf !== null && tries < UTILITY_MEASURE_TRIES) {
71
+ tries += 1
72
+ frameId = raf(sync)
73
+ }
74
+ }
75
+ sync()
76
+ window.addEventListener('resize', sync)
77
+ return () => {
78
+ stopped = true
79
+ if (raf !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frameId)
80
+ window.removeEventListener('resize', sync)
81
+ }
82
+ }, [])
83
+ return left
84
+ }
85
+ // The block owns no dictionary, so its own title follows the browser
86
+ // language; each row's label comes from the plugin that contributes it.
87
+ const utilityTitle = () => {
88
+ const nav = (typeof navigator !== 'undefined' && navigator.language) || ''
89
+ return /^zh/i.test(nav) ? '工具面板' : 'Utility panels'
90
+ }
91
+ const utilityMenu = { open: false, listeners: new Set() }
92
+ const setUtilityMenu = (value) => {
93
+ utilityMenu.open = !!value
94
+ utilityMenu.listeners.forEach((listener) => listener())
95
+ }
96
+ const useUtilityMenu = () => {
97
+ const [, setTick] = React.useState(0)
98
+ React.useEffect(() => {
99
+ const listener = () => setTick((value) => value + 1)
100
+ utilityMenu.listeners.add(listener)
101
+ return () => utilityMenu.listeners.delete(listener)
102
+ }, [])
103
+ return utilityMenu.open
104
+ }
105
+ // The launcher and its menu. The menu is always rendered - a declared child
106
+ // slot is not conditionally declared - and hidden when closed. Choosing a row
107
+ // closes the menu through the click that bubbles out of it, because the rows
108
+ // are other plugins' components.
109
+ function UtilityLauncher (props) {
110
+ const left = useUtilityLeft()
111
+ const open = useUtilityMenu()
112
+ ensureUtilityStyles()
113
+ React.useEffect(() => {
114
+ if (!open) return undefined
115
+ const onDown = (event) => {
116
+ const target = event && event.target
117
+ if (target && typeof target.closest === 'function' && target.closest('[data-utility-anchor]') !== null) return
118
+ setUtilityMenu(false)
119
+ }
120
+ const onKey = (event) => { if (event && event.key === 'Escape') setUtilityMenu(false) }
121
+ document.addEventListener('mousedown', onDown)
122
+ document.addEventListener('keydown', onKey)
123
+ return () => {
124
+ document.removeEventListener('mousedown', onDown)
125
+ document.removeEventListener('keydown', onKey)
126
+ }
127
+ }, [open])
128
+ return h('div', {
129
+ 'data-utility-anchor': '',
130
+ className: 'createhelper-utility-anchor',
131
+ style: { left: left + 'px' }
132
+ }, [
133
+ h('div', {
134
+ key: 'menu',
135
+ className: 'createhelper-utility-menu',
136
+ hidden: !open,
137
+ onClick: () => setUtilityMenu(false)
138
+ }, props && typeof props.renderSlot === 'function' ? props.renderSlot(UTILITY_ITEM_SLOT, {}) : null),
139
+ h('button', {
140
+ key: 'icon',
141
+ type: 'button',
142
+ className: 'createhelper-utility-launcher',
143
+ title: utilityTitle(),
144
+ 'aria-label': utilityTitle(),
145
+ 'aria-expanded': open ? 'true' : 'false',
146
+ onClick: () => setUtilityMenu(!open),
147
+ dangerouslySetInnerHTML: { __html: UTILITY_ICON }
148
+ })
149
+ ])
150
+ }
151
+ // Whoever loads first owns the assembly; everyone else only contributes rows.
152
+ // The child slot is declared here, so a plugin that joins later still finds it
153
+ // through slots.inject, and an install with one plugin still gets a launcher.
154
+ //
155
+ // The seat belongs to the shell, so it is reached through inject: registering
156
+ // into it directly throws while that declaration is still pending, and a
157
+ // launcher must never cost the caller the surfaces it registers afterwards.
158
+ //
159
+ // The claim lives on the page and is released with its owner. Every family
160
+ // client half carries this assembly, so without the release a hot reload of the
161
+ // owner would dispose its registration while the claim stayed taken, and the
162
+ // launcher would stay missing until the page was reloaded. A released claim
163
+ // wakes the other copies, which re-register immediately.
164
+ const utilityClaim = () => {
165
+ if (typeof window === 'undefined') return null
166
+ const existing = window[UTILITY_MUTEX_KEY]
167
+ if (existing !== null && typeof existing === 'object') return existing
168
+ const claim = { owner: null, waiters: new Set() }
169
+ window[UTILITY_MUTEX_KEY] = claim
170
+ return claim
171
+ }
172
+ const registerUtilityLauncher = (scope) => {
173
+ const claim = utilityClaim()
174
+ if (claim === null) return
175
+ if (claim.owner !== null) {
176
+ claim.waiters.add(() => registerUtilityLauncher(scope))
177
+ return
178
+ }
179
+ claim.owner = scope
180
+ let released = false
181
+ const release = () => {
182
+ if (released) return
183
+ released = true
184
+ if (claim.owner === scope) claim.owner = null
185
+ if (window[UTILITY_MUTEX_KEY] !== claim) return
186
+ const waiters = [...claim.waiters]
187
+ claim.waiters.clear()
188
+ for (const wake of waiters) wake()
189
+ }
190
+ scope.on('dispose', release)
191
+ scope.slots.inject('shell.overlay', () => {
192
+ try {
193
+ scope.slots.register({
194
+ name: 'shell.overlay',
195
+ id: 'utility-launcher',
196
+ order: 98,
197
+ children: { [UTILITY_ITEM_SLOT]: { kind: 'list', scope: 'root' } }
198
+ }, UtilityLauncher)
199
+ } catch (error) {
200
+ release()
201
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
202
+ console.error('[utility-launcher] could not register the family launcher', error)
203
+ }
204
+ }
205
+ })
206
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mini-utility-dock",
3
- "version": "0.3.0",
4
- "description": "The DSH plugin family's shared assets: source fragments (the Mini Utility Dock bootstrap for client halves, the loopback predicates and host request guard for host halves), the embedding CLI, and the family diagnostics — cross-repo drift check and bilingual docs structure check.",
3
+ "version": "0.5.1",
4
+ "description": "The DSH plugin family's shared assets: source fragments (the loopback predicates and host request guard for host halves, the utility launcher for client halves), the embedding CLI, and the family diagnostics — cross-repo drift check and bilingual docs structure check.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js",
package/dist/bootstrap.js DELETED
@@ -1,263 +0,0 @@
1
- // Mini Utility Dock bootstrap. DSH client artifacts are self-contained classic
2
- // scripts, so this fragment is embedded at build time. At runtime the dock is a
3
- // page-local protocol with no package or plugin dependency.
4
- //
5
- // Protocol invariants (createhelper.dsh.utility-dock v1), all page-local:
6
- // - Exactly one dock container in the page. Whoever loads first creates it;
7
- // everyone else joins. Joining never takes over an existing dock.
8
- // - register() requires a non-empty `id` and an `onActivate()`; a dock item
9
- // is a launcher, and each plugin owns and renders its own panel.
10
- // - Activating one item deactivates the others.
11
- // - An item's `icon` is untrusted markup: only a presentational inline SVG
12
- // reaches the page, anything else renders the label as text.
13
- // - The registration disposer carries an ownership token, so a stale HMR
14
- // disposer cannot delete a newer registration for the same id.
15
- // - Placement is shared and persisted; `hidden` keeps a recovery entry.
16
-
17
- const DOCK_KEY = '__CREATEHELPER_DSH_UTILITY_DOCK_V1__'
18
- const DOCK_PROTOCOL = 'createhelper.dsh.utility-dock'
19
- const DOCK_VERSION = 1
20
- const DOCK_PLACEMENT_KEY = 'createhelper.utilityDock.placement'
21
- const DOCK_CSS_ID = 'createhelper-utility-dock'
22
- const DOCK_SNAPSHOT = 'createhelper.utility-dock/1+placement'
23
- const DOCK_LEFT_FALLBACK_PX = 80
24
-
25
- const warnDockGeometry = (left) => {
26
- if (typeof console === 'undefined' || typeof console.warn !== 'function') return
27
- console.warn('[dsh-mini-utility-dock] shell geometry unavailable; falling back to left=' + left + 'px')
28
- }
29
-
30
- const isCompatibleDock = (value) => !!value &&
31
- typeof value.register === 'function' &&
32
- typeof value.setPlacement === 'function' &&
33
- typeof value.getPlacement === 'function' &&
34
- // Builds before the protocol metadata shipped already implemented v1.
35
- (value.protocol === undefined ||
36
- (value.protocol === DOCK_PROTOCOL && value.version === DOCK_VERSION))
37
-
38
- /**
39
- * Dock chrome styles live here because the creator owns the container. Both
40
- * shipped plugins previously carried their own copy of these five rules, so a
41
- * dock created by the plugin that happens to load second still painted.
42
- */
43
- function ensureUtilityDockStyles() {
44
- if (typeof document === 'undefined') return
45
- if (document.querySelector('style[data-plugin-css="' + DOCK_CSS_ID + '"]') !== null) return
46
- const styleEl = document.createElement('style')
47
- styleEl.setAttribute('data-plugin-css', DOCK_CSS_ID)
48
- styleEl.textContent =
49
- '.createhelper-utility-dock{position:fixed;bottom:16px;z-index:9997;display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-overlay);box-shadow:0 6px 22px rgba(0,0,0,.24);pointer-events:auto}' +
50
- '.createhelper-utility-dock[hidden]{display:none}' +
51
- '.createhelper-utility-dock-item{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:9px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}' +
52
- '.createhelper-utility-dock-item:hover,.createhelper-utility-dock-item[aria-pressed="true"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}' +
53
- '.createhelper-utility-dock-item svg{display:block}'
54
- document.head.appendChild(styleEl)
55
- }
56
-
57
- /**
58
- * An item's `icon` is markup another plugin hands to `innerHTML`, so the dock —
59
- * not each registrant — owns what reaches the page. Admit a single inline SVG
60
- * whose tags and attributes are presentational; `href`, `style`, `on*`,
61
- * `<script>` and `<foreignObject>` are exactly the shapes that turn an icon
62
- * into a script, and none of them draws a glyph.
63
- */
64
- const DOCK_ICON_TAGS = /^(svg|g|path|rect|circle|ellipse|line|polyline|polygon)$/i
65
- const DOCK_ICON_ATTRS = /^(width|height|viewBox|preserveAspectRatio|fill|fill-rule|fill-opacity|stroke|stroke-width|stroke-linecap|stroke-linejoin|stroke-miterlimit|stroke-opacity|stroke-dasharray|stroke-dashoffset|opacity|d|x|y|x1|y1|x2|y2|rx|ry|cx|cy|r|points|transform|role|aria-hidden|focusable|class)$/i
66
-
67
- function safeDockIcon(icon) {
68
- if (typeof icon !== 'string') return false
69
- const markup = icon.trim()
70
- if (!/^<svg(?:\s|>)/i.test(markup) || !/<\/svg>$/i.test(markup)) return false
71
- // A comment, CDATA or processing instruction can carry markup the scans below
72
- // never look at.
73
- if (/<!--|<!\[CDATA\[|<\?|]]>/.test(markup)) return false
74
- // Splitting on `"` pairs the quotes up: an even segment count is an unbalanced
75
- // quote, and only the odd-index segments are quoted values. A value holding a
76
- // tag boundary would move `>` past what the scans below can see.
77
- const quoted = markup.split('"')
78
- if (quoted.length % 2 === 0) return false
79
- for (let i = 1; i < quoted.length; i += 2) {
80
- if (/[<>]/.test(quoted[i])) return false
81
- }
82
- if (/[\s"']on[a-z]+\s*=/i.test(markup)) return false
83
- if (/javascript\s*:/i.test(markup)) return false
84
- // A same-document fragment reference is how a gradient is painted; anything
85
- // else turns a presentational attribute into a network read.
86
- if (/url\(\s*(?!#)/i.test(markup)) return false
87
- const tags = markup.match(/<\/?[a-zA-Z][^>]*>/g)
88
- if (!tags) return false
89
- for (const tag of tags) {
90
- const name = /^<\/?\s*([^/>\s]+)/.exec(tag)
91
- if (!name || !DOCK_ICON_TAGS.test(name[1])) return false
92
- for (const raw of tag.match(/[^=<>\s]+\s*=/g) || []) {
93
- if (!DOCK_ICON_ATTRS.test(raw.replace(/\s*=$/, ''))) return false
94
- }
95
- }
96
- return true
97
- }
98
-
99
- /** Two characters stand in for an icon the dock could not admit. */
100
- function dockIconFallback(item) {
101
- const label = String(item.label || item.id || '')
102
- return label.slice(0, 2)
103
- }
104
-
105
- /**
106
- * A missing, blank, or non-string `label` would reach `render()` as `undefined`
107
- * and produce aria-label="undefined". `id` is always present (register() rejects
108
- * an empty one), so it is the safe, meaningful accessible name. Normalize once
109
- * on store — not at every render — so the stored item is always valid.
110
- */
111
- const normalizeDockLabel = (item) => {
112
- const label = typeof item.label === 'string' ? item.label.trim() : ''
113
- return label || item.id
114
- }
115
-
116
- function getUtilityDock() {
117
- if (isCompatibleDock(window[DOCK_KEY])) return window[DOCK_KEY]
118
- ensureUtilityDockStyles()
119
- const items = new Map()
120
- let root = null
121
- let resizeObserver = null
122
- let mutationObserver = null
123
- const readPlacement = () => {
124
- try {
125
- const value = localStorage.getItem(DOCK_PLACEMENT_KEY)
126
- if (value === 'main-bottom-right' || value === 'hidden') return value
127
- } catch (e) { }
128
- return 'main-bottom-left'
129
- }
130
- let placement = readPlacement()
131
- const findShellFrame = () => {
132
- const overlay = document.querySelector('[data-shell-overlay]')
133
- return (overlay && overlay.parentElement) || null
134
- }
135
- let geometryWarned = false
136
- const measureDockLeft = () => {
137
- const frame = findShellFrame()
138
- const sidebar = frame && frame.firstElementChild
139
- const sidebarRect = sidebar && typeof sidebar.getBoundingClientRect === 'function'
140
- ? sidebar.getBoundingClientRect()
141
- : null
142
- if (!sidebarRect) {
143
- if (!geometryWarned) {
144
- geometryWarned = true
145
- warnDockGeometry(DOCK_LEFT_FALLBACK_PX)
146
- }
147
- return DOCK_LEFT_FALLBACK_PX
148
- }
149
- return Math.max(16, Math.round(sidebarRect.right + 16))
150
- }
151
- const updateGeometry = () => {
152
- if (!root) return
153
- root.hidden = placement === 'hidden'
154
- root.dataset.placement = placement
155
- document.documentElement.dataset.createhelperUtilityDockPlacement = placement
156
- root.style.right = ''
157
- root.style.left = ''
158
- if (placement === 'main-bottom-right') {
159
- root.style.right = '16px'
160
- return
161
- }
162
- const left = measureDockLeft()
163
- root.style.left = left + 'px'
164
- document.documentElement.style.setProperty('--createhelper-utility-dock-left', left + 'px')
165
- }
166
- const render = () => {
167
- if (!root) {
168
- root = document.createElement('nav')
169
- root.className = 'createhelper-utility-dock'
170
- root.setAttribute('aria-label', 'DSH utilities')
171
- document.body.appendChild(root)
172
- window.addEventListener('resize', updateGeometry)
173
- const observeLayout = () => {
174
- const frame = findShellFrame()
175
- if (!frame) return false
176
- mutationObserver?.disconnect()
177
- mutationObserver = null
178
- if (typeof ResizeObserver === 'function' && !resizeObserver) {
179
- resizeObserver = new ResizeObserver(updateGeometry)
180
- resizeObserver.observe(frame)
181
- if (frame.firstElementChild) resizeObserver.observe(frame.firstElementChild)
182
- }
183
- updateGeometry()
184
- return true
185
- }
186
- if (!observeLayout() && typeof MutationObserver === 'function') {
187
- mutationObserver = new MutationObserver(() => { observeLayout() })
188
- mutationObserver.observe(document.body, { childList: true, subtree: true })
189
- }
190
- }
191
- root.replaceChildren()
192
- Array.from(items.values()).sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)).forEach((item) => {
193
- const button = document.createElement('button')
194
- button.type = 'button'
195
- button.className = 'createhelper-utility-dock-item'
196
- button.dataset.createhelperDockItem = item.id
197
- button.title = item.label
198
- button.setAttribute('aria-label', item.label)
199
- button.setAttribute('aria-pressed', item.active ? 'true' : 'false')
200
- // Sanitized here, not in register(), so `update({ icon })` cannot be a
201
- // second way past the gate.
202
- if (safeDockIcon(item.icon)) button.innerHTML = item.icon
203
- else button.textContent = dockIconFallback(item)
204
- button.addEventListener('click', () => {
205
- if (!item.active) {
206
- for (const other of items.values()) {
207
- if (other.id !== item.id && other.active && typeof other.onDeactivate === 'function') {
208
- other.onDeactivate()
209
- }
210
- }
211
- }
212
- item.onActivate()
213
- })
214
- root.appendChild(button)
215
- })
216
- updateGeometry()
217
- }
218
- const api = {
219
- protocol: DOCK_PROTOCOL,
220
- version: DOCK_VERSION,
221
- snapshot: DOCK_SNAPSHOT,
222
- register(item) {
223
- if (!item || typeof item.id !== 'string' || !item.id || typeof item.onActivate !== 'function') {
224
- throw new TypeError('utility dock item requires a non-empty id and onActivate()')
225
- }
226
- const registration = Object.freeze({})
227
- items.set(item.id, { ...item, registration, label: normalizeDockLabel(item), order: Number(item.order) || 0, active: !!item.active })
228
- render()
229
- return {
230
- update(patch) {
231
- const current = items.get(item.id)
232
- if (!current || current.registration !== registration) return
233
- const next = { ...current, ...patch }
234
- // Keep the stored label valid even when `update({ label })` is passed.
235
- next.label = normalizeDockLabel(next)
236
- items.set(item.id, next)
237
- render()
238
- },
239
- dispose() {
240
- const current = items.get(item.id)
241
- if (!current || current.registration !== registration) return
242
- items.delete(item.id)
243
- if (items.size) { render(); return }
244
- resizeObserver?.disconnect()
245
- resizeObserver = null
246
- mutationObserver?.disconnect()
247
- mutationObserver = null
248
- window.removeEventListener('resize', updateGeometry)
249
- root?.remove()
250
- root = null
251
- }
252
- }
253
- },
254
- setPlacement(next) {
255
- placement = next === 'main-bottom-right' || next === 'hidden' ? next : 'main-bottom-left'
256
- try { localStorage.setItem(DOCK_PLACEMENT_KEY, placement) } catch (e) { }
257
- updateGeometry()
258
- },
259
- getPlacement() { return placement }
260
- }
261
- window[DOCK_KEY] = api
262
- return api
263
- }