dsh-web-icon-indicator 0.5.2 → 0.6.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
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.2...v0.6.0) (2026-09-24)
11
+
12
+ ### Added
13
+
14
+ * show the settings form on the plugin's own page ([a85a8b0](https://github.com/waknow/dsh-web-icon-indicator/commit/a85a8b0ce3821743c45bd8cca6ea0d969e8a419f))
15
+
10
16
  ## [0.5.2](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.5.1...v0.5.2) (2026-09-24)
11
17
 
12
18
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm downloads](https://img.shields.io/npm/dm/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
9
9
 
10
- > **⚠️ DSH version support** — requires **DSH ≥ 0.1.2-rc.1**. One bundle serves both settings generations: **modern** (≥ 0.1.7-alpha.1: exported `Config` schema + `configForms` + `plugins.row.config`) and **legacy** (≤ 0.1.6-alpha.1: `settings.installSection` + `settingsScope` + `settings.plugin.item`). Verified on **DSH 0.1.5-rc.3** and **DSH 0.1.7-alpha.1**; on any older host the favicon still works even if the settings page is not reachable.
10
+ > **⚠️ DSH version support** — requires **DSH ≥ 0.1.2-rc.1**. One bundle serves both settings generations: **modern** (≥ 0.1.7-alpha.1: exported `Config` schema + `configForms` + `plugins.bundle.config`) and **legacy** (≤ 0.1.6-alpha.1: `settings.installSection` + `settingsScope` + `settings.plugin.item`). Verified on **DSH 0.1.5-rc.3** and **DSH 0.1.7-alpha.1**; on any older host the favicon still works even if the settings page is not reachable.
11
11
 
12
12
  Browser tab favicon reflects the current DSH session state — `idle` / `running` / `asking` / `done` — so you can see at a glance whether a session needs your attention, even when the tab is in the background.
13
13
 
@@ -227,9 +227,12 @@ patch declares); **≤ 0.1.6-alpha.1** uses the plugin-chosen
227
227
  `web-icon-indicator`, the same string 0.5.x used, so an existing section keeps
228
228
  resolving:
229
229
 
230
- - **Web GUI:** open **设置 → 插件**, expand the **dsh-web-icon-indicator**
231
- bundle and configure its row. The *Favicon indicator* page edits the same
232
- keys: asking/done hold, the **default icon color** (with a
230
+ - **Web GUI:** open **设置 → 插件** and click the **dsh-web-icon-indicator**
231
+ card — the settings form opens directly on the plugin's page (the same place
232
+ the official plugins put theirs) — including on **0.1.6-alpha.2**, which still
233
+ binds through the legacy `settingsScope` but already renders the bundle slot.
234
+ The
235
+ *Favicon indicator* page edits the same keys: asking/done hold, the **default icon color** (with a
233
236
  live palette preview and the similarity warning), and the per-state effect /
234
237
  colors / cycle for `running` / `asking` / `done`. Each of those states is a
235
238
  collapsible row whose header shows one **color chip** per state — split in two
@@ -274,8 +277,10 @@ resolving:
274
277
  omitted from the resolved form until a user sets it.
