dsh-plugin-toolkit 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## 0.12.0
2
+
3
+ Five rounds of the panel's own UI, each one driven by looking at the rendered
4
+ thing rather than at the code.
5
+
6
+ - **The tool comes before the guide.** The input, the button and the result now
7
+ sit directly under the header, and the guide moved below a rule into a
8
+ foldable `使用说明`. It used to take a screen and a half of scrolling to reach
9
+ the one control the panel exists for. The docked body is capped at `76ch`, and
10
+ the language switch no longer wraps onto its own line in the sidebar tab.
11
+ - **Results answer first.** A summary bar states the verdict and the counts
12
+ (`3 项里 1 项没过`), the checked folder is shown in monospace, failures come
13
+ first with a tinted row and a left rule, and the passing checks fold into
14
+ `<details>` — closed when something failed, open when everything passed.
15
+ - **No state renders as nothing.** Before the first check the panel says what to
16
+ do; while a check runs the region is `aria-busy`; a request that never reaches
17
+ the host still shows the folder it was asked about.
18
+ - **Keyboard and screen reader.** `Escape` closes the dock from anywhere inside
19
+ it, the toggle carries `aria-controls`, and focus moves to the verdict when a
20
+ check lands, so the answer is announced instead of the input keeping focus.
21
+ - **The outcome survives collapsing.** A chip in the collapsed row reads
22
+ `1 项没过` / `通过`, so the result is visible without opening the panel again.
23
+ - `scripts/preview.mjs` renders the panel — collapsed, open, with a result, and
24
+ the sidebar tab — to one static HTML file, using the same testkit the tests
25
+ use. That is how these five rounds were reviewed; it reads the stylesheet from
26
+ the injected `<style>`, because a regex over the source truncates at the first
27
+ `;` inside a CSS string.
28
+ - Fixed in the testkit: passing both `host` and `fetch` to `mountClient` used to
29
+ ignore the stub, so every request answered 404 and the failure looked like the
30
+ panel's fault.
31
+
32
+ ## 0.11.0
33
+
34
+ - **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
35
+ in the sidebar tab, opens in Chinese (the audience this plugin is written for),
36
+ and remembers the choice in `localStorage` under `dsh-plugin-toolkit:lang`. The
37
+ panel no longer follows the host's language silently: the app can be in English
38
+ while the person reading it is not.
39
+ - `useLang()` is a tiny subscription store, so the dock, the tab and the tab
40
+ type's `title()` all switch together.
41
+ - **The testkit hands a bundle the real `window`.** `loadBundle` used to pass a
42
+ stand-in that carried only `__ModuleLoader__`, so `window.localStorage` was
43
+ `undefined` and any preference silently failed to save. That bug was found by
44
+ this feature failing its own persistence assertion.
45
+ - Fixed invalid markup: the switch was nested inside the dock's toggle button.
46
+ React warns and drops the inner button, so it now sits beside it.
47
+
1
48
  ## 0.10.0
2
49
 
3
50
  - **The checker is docked under the conversation.** It used to live only in the
package/README.md CHANGED
@@ -98,6 +98,15 @@ for, how to use it, what each check means, and what it cannot tell you — plus
98
98
  the folder field and the results. The same panel is also a **Plugin doctor** tab
99
99
  in the right sidebar, for when the conversation is busy.
100
100
 
101
+ The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
102
+ in the dock's header row (and in the sidebar tab) and the choice is remembered.
103
+
104
+ Look at it without the app:
105
+
106
+ ```sh
107
+ node scripts/preview.mjs /tmp/panel.html # collapsed, open, with a result, and the tab
108
+ ```
109
+
101
110
  ## Test a panel without a browser
102
111
 
103
112
  A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
package/README.zh.md CHANGED
@@ -57,6 +57,14 @@ cd my-panel && npm install && npm test
57
57
 
58
58
  检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
59
59
 
60
+ 面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
61
+
62
+ 不开应用也能看:
63
+
64
+ ```sh
65
+ node scripts/preview.mjs /tmp/panel.html # 收起、展开、有结果、侧栏标签
66
+ ```
67
+
60
68
  ## 不用浏览器测面板
61
69
 
