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 +13 -0
- package/CHANGELOG.md +13 -0
- package/README.en.md +4 -5
- package/README.md +4 -5
- package/bin/dsh-mini-utility-dock.js +12 -9
- package/dist/launcher.js +206 -0
- package/package.json +2 -2
- package/dist/bootstrap.js +0 -263
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/
|
|
37
|
-
npm run dock:embed -- sync path/to/
|
|
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
|
|
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/
|
|
37
|
-
npm run dock:embed -- sync path/to/
|
|
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
|
-
//
|
|
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-
|
|
10
|
-
// the
|
|
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
|
|
25
|
-
// `
|
|
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)))
|
package/dist/launcher.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "The DSH plugin family's shared assets: source fragments (the
|
|
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
|
-
}
|