275
278
  - The browser half is a hand-written `lib/client.js` (ModuleLoader factory
276
279
  format — no build step, no runtime deps beyond the shell's `react`). It
277
- registers on both page slots (`plugins.row.config` — gated on the served
278
- namespace — and the legacy `settings.plugin.item`) and resolves whichever
280
+ registers on the slot the running host declares — `plugins.bundle.config`
281
+ on ≥ 0.1.7 (so the Plugins list's card opens straight onto the form), else
282
+ `plugins.row.config`; both gated on the served namespace — plus the legacy
283
+ `settings.plugin.item` — and resolves whichever
279
284
  settings provider the host exposes (`configForms` or `settingsScope`) at
280
285
  render time, so neither is a hard dependency of the module. The DSH client
281
286
  scanner picks a new `dsh.client` declaration up on the next profile start.
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm downloads](https://img.shields.io/npm/dm/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
9
9
 
10
- > **⚠️ DSH 版本支持** —— 需要 **DSH ≥ 0.1.2-rc.1**。同一份插件同时兼容两代 settings 契约:**新一代**(≥ 0.1.7-alpha.1:导出 `Config` schema + `configForms` + `plugins.row.config`)与**旧一代**(≤ 0.1.6-alpha.1:`settings.installSection` + `settingsScope` + `settings.plugin.item`)。已在 **DSH 0.1.5-rc.3** 与 **DSH 0.1.7-alpha.1** 上验证;更早的宿主上 favicon 仍可用,只是配置页可能不可达。
10
+ > **⚠️ DSH 版本支持** —— 需要 **DSH ≥ 0.1.2-rc.1**。同一份插件同时兼容两代 settings 契约:**新一代**(≥ 0.1.7-alpha.1:导出 `Config` schema + `configForms` + `plugins.bundle.config`)与**旧一代**(≤ 0.1.6-alpha.1:`settings.installSection` + `settingsScope` + `settings.plugin.item`)。已在 **DSH 0.1.5-rc.3** 与 **DSH 0.1.7-alpha.1** 上验证;更早的宿主上 favicon 仍可用,只是配置页可能不可达。
11
11
 
12
12
  浏览器标签页 favicon 实时反映 DSH 会话状态——`待机` / `运行中` / `提问` / `完成`——让你在标签页置于后台时也能一眼看出是否有会话需要处理。
13
13
 
@@ -205,8 +205,9 @@ schemastery schema),**并在宿主仍提供旧版服务时**用同一份 sch
205
205
  表单,即 `dsh-web-icon-indicator`(bundle patch 声明的行 id);**≤ 0.1.6-alpha.1**
206
206
  使用插件自定的 `web-icon-indicator`,与 0.5.x 保持一致,老用户的 section 继续生效:
207
207
 
208
- - **Web GUI:** 打开 **设置 → 插件**,展开 **dsh-web-icon-indicator** bundle 并
209
- 配置它的行,即出现 *标签页图标指示器* 页面,可编辑:提问/完成驻留、
208
+ - **Web GUI:** 打开 **设置 → 插件**,点开 **dsh-web-icon-indicator** 卡片,
209
+ 设置表单就直接出现在插件页上(与官方插件的位置一致);**0.1.6-alpha.2** 虽然还在用
210
+ 旧的 `settingsScope`,但它同样渲染 bundle 插槽,因此表单位置一致。可编辑:提问/完成驻留、
210
211
  **默认图标颜色**(带配色一览与相似度告警),以及
211
212
  `running` / `asking` / `done` 三个状态各自的特效 / 颜色 / 周期。这三个状态
212
213
  各占一行可折叠条目,行首是一个**色块**——多色状态(asking)会左右分格同时显示
@@ -239,8 +240,9 @@ schemastery schema),**并在宿主仍提供旧版服务时**用同一份 sch
239
240
  - 因此实时设置面覆盖 `askingHoldMs`、`doneHoldMs`、`iconsDir`、`defaultColor`
240
241
  与 `states`。`iconsDir` 没有 schema 默认值,用户未设置时不会出现在解析后的表单中。
241
242
  - 浏览器半区是手写的 `lib/client.js`(ModuleLoader factory 格式——无构建步骤、
242
- 无额外运行期依赖,仅用 shell 自带的 `react`)。它同时往两代页面插槽注册
243
- (`plugins.row.config`——按「Host 是否提供该命名空间」门控;以及旧的
243
+ 无额外运行期依赖,仅用 shell 自带的 `react`)。它按宿主实际声明的插槽注册
244
+ (≥ 0.1.7 用 `plugins.bundle.config`,这样插件列表的卡片点开即见表单;否则退回
245
+ `plugins.row.config`——两者都按「Host 是否提供该命名空间」门控;另有旧的
244
246
  `settings.plugin.item`),并在渲染时按宿主实际提供的服务解析表单