62
70
  ```js
package/client.js CHANGED
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  const module = { exports: {} };
5
5
  const React = require('react');
6
- const { useState, useCallback } = React;
6
+ const { useState, useCallback, useEffect, useRef } = React;
7
7
 
8
8
  const NS = 'dsh-plugin-toolkit';
9
9
  const KIND = 'toolkit';
@@ -18,6 +18,14 @@ window.__ModuleLoader__.load({
18
18
  checking: 'Checking…',
19
19
  pass: 'Everything the market checks passes.',
20
20
  fail: 'This would not install cleanly.',
21
+ counts: (failed, total) => `${failed} of ${total} checks failed`,
22
+ passedLabel: (n) => `${n} passed`,
23
+ empty: 'Paste the folder that holds package.json, then press Check.',
24
+ guideTitle: 'How to use this',
25
+ checkingNow: 'Checking…',
26
+ chipFail: (n) => `${n} failed`,
27
+ chipPass: 'passes',
28
+ checked: 'Checked',
21
29
  noDir: 'Name the folder that holds the plugin\'s package.json.',
22
30
  failed: 'Could not run the checks',
23
31
  guideDescription: 'Run the install checks on a plugin folder',
@@ -33,6 +41,14 @@ window.__ModuleLoader__.load({
33
41
  checking: '检查中…',
34
42
  pass: '市场会检查的项都通过了。',
35
43
  fail: '这样装上去会出问题。',
44
+ counts: (failed, total) => `${total} 项里 ${failed} 项没过`,
45
+ passedLabel: (n) => `${n} 项通过`,
46
+ empty: '粘贴放着 package.json 的文件夹,然后按「检查」。',
47
+ guideTitle: '使用说明',
48
+ checkingNow: '正在检查…',
49
+ chipFail: (n) => `${n} 项没过`,
50
+ chipPass: '通过',
51
+ checked: '检查的是',
36
52
  noDir: '请填写放着 package.json 的插件文件夹。',
37
53
  failed: '检查没能跑起来',
38
54
  guideDescription: '对任意插件文件夹跑一遍安装前的检查',
@@ -132,32 +148,119 @@ window.__ModuleLoader__.load({
132
148
  * type's `title`/`description` are called by the sidebar's guide list, not
133
149
  * by this component, and they need the same strings.
134
150
  */
135
- const translate = (key) => {
136
- const lang = typeof document === 'undefined' ? 'en' : (document.documentElement.lang === 'zh' ? 'zh' : 'en');
137
- return COPY[lang][key] ?? key;
151
+ const translate = (key, lang = readLang()) => {
152
+ return (COPY[lang] ?? COPY.en)[key] ?? key;
138
153
  };
139
154
 
155
+ /**
156
+ * The panel's language is the user's choice, not the host's: the app may be
157
+ * in English while the person reading it is not. Chinese is the default,
158
+ * because that is the audience this plugin is written for, and the choice is
159
+ * remembered per browser.
160
+ */
161
+ const LANG_KEY = 'dsh-plugin-toolkit:lang';
162
+ const LANGUAGES = ['zh', 'en'];
163
+ const langListeners = new Set();
164
+ let langCache = null;
165
+
166
+ function readLang() {
167
+ if (langCache !== null) return langCache;
168
+ try {
169
+ const stored = window.localStorage.getItem(LANG_KEY);
170
+ if (LANGUAGES.includes(stored)) {
171
+ langCache = stored;
172
+ return langCache;
173
+ }
174
+ } catch {
175
+ // No storage (private mode, a sandboxed frame): fall through to the default.
176
+ }
177
+ langCache = 'zh';
178
+ return langCache;
179
+ }
180
+
181
+ function setLang(next) {
182
+ if (!LANGUAGES.includes(next)) return;
183
+ langCache = next;
184
+ try {
185
+ window.localStorage.setItem(LANG_KEY, next);
186
+ } catch {
187
+ // Remembering the choice is a convenience, never a requirement.
188
+ }
189
+ for (const listener of langListeners) listener();
190
+ }
191
+
192
+ function useLang() {
193
+ const [lang, set] = useState(readLang);
194
+ useEffect(() => {
195
+ const listener = () => set(readLang());
196
+ langListeners.add(listener);
197
+ return () => langListeners.delete(listener);
198
+ }, []);
199
+ return lang;
200
+ }
201
+
202
+ function LangSwitch({ lang }) {
203
+ return h('span', { className: 'tk-lang', role: 'group', 'aria-label': 'Language' },
204
+ LANGUAGES.map((code) => h('button', {
205
+ key: code,
206
+ type: 'button',
207
+ className: 'tk-lang-button',
208
+ 'data-on': code === lang ? 'true' : 'false',
209
+ 'aria-pressed': code === lang ? 'true' : 'false',
210
+ onClick: (event) => {
211
+ // The dock sits inside the composer: a click here must not reach
212
+ // the conversation's own handlers.
213
+ event.stopPropagation();
214
+ setLang(code);
215
+ },
216
+ }, code === 'zh' ? '中文' : 'EN')));
217
+ }
218
+
140
219
  const h = React.createElement;
141
220
 
142
221
  const CSS = [
143
222
  '.tk-panel{padding:16px;display:flex;flex-direction:column;gap:10px;font-size:13px}',
144
- '.tk-title{margin:0;font-size:14px;font-weight:600}',
223
+ '.tk-title{margin:0;flex:1;min-width:0;font-size:14px;font-weight:600}',
145
224
  '.tk-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
146
225
  '.tk-form{display:flex;gap:8px}',
147
226
  '.tk-input{flex:1;min-width:0;height:28px;padding:0 9px;font:inherit;font-size:12px;',
148
227
  'border:0.5px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;',
149
228
  'background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}',
229
+ '.tk-input:hover{border-color:var(--dsw-alias-border-l3,#c9ccd1)}',
150
230
  '.tk-input:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
151
231
  '.tk-button{height:28px;padding:0 12px;border-radius:6px;border:0;cursor:pointer;font:inherit;',
152
232
  'background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
153
- '.tk-button:disabled{opacity:.6;cursor:default}',
233
+ '.tk-button:hover{filter:brightness(1.06)}',
234
+ '.tk-button:disabled{opacity:.6;cursor:default;filter:none}',
235
+ '.tk-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:2px}',
236
+ '.tk-chip{flex:none;font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;',
237
+ 'font-variant-numeric:tabular-nums}',
238
+ '.tk-chip[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a);',
239
+ 'background:var(--dsw-alias-state-success-bg,#eef8ee)}',
240
+ '.tk-chip[data-ok="false"]{color:var(--dsw-alias-state-error-primary,#c0392b);',
241
+ 'background:var(--dsw-alias-state-error-bg,#fdf3f2)}',
154
242
  '.tk-failed{margin:0;font-size:12px;color:var(--dsw-alias-state-error-primary,#c0392b)}',
243
+ '.tk-empty{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
244
+ '.tk-passes{margin:0}',
245
+ '.tk-passes summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);',
246
+ 'padding:2px 0;list-style:none}',
247
+ '.tk-passes summary::-webkit-details-marker{display:none}',
248
+ '.tk-passes summary::before{content:"▸ ";font-size:10px}',
249
+ '.tk-passes[open] summary::before{content:"▾ "}',
250
+ '.tk-passes .tk-rows{margin-top:4px}',
251
+ '.tk-summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}',
252
+ '.tk-count{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);font-variant-numeric:tabular-nums}',
253
+ '.tk-checked{margin:0;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);overflow-wrap:anywhere}',
254
+ '.tk-checked code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
155
255
  '.tk-verdict{margin:0;font-size:12px;font-weight:600}',
156
256
  '.tk-verdict[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a)}',
157
257
  '.tk-verdict[data-ok="false"]{color:var(--dsw-alias-state-warn-primary,#b7791f)}',
158
258
  '.tk-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
159
- '.tk-row{display:flex;gap:8px;align-items:baseline;padding:5px 8px;border-radius:6px;',
160
- 'background:var(--dsw-alias-bg-layer-1,#fafafa)}',
259
+ '.tk-row{display:flex;gap:8px;align-items:baseline;padding:6px 8px;border-radius:6px;}',
260
+ '.tk-rows .tk-row + .tk-row{margin-top:3px}',
261
+ '.tk-row[data-ok="false"]{background:var(--dsw-alias-state-error-bg,#fdf3f2);',
262
+ 'box-shadow:inset 2px 0 0 var(--dsw-alias-state-error-primary,#c0392b)}',
263
+ '.tk-row[data-ok="true"] .tk-message{color:var(--dsw-alias-label-secondary,#555)}',
161
264
  '.tk-mark{width:12px;flex:none;font-weight:600}',
162
265
  '.tk-row[data-ok="true"] .tk-mark{color:var(--dsw-alias-state-success-primary,#2f855a)}',
163
266
  '.tk-row[data-ok="false"] .tk-mark{color:var(--dsw-alias-state-error-primary,#c0392b)}',
@@ -165,24 +268,41 @@ window.__ModuleLoader__.load({
165
268
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
166
269
  'color:var(--dsw-alias-label-tertiary,#888)}',
167
270
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
271
+ '.tk-panel-head{display:flex;align-items:center;gap:8px}',
272
+ '.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
273
+ 'border-radius:6px;overflow:hidden}',
274
+ '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
275
+ 'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
276
+ '.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
277
+ '.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
168
278
  '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
169
279
  '.tk-dock-head{display:flex}',
170
- '.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:7px 2px;',
280
+ '.tk-dock-toggle{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 2px;',
171
281
  'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
172
282
  '.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
173
283
  'outline-offset:-1px;border-radius:4px}',
174
284
  '.tk-dock-title{font-weight:600}',
175
285
  '.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
176
286
  'color:var(--dsw-alias-label-tertiary,#888)}',
177
- '.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
178
- '.tk-dock-body{display:flex;flex-direction:column;gap:12px;padding:2px 2px 14px;',
179
- 'max-height:46vh;overflow:auto;font-size:12px}',
180
- '.tk-guide{margin:0;display:flex;flex-direction:column;gap:5px}',
181
- '.tk-guide h3{margin:0;font-size:12px;font-weight:600}',
287
+ '.tk-dock-chevron{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
288
+ '.tk-summary:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
289
+ 'outline-offset:2px;border-radius:4px}',
290
+ '.tk-dock-body{display:flex;flex-direction:column;gap:10px;max-width:76ch;padding:2px 2px 14px;',
291
+ 'max-height:60vh;overflow:auto;font-size:12px}',
292
+ '.tk-guide-all{margin-top:2px;padding-top:12px;border-top:0.5px solid var(--dsw-alias-border-l1,#ececec)}',
293
+ '.tk-guide-all[open]{display:flex;flex-direction:column;gap:14px}',
294
+ '.tk-guide-summary{cursor:pointer;font-size:11px;font-weight:600;list-style:none;',
295
+ 'color:var(--dsw-alias-label-secondary,#555);padding:2px 0}',
296
+ '.tk-guide-summary::-webkit-details-marker{display:none}',
297
+ '.tk-guide-summary::before{content:"▸ ";font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}',
298
+ '.tk-guide-all[open] .tk-guide-summary::before{content:"▾ ";}',
299
+ '.tk-guide{margin:0;display:flex;flex-direction:column;gap:4px}',
300
+ '.tk-guide h3{margin:0;font-size:12px;font-weight:600;',
301
+ 'color:var(--dsw-alias-label-primary,#1f2329)}',
182
302
  '.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
183
303
  '.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
184
304
  'color:var(--dsw-alias-label-secondary,#555)}',
185
- '.tk-guide-rows{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:0}',
305
+ '.tk-guide-rows{display:grid;grid-template-columns:132px 1fr;gap:4px 12px;margin:0}',
186
306
  '.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
187
307
  'color:var(--dsw-alias-label-primary,#222)}',
188
308
  '.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
@@ -236,13 +356,15 @@ window.__ModuleLoader__.load({
236
356
  }
237
357
 
238
358
  function Form({ checker }) {
359
+ const lang = useLang();
360
+ const tr = (key) => translate(key, lang);
239
361
  return h('div', { className: 'tk-form' },
240
362
  h('input', {
241
363
  className: 'tk-input',
242
364
  type: 'text',
243
365
  value: checker.dir,
244
- placeholder: translate('placeholder'),
245
- 'aria-label': translate('placeholder'),
366
+ placeholder: tr('placeholder'),
367
+ 'aria-label': tr('placeholder'),
246
368
  onChange: (event) => checker.setDir(event.target.value),
247
369
  onKeyDown: (event) => { if (event.key === 'Enter') checker.check(); },
248
370
  }),
@@ -251,25 +373,65 @@ window.__ModuleLoader__.load({
251
373
  type: 'button',
252
374
  disabled: checker.busy === true,
253
375
  onClick: checker.check,
254
- }, checker.busy === true ? translate('checking') : translate('check')));
376
+ }, checker.busy === true ? tr('checking') : tr('check')));
255
377
  }
256
378
 
257
379
  function Result({ checker }) {
258
- return h('div', { className: 'tk-result' },
380
+ const lang = useLang();
381
+ const tr = (key) => translate(key, lang);
382
+ return h('div', { className: 'tk-result', 'aria-busy': checker.busy === true ? 'true' : 'false' },
259
383
  checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
384
+ checker.failed === null && checker.result === null && checker.busy !== true
385
+ ? h('p', { className: 'tk-empty' }, tr('empty'))
386
+ : null,
260
387
  checker.result !== null
261
- ? h('div', { className: 'tk-result-body' },
262
- h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
263
- checker.result.ok === true ? translate('pass') : translate('fail')),
264
- h('ul', { className: 'tk-rows' }, checker.result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
388
+ ? h(ResultBody, { result: checker.result, tr, lang })
389
+ : null);
390
+ }
391
+
392
+ function ResultBody({ result, tr, lang }) {
393
+ const summary = useRef(null);
394
+ // After a check the answer is the thing to read; leaving focus in the
395
+ // input means a screen reader announces nothing.
396
+ useEffect(() => {
397
+ summary.current?.focus();
398
+ }, [result]);
399
+ const checks = result.checks ?? [];
400
+ const failed = checks.filter((entry) => entry.ok !== true);
401
+ const passed = checks.filter((entry) => entry.ok === true);
402
+ // Failures first: a list that opens with six green lines buries the two
403
+ // that matter below the fold of a docked panel.
404
+ const ordered = [...failed, ...passed];
405
+ return h('div', { className: 'tk-result-body' },
406
+ h('div', { className: 'tk-summary', ref: summary, tabIndex: -1,
407
+ 'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
408
+ h('span', { className: 'tk-verdict' },
409
+ result.ok === true ? tr('pass') : tr('fail')),
410
+ failed.length > 0
411
+ ? h('span', { className: 'tk-count' }, COPY[lang].counts(failed.length, checks.length))
412
+ : null),
413
+ typeof result.dir === 'string' && result.dir !== ''
414
+ ? h('p', { className: 'tk-checked' },
415
+ h('span', {}, `${tr('checked')} `),
416
+ h('code', {}, result.dir))
417
+ : null,
418
+ h('ul', { className: 'tk-rows' }, failed.map((entry) => h(Row, { key: entry.check, entry }))),
419
+ passed.length > 0
420
+ ? h('details', { className: 'tk-passes', open: failed.length === 0 ? true : undefined },
421
+ h('summary', {}, COPY[lang].passedLabel(passed.length)),
422
+ h('ul', { className: 'tk-rows' }, passed.map((entry) => h(Row, { key: entry.check, entry }))))
265
423
  : null);
266
424
  }
267
425
 
268
426
  function Panel() {
269
427
  const checker = useChecker();
428
+ const lang = useLang();
429
+ const tr = (key) => translate(key, lang);
270
430
  return h('div', { className: 'tk-panel' },
271
- h('h2', { className: 'tk-title' }, translate('title')),
272
- h('p', { className: 'tk-hint' }, translate('hint')),
431
+ h('div', { className: 'tk-panel-head' },
432
+ h('h2', { className: 'tk-title' }, tr('title')),
433
+ h(LangSwitch, { lang })),
434
+ h('p', { className: 'tk-hint' }, tr('hint')),
273
435
  h(Form, { checker }),
274
436
  h(Result, { checker }));
275
437
  }
@@ -282,22 +444,50 @@ window.__ModuleLoader__.load({
282
444
  function Dock() {
283
445
  const [open, setOpen] = useState(false);
284
446
  const checker = useChecker();
285
- const guide = GUIDE[typeof document === 'undefined' || document.documentElement.lang !== 'zh' ? 'en' : 'zh'];
447
+ const lang = useLang();
448
+ const guide = GUIDE[lang] ?? GUIDE.zh;
286
449
 
287
- return h('section', { className: 'tk-dock', 'data-open': open === true ? 'true' : 'false' },
450
+ return h('section', {
451
+ className: 'tk-dock',
452
+ 'data-open': open === true ? 'true' : 'false',
453
+ // Escape closes it wherever the focus is inside: a docked panel that
454
+ // traps the keyboard is worse than one that is hard to find.
455
+ onKeyDown: (event) => {
456
+ if (event.key === 'Escape' && open === true) setOpen(false);
457
+ },
458
+ },
288
459
  h('div', { className: 'tk-dock-head' },
460
+ // The language switch is a sibling of the toggle, never inside it: a
461
+ // button inside a button is invalid and React drops the inner one.
289
462
  h('button', {
290
463
  className: 'tk-dock-toggle',
291
464
  type: 'button',
292
465
  'aria-expanded': open === true ? 'true' : 'false',
466
+ 'aria-controls': 'dsh-toolkit-dock-body',
293
467
  onClick: () => setOpen(open !== true),
294
468
  },
295
- h('span', { className: 'tk-dock-title' }, translate('dockTitle')),
296
- h('span', { className: 'tk-dock-sub' }, translate('dockSub')),
297
- h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸'))),
469
+ h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
470
+ h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
471
+ h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
472
+ open !== true && checker.result !== null
473
+ ? h('span', {
474
+ className: 'tk-chip',
475
+ 'data-ok': checker.result.ok === true ? 'true' : 'false',
476
+ }, checker.result.ok === true
477
+ ? COPY[lang].chipPass
478
+ : COPY[lang].chipFail((checker.result.checks ?? []).filter((entry) => entry.ok !== true).length))
479
+ : null,
480
+ h(LangSwitch, { lang })),
298
481
  open === true
299
- ? h('div', { className: 'tk-dock-body' },
300
- h('div', { className: 'tk-guide' },
482
+ ? h('div', { className: 'tk-dock-body', id: 'dsh-toolkit-dock-body' },
483
+ // The input comes first: the guide is reference material, and making
484
+ // people scroll past a wall of prose to reach the one control they
485
+ // came for is how a docked panel stops being used.
486
+ h(Form, { checker }),
487
+ h(Result, { checker }),
488
+ h('details', { className: 'tk-guide-all', open: true },
489
+ h('summary', { className: 'tk-guide-summary' }, translate('guideTitle', lang)),
490
+ h('div', { className: 'tk-guide' },
301
491
  h('h3', {}, guide.who.title),
302
492
  h('p', {}, guide.who.body)),
303
493
  h('div', { className: 'tk-guide' },
@@ -313,11 +503,9 @@ window.__ModuleLoader__.load({
313
503
  h('div', { className: 'tk-guide' },
314
504
  h('h3', {}, guide.notes.title),
315
505
  h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
316
- h('div', { className: 'tk-guide' },
317
- h('h3', {}, guide.cli.title),
318
- h('p', {}, h('code', {}, guide.cli.body))),
319
- h(Form, { checker }),
320
- h(Result, { checker }))
506
+ h('div', { className: 'tk-guide' },
507
+ h('h3', {}, guide.cli.title),
508
+ h('p', {}, h('code', {}, guide.cli.body)))))
321
509
  : null);
322
510
  }
323
511
 
@@ -46,7 +46,13 @@ export function createModuleLoader({ require: provide, onLoad } = {}) {
46
46
  export function loadBundle(file, options = {}) {
47
47
  const source = readBundleSource(file);
48
48
  const loader = createModuleLoader(options);
49
- const context = { window: { __ModuleLoader__: loader }, __ModuleLoader__: loader, ...(options.globals ?? {}) };
49
+ // The bundle's `window` must be the real one: a plugin reads
50
+ // `window.localStorage`, `window.matchMedia`, `window.navigator`, and a
51
+ // stand-in that only carries the loader turns every one of those into a
52
+ // silent `undefined`.
53
+ const hostWindow = options.window ?? {};
54
+ hostWindow.__ModuleLoader__ = loader;
55
+ const context = { window: hostWindow, __ModuleLoader__: loader, ...(options.globals ?? {}) };
50
56
  // The bundle is an IIFE or a top-level statement list; the browser would run it
51
57
  // as a classic script, so a function body is the closest equivalent.
52
58
  const run = new Function('window', '__ModuleLoader__', 'require', 'module', 'exports', source);
@@ -69,12 +69,16 @@ export async function mountClient(options) {
69
69
  // host never throws, and "the service threw" is the failure mode that costs
70
70
  // the most to debug in the real app.
71
71
  const host = options.host ?? createHost({ locale: copy, fetch: fetchImpl, surface });
72
+ // A caller that passes both a host and a fetch stub means the stub for this
73
+ // mount: silently keeping the host's own fetch answers 404 to everything and
74
+ // the test then blames the panel.
75
+ if (options.host !== undefined && fetchImpl !== undefined) host.fetch = fetchImpl;
72
76
  // The panel calls the browser's fetch, not a service, so the stub has to be
73
77
  // the global one for the duration of the mount.
74
78
  const previousFetch = globalThis.fetch;
75
79
  globalThis.fetch = host.fetch;
76
80
  try {
77
- const exported = loadBundle(file, { require: moduleShim(modules) });
81
+ const exported = loadBundle(file, { require: moduleShim(modules), window });
78
82
  const apply = exported.apply ?? exported.default ?? exported.install ?? exported;
79
83
  if (typeof apply !== 'function') {
80
84
  throw new Error(`${file} exports no apply(ctx) function, so there is nothing to mount`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
5
5
  "type": "module",
6
6
  "bin": {