dsh-plugin-toolkit 0.11.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,34 @@
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
+
1
32
  ## 0.11.0
2
33
 
3
34
  - **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
package/README.md CHANGED
@@ -101,6 +101,12 @@ in the right sidebar, for when the conversation is busy.
101
101
  The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
102
102
  in the dock's header row (and in the sidebar tab) and the choice is remembered.
103
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
+
104
110
  ## Test a panel without a browser
105
111
 
106
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
@@ -59,6 +59,12 @@ cd my-panel && npm install && npm test
59
59
 
60
60
  面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
61
61
 
62
+ 不开应用也能看:
63
+
64
+ ```sh
65
+ node scripts/preview.mjs /tmp/panel.html # 收起、展开、有结果、侧栏标签
66
+ ```
67
+
62
68
  ## 不用浏览器测面板
63
69
 
64
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, useEffect } = 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: '对任意插件文件夹跑一遍安装前的检查',
@@ -204,23 +220,47 @@ window.__ModuleLoader__.load({
204
220
 
205
221
  const CSS = [
206
222
  '.tk-panel{padding:16px;display:flex;flex-direction:column;gap:10px;font-size:13px}',
207
- '.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}',
208
224
  '.tk-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
209
225
  '.tk-form{display:flex;gap:8px}',
210
226
  '.tk-input{flex:1;min-width:0;height:28px;padding:0 9px;font:inherit;font-size:12px;',
211
227
  'border:0.5px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;',
212
228
  'background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}',
229
+ '.tk-input:hover{border-color:var(--dsw-alias-border-l3,#c9ccd1)}',
213
230
  '.tk-input:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
214
231
  '.tk-button{height:28px;padding:0 12px;border-radius:6px;border:0;cursor:pointer;font:inherit;',
215
232
  'background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
216
- '.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)}',
217
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}',
218
255
  '.tk-verdict{margin:0;font-size:12px;font-weight:600}',
219
256
  '.tk-verdict[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a)}',
220
257
  '.tk-verdict[data-ok="false"]{color:var(--dsw-alias-state-warn-primary,#b7791f)}',
221
258
  '.tk-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
222
- '.tk-row{display:flex;gap:8px;align-items:baseline;padding:5px 8px;border-radius:6px;',
223
- '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)}',
224
264
  '.tk-mark{width:12px;flex:none;font-weight:600}',
225
265
  '.tk-row[data-ok="true"] .tk-mark{color:var(--dsw-alias-state-success-primary,#2f855a)}',
226
266
  '.tk-row[data-ok="false"] .tk-mark{color:var(--dsw-alias-state-error-primary,#c0392b)}',
@@ -228,7 +268,7 @@ window.__ModuleLoader__.load({
228
268
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
229
269
  'color:var(--dsw-alias-label-tertiary,#888)}',
230
270
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
231
- '.tk-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
271
+ '.tk-panel-head{display:flex;align-items:center;gap:8px}',
232
272
  '.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
233
273
  'border-radius:6px;overflow:hidden}',
234
274
  '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
@@ -237,22 +277,32 @@ window.__ModuleLoader__.load({
237
277
  '.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
238
278
  '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
239
279
  '.tk-dock-head{display:flex}',
240
- '.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;',
241
281
  'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
242
282
  '.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
243
283
  'outline-offset:-1px;border-radius:4px}',
244
284
  '.tk-dock-title{font-weight:600}',
245
285
  '.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
246
286
  'color:var(--dsw-alias-label-tertiary,#888)}',
247
- '.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
248
- '.tk-dock-body{display:flex;flex-direction:column;gap:12px;padding:2px 2px 14px;',
249
- 'max-height:46vh;overflow:auto;font-size:12px}',
250
- '.tk-guide{margin:0;display:flex;flex-direction:column;gap:5px}',
251
- '.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)}',
252
302
  '.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
253
303
  '.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
254
304
  'color:var(--dsw-alias-label-secondary,#555)}',
255
- '.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}',
256
306
  '.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
257
307
  'color:var(--dsw-alias-label-primary,#222)}',
258
308
  '.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
@@ -329,13 +379,47 @@ window.__ModuleLoader__.load({
329
379
  function Result({ checker }) {
330
380
  const lang = useLang();
331
381
  const tr = (key) => translate(key, lang);
332
- return h('div', { className: 'tk-result' },
382
+ return h('div', { className: 'tk-result', 'aria-busy': checker.busy === true ? 'true' : 'false' },
333
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,
334
387
  checker.result !== null
335
- ? h('div', { className: 'tk-result-body' },
336
- h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
337
- checker.result.ok === true ? tr('pass') : tr('fail')),
338
- 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 }))))
339
423
  : null);
340
424
  }
341
425
 
@@ -363,7 +447,15 @@ window.__ModuleLoader__.load({
363
447
  const lang = useLang();
364
448
  const guide = GUIDE[lang] ?? GUIDE.zh;
365
449
 
366
- 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
+ },
367
459
  h('div', { className: 'tk-dock-head' },
368
460
  // The language switch is a sibling of the toggle, never inside it: a
369
461
  // button inside a button is invalid and React drops the inner one.
@@ -371,15 +463,31 @@ window.__ModuleLoader__.load({
371
463
  className: 'tk-dock-toggle',
372
464
  type: 'button',
373
465
  'aria-expanded': open === true ? 'true' : 'false',
466
+ 'aria-controls': 'dsh-toolkit-dock-body',
374
467
  onClick: () => setOpen(open !== true),
375
468
  },
376
469
  h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
377
470
  h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
378
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,
379
480
  h(LangSwitch, { lang })),
380
481
  open === true
381
- ? h('div', { className: 'tk-dock-body' },
382
- 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' },
383
491
  h('h3', {}, guide.who.title),
384
492
  h('p', {}, guide.who.body)),
385
493
  h('div', { className: 'tk-guide' },
@@ -395,11 +503,9 @@ window.__ModuleLoader__.load({
395
503
  h('div', { className: 'tk-guide' },
396
504
  h('h3', {}, guide.notes.title),
397
505
  h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
398
- h('div', { className: 'tk-guide' },
399
- h('h3', {}, guide.cli.title),
400
- h('p', {}, h('code', {}, guide.cli.body))),
401
- h(Form, { checker }),
402
- h(Result, { checker }))
506
+ h('div', { className: 'tk-guide' },
507
+ h('h3', {}, guide.cli.title),
508
+ h('p', {}, h('code', {}, guide.cli.body)))))
403
509
  : null);
404
510
  }
405
511
 
@@ -69,6 +69,10 @@ 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.11.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": {