245
247
  (`configForms` 或 `settingsScope`),因此两者都不是模块的硬依赖。DSH 客户端
246
248
  扫描器会在下次启动 profile 时识别新的 `dsh.client` 声明。
package/lib/client.js CHANGED
@@ -5,9 +5,15 @@
5
5
  * Registers one configuration page and serves BOTH host generations from one
6
6
  * bundle:
7
7
  *
8
- * - DSH ≥ 0.1.7 (modern): page slot `plugins.row.config` keyed
9
- * `<package name>#<row id>`, bound to the profile-entry settings namespace
10
- * through `configForms` (`ctx.configForms.get(entryId)`).
8
+ * - DSH ≥ 0.1.7 (modern): the official Plugins page, bound to the
9
+ * profile-entry settings namespace through `configForms`
10
+ * (`ctx.configForms.get(entryId)`). The form registers into
11
+ * `plugins.bundle.config` keyed by the bundle's package name, which the
12
+ * bundle's page renders under its description — the page a card in the
13
+ * Plugins list opens, so the settings are one click from that list (the
14
+ * shape the official plugins use). A host that declares only the row slot —
15
+ * no released host does (see AGENTS.md) — gets the row's configure control
16
+ * instead; the two are mutually exclusive, never both.
11
17
  * - DSH ≤ 0.1.6-alpha.1 (legacy): page slot `settings.plugin.item` keyed by the
12
18
  * legacy `web-icon-indicator` namespace, bound through `settingsScope`.
13
19
  *
@@ -57,6 +63,14 @@ window.__ModuleLoader__.load({
57
63
  * settings tab dispatches in `settings.plugin.item`.
58
64
  */
59
65
  var LEGACY_NS = "web-icon-indicator";
66
+ /**
67
+ * `plugins.bundle.config` key: the bundle's own package name. The bundle
68
+ * page renders this entry between its description and its rows, and that
69
+ * page is exactly what a card in the Plugins list opens — so this is the
70
+ * slot that keeps the settings one click from the list. Must match the
71
+ * installed package name in `package.json`.
72
+ */
73
+ var BUNDLE_KEY = "dsh-web-icon-indicator";
60
74
  /** `plugins.row.config` key: `<bundle package name>#<row id as the patch declares it>`. */
61
75
  var ROW_KEY = "dsh-web-icon-indicator#dsh-web-icon-indicator";
62
76
  var LOCALE = "dsh-web-icon-indicator";
@@ -1355,26 +1369,67 @@ window.__ModuleLoader__.load({
1355
1369
  // card follows whichever provider is mounted and renders nothing while
1356
1370
  // neither is.
1357
1371
  var pageFace = function () { return { scope: resolveScope(ctx), t: t }; };
1358
- // Register on BOTH page slots. `ctx.slots.inject(key, …)` is a no-op for a
1359
- // slot this host never declares, so each line runs exactly the branch it
1360
- // understands:
1361
- // - `plugins.row.config` (≥0.1.6-alpha.2): keyed `<package>#<row id>`,
1362
- // the row's configure control comes from this registration;
1363
- // - `settings.plugin.item` (≤0.1.6-alpha.1): keyed by the legacy
1372
+ // One settings card, three page slots across the host generations.
1373
+ // `ctx.slots.inject(key, …)` is a no-op for a slot this host never
1374
+ // declares, so `start` runs exactly the branches the running host
1375
+ // understands. A DSH ≥ 0.1.7 host declares BOTH modern slots, so those
1376
+ // two branches are mutually exclusive:
1377
+ // - `plugins.bundle.config` (≥ 0.1.7): keyed by the bundle's own package
1378
+ // name. The bundle page renders it under the description, and that
1379
+ // page is exactly what a card in the Plugins list opens — so the
1380
+ // settings sit one click from the list. This is the shape the
1381
+ // official plugins use (the official voice-input bundle registers
1382
+ // this slot).
1383
+ // - `plugins.row.config` (≥ 0.1.6-alpha.2): keyed `<package>#<row id>`,
1384
+ // it only adds the configure control on the bundle page's row, which
1385
+ // buries the form one level deeper (bundle page -> row -> configure).
1386
+ // Registered only where the bundle slot is absent — a guard no
1387
+ // released host reaches, since every host with a Plugins page
1388
+ // declares the bundle slot too (see AGENTS.md).
1389
+ // - `settings.plugin.item` (≤ 0.1.6-alpha.1): keyed by the legacy
1364
1390
  // settings namespace, dispatched by the settings tab per served
1365
1391
  // namespace.
1366
1392
  var start = function () {
1367
- var disposeRow = ctx.slots.inject("plugins.row.config", function () {
1393
+ var rowInject = null;
1394
+ var bundleLive = false;
1395
+ var dropRow = function () {
1396
+ var cancelRow = rowInject;
1397
+ rowInject = null;
1398
+ if (cancelRow !== null) cancelRow();
1399
+ };
1400
+ var disposeBundle = ctx.slots.inject("plugins.bundle.config", function () {
1401
+ bundleLive = true;
1402
+ // The bundle slot owns the card wherever it exists: cancel the row
1403
+ // wait, or dispose a row registration the host declared first, so the
1404
+ // same form is never drawn twice on one page.
1405
+ dropRow();
1368
1406
  return ctx.slots.register(
1369
1407
  {
1370
- name: "plugins.row.config",
1371
- key: ROW_KEY,
1408
+ name: "plugins.bundle.config",
1409
+ key: BUNDLE_KEY,
1372
1410
  locale: LOCALE,
1373
1411
  inject: pageFace,
1374
1412
  },
1375
1413
  IconConfigCard
1376
1414
  );
1377
1415
  });
1416
+ if (!bundleLive) {
1417
+ rowInject = ctx.slots.inject("plugins.row.config", function () {
1418
+ // The bundle slot may still be declared after this one (both slots
1419
+ // arrive with the Plugins page's own registration): stay out of its
1420
+ // way rather than registering a second copy of the same card.
1421
+ if (bundleLive) return function () {};
1422
+ return ctx.slots.register(
1423
+ {
1424
+ name: "plugins.row.config",
1425
+ key: ROW_KEY,
1426
+ locale: LOCALE,
1427
+ inject: pageFace,
1428
+ },
1429
+ IconConfigCard
1430
+ );
1431
+ });
1432
+ }
1378
1433
  var disposeItem = ctx.slots.inject("settings.plugin.item", function () {
1379
1434
  return ctx.slots.register(
1380
1435
  {
@@ -1386,7 +1441,7 @@ window.__ModuleLoader__.load({
1386
1441
  IconConfigCard
1387
1442
  );
1388
1443
  });
1389
- return function () { disposeRow(); disposeItem(); };
1444
+ return function () { disposeBundle(); dropRow(); disposeItem(); };
1390
1445
  };
1391
1446
  // On the modern line, gate registration on the served namespace so an
1392
1447
  // uncomposed plugin shows no page; `whileServed` owns the returned
@@ -2,10 +2,16 @@
2
2
  * Browser half of dsh-web-icon-indicator (dsh.client bundle).
3
3
  *
4
4
  * Registers one configuration page and serves both host generations from one
5
- * bundle: `plugins.row.config` (keyed `dsh-web-icon-indicator#dsh-web-icon-indicator`)
6
- * bound through the modern `configForms` service on DSH ≥ 0.1.7, and
7
- * `settings.plugin.item` (keyed by the legacy `web-icon-indicator` namespace)
8
- * bound through the legacy `settingsScope` service on DSH ≤ 0.1.6-alpha.1.
5
+ * bundle. Modern (DSH ≥ 0.1.7): `plugins.bundle.config` keyed by the bundle
6
+ * package name `dsh-web-icon-indicator`, so the bundle page a Plugins-list card
7
+ * opens renders the form directly; a host that declares only
8
+ * `plugins.row.config` (no released host does) gets that slot instead, keyed
9
+ * `dsh-web-icon-indicator#dsh-web-icon-indicator` — never both. Either binds
10
+ * through `configForms` on ≥ 0.1.7 or `settingsScope` on 0.1.6-alpha.2 (which
11
+ * already renders the bundle slot). Legacy (DSH ≤ 0.1.6-alpha.1):
12
+ * `settings.plugin.item`, keyed by the legacy `web-icon-indicator` namespace,
13
+ * bound through `settingsScope`.
14
+ *
9
15
  * Neither settings provider appears in `inject`: both are read weakly at render
10
16
  * time, so the fiber activates on either line.
11
17
  */
@@ -21,7 +21,8 @@
21
21
  * The same surface is registered with the DSH settings service under the
22
22
  * `web-icon-indicator` namespace (schemastery schema in `lib/index.js`): it is
23
23
  * validated, persisted into the profile's `settings.yaml`, and editable from
24
- * 设置 → 插件 → 插件配置 through the browser half (`./client`). While no
24
+ * the Plugins page (设置 → 插件) through the browser half (`./client`) — the
25
+ * settings form is registered on the plugin's own page. While no
25
26
  * settings service is composed, the plugin reads the composition entry only.
26
27
  */
27
28
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-web-icon-indicator",
3
3
  "description": "Browser tab favicon reflects the DSH session state: idle / running / asking / done, recolored and animated from one base SVG in the browser (fully configurable colors + effects). · 浏览器标签页 favicon 实时反映 DSH 会话状态:待机 / 运行中 / 提问 / 完成,由单个 base.svg 在浏览器中动态上色与动画(颜色与特效完全可配置)。",
4
4
  "icon": "icons/plugin.svg",
5
- "version": "0.5.2",
5
+ "version": "0.6.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "dsh": ">=0.1.2-rc.1"
package/test/verify.js CHANGED
@@ -1575,6 +1575,7 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1575
1575
  const makeClientCtx = (opts) => {
1576
1576
  const declared = new Set(opts.declared || []);
1577
1577
  const services = opts.services || {};
1578
+ const sink = opts.sink || registered;
1578
1579
  return {
1579
1580
  get(name) { return services[name]; },
1580
1581
  locale: {
@@ -1583,13 +1584,23 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1583
1584
  },
1584
1585
  slots: {
1585
1586
  inject(name, cb) { if (declared.has(name)) cb(); return () => {}; },
1586
- register(slotSpec, component) { registered.push({ slotSpec, component }); return () => {}; },
1587
+ register(slotSpec, component) {
1588
+ const entry = { slotSpec, component, disposed: false };
1589
+ sink.push(entry);
1590
+ return () => { entry.disposed = true; };
1591
+ },
1587
1592
  },
1588
1593
  effect(fn) { return fn(); },
1589
1594
  };
1590
1595
  };
1596
+ // A DSH >= 0.1.7 host declares BOTH configuration slots when the Plugins
1597
+ // page's own registration commits, and the card must land in exactly one of
1598
+ // them: `plugins.bundle.config` renders on the bundle's page — the page a
1599
+ // Plugins-list card opens, so one click from the list — while
1600
+ // `plugins.row.config` only puts a configure control on that page's row,
1601
+ // one level deeper. Registering into both would draw the same form twice.
1591
1602
  const clientCtx = makeClientCtx({
1592
- declared: ["plugins.row.config"],
1603
+ declared: ["plugins.bundle.config", "plugins.row.config"],
1593
1604
  services: {
1594
1605
  configForms: {
1595
1606
  get(entryId) { boundEntryId = entryId; return scope; },
@@ -1602,15 +1613,18 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1602
1613
  ok("F6 locale dictionaries registered (en + zh)",
1603
1614
  dictionaries.length === 1 && !!dictionaries[0].dicts.en && !!dictionaries[0].dicts.zh,
1604
1615
  "ns=" + (dictionaries[0] && dictionaries[0].ns));
1605
- ok("F7 modern line: one card registered into plugins.row.config",
1606
- registered.length === 1 && registered[0].slotSpec.name === "plugins.row.config",
1616
+ ok("F7 modern line: one card registered into plugins.bundle.config",
1617
+ registered.length === 1 && registered[0].slotSpec.name === "plugins.bundle.config",
1607
1618
  "count=" + registered.length + " " + JSON.stringify(registered.map((r) => r.slotSpec.name)));
1608
1619
  ok("F7b registration is gated on whileServed(entry id)",
1609
1620
  Array.isArray(servedNamespaces) && servedNamespaces.length === 1 &&
1610
1621
  servedNamespaces[0] === "dsh-web-icon-indicator",
1611
1622
  JSON.stringify(servedNamespaces));
1612
1623
  const slotSpec = registered[0] && registered[0].slotSpec;
1613
- eq("F8 slot key is <package>#<row id>", slotSpec && slotSpec.key, "dsh-web-icon-indicator#dsh-web-icon-indicator");
1624
+ eq("F8 bundle slot key is the bundle package name", slotSpec && slotSpec.key, "dsh-web-icon-indicator");
1625
+ ok("F8b the row slot stays untouched where the bundle slot exists",
1626
+ registered.every((r) => r.slotSpec.name !== "plugins.row.config"),
1627
+ JSON.stringify(registered.map((r) => r.slotSpec.name)));
1614
1628
  eq("F9 slot locale namespace", slotSpec && slotSpec.locale, "dsh-web-icon-indicator");
1615
1629
  // The scope is resolved lazily inside the inject face (the two settings
1616
1630
  // providers are mutually exclusive), so evaluate it before asserting.
@@ -1663,6 +1677,99 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1663
1677
  ok("F61f legacy component renders the card", !!legacyCard && legacyCard.type === "div");
1664
1678
  }
1665
1679
 
1680
+ // ---- the row-only Plugins page (DSH 0.1.6-alpha.2) ------------------------
1681
+ // The bundle slot arrived with the 0.1.7 Plugins page. A host that declares
1682
+ // only `plugins.row.config` keeps that older entry point — the configure
1683
+ // control on the row the bundle declares — and must still get exactly one
1684
+ // card, keyed by the row slot's `<package>#<row id>`.
1685
+ {
1686
+ const rowSink = [];
1687
+ const rowCtx = makeClientCtx({
1688
+ declared: ["plugins.row.config"],
1689
+ sink: rowSink,
1690
+ services: {
1691
+ configForms: {
1692
+ get: () => scope,
1693
+ whileServed: (namespaces, register) => register(new Set(namespaces)),
1694
+ },
1695
+ },
1696
+ });
1697
+ client.apply(rowCtx);
1698
+ eq("F66 row-only page: exactly one registration", rowSink.length, 1);
1699
+ eq("F66b row-only page: registered into plugins.row.config",
1700
+ rowSink[0] && rowSink[0].slotSpec.name, "plugins.row.config");
1701
+ eq("F66c row-only page: key is <package>#<row id>",
1702
+ rowSink[0] && rowSink[0].slotSpec.key, "dsh-web-icon-indicator#dsh-web-icon-indicator");
1703
+ }
1704
+
1705
+ // ---- both modern slots, declared in either order --------------------------
1706
+ // The real 0.1.7 host declares `plugins.bundle.config` and
1707
+ // `plugins.row.config` from the Plugins page's own registration, so both
1708
+ // waits this bundle installs resolve AFTER apply(). Whichever declaration
1709
+ // lands first, exactly one entry must survive — and it must be the bundle
1710
+ // one, or the settings would sit a level deeper than the official plugins'.
1711
+ {
1712
+ const makeLazyCtx = (sink) => {
1713
+ const pending = new Map();
1714
+ const ctx = {
1715
+ get: (name) => (name === "configForms"
1716
+ ? { get: () => scope, whileServed: (_ns, register) => register(new Set()) }
1717
+ : undefined),
1718
+ locale: { register: () => () => {}, bind: () => (k) => k },
1719
+ slots: {
1720
+ inject(name, cb) {
1721
+ const controller = { cb, active: true, disposers: [] };
1722
+ if (!pending.has(name)) pending.set(name, []);
1723
+ pending.get(name).push(controller);
1724
+ return () => {
1725
+ if (!controller.active) return;
1726
+ controller.active = false;
1727
+ for (const dispose of controller.disposers.splice(0)) dispose();
1728
+ };
1729
+ },
1730
+ register(slotSpec, component) {
1731
+ const entry = { slotSpec, component, disposed: false };
1732
+ sink.push(entry);
1733
+ return () => { entry.disposed = true; };
1734
+ },
1735
+ },
1736
+ effect: (fn) => fn(),
1737
+ };
1738
+ return {
1739
+ ctx,
1740
+ declare(name) {
1741
+ for (const controller of pending.get(name) || []) {
1742
+ if (!controller.active) continue;
1743
+ const dispose = controller.cb();
1744
+ if (typeof dispose === "function") controller.disposers.push(dispose);
1745
+ }
1746
+ },
1747
+ };
1748
+ };
1749
+
1750
+ const rowFirstSink = [];
1751
+ const rowFirst = makeLazyCtx(rowFirstSink);
1752
+ client.apply(rowFirst.ctx);
1753
+ rowFirst.declare("plugins.row.config");
1754
+ eq("F67 row declaration first: the row entry is registered", rowFirstSink.length, 1);
1755
+ eq("F67b ... into plugins.row.config", rowFirstSink[0].slotSpec.name, "plugins.row.config");
1756
+ rowFirst.declare("plugins.bundle.config");
1757
+ const survivors = rowFirstSink.filter((entry) => !entry.disposed);
1758
+ eq("F67c the bundle declaration takes the card over — one live entry", survivors.length, 1);
1759
+ eq("F67d ... and it is the bundle entry",
1760
+ survivors[0] && survivors[0].slotSpec.name, "plugins.bundle.config");
1761
+ eq("F67e ... keyed by the bundle package name",
1762
+ survivors[0] && survivors[0].slotSpec.key, "dsh-web-icon-indicator");
1763
+
1764
+ const bundleFirstSink = [];
1765
+ const bundleFirst = makeLazyCtx(bundleFirstSink);
1766
+ client.apply(bundleFirst.ctx);
1767
+ bundleFirst.declare("plugins.bundle.config");
1768
+ bundleFirst.declare("plugins.row.config");
1769
+ eq("F68 bundle declaration first: exactly one entry", bundleFirstSink.length, 1);
1770
+ eq("F68b ... the bundle one", bundleFirstSink[0].slotSpec.name, "plugins.bundle.config");
1771
+ }
1772
+
1666
1773
  // ---- card render ---------------------------------------------------------
1667
1774
  // React is stubbed, so a re-render is driven explicitly: the card's own
1668
1775
  // setState calls `rerender()`, which re-invokes the component with the same
@@ -2391,6 +2498,7 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
2391
2498
  CLIENT_SRC.indexOf("settingsScope") !== -1 &&
2392
2499
  CLIENT_SRC.indexOf("settings.plugin.item") !== -1 &&
2393
2500
  CLIENT_SRC.indexOf("configForms") !== -1 &&
2501
+ CLIENT_SRC.indexOf("plugins.bundle.config") !== -1 &&
2394
2502
  CLIENT_SRC.indexOf("plugins.row.config") !== -1 &&
2395
2503
  CLIENT_SRC.indexOf("whileServed") !== -1);
2396
2504