dsh-plugin-shop 0.8.1 → 0.8.2

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/lib/client.js CHANGED
@@ -4355,7 +4355,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4355
4355
  });
4356
4356
  const dsh_plugin_shop_shop_installStart_result$schema = union([object({
4357
4357
  "ok": literal(true),
4358
- "installId": string()
4358
+ "installId": string(),
4359
+ "state": union([
4360
+ literal("downloading"),
4361
+ literal("running"),
4362
+ literal("done"),
4363
+ literal("failed")
4364
+ ])
4359
4365
  }), object({
4360
4366
  "ok": literal(false),
4361
4367
  "code": union([
@@ -4373,15 +4379,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4373
4379
  const dsh_plugin_shop_shop_installStatus_result$schema = object({
4374
4380
  "found": boolean(),
4375
4381
  "state": union([
4382
+ literal("downloading"),
4376
4383
  literal("running"),
4377
4384
  literal("done"),
4378
4385
  literal("failed")
4379
4386
  ]),
4380
4387
  "log": array(string()),
4381
- "needsRestart": union([
4388
+ "activation": union([
4382
4389
  _undefined(),
4383
- literal(false),
4384
- literal(true)
4390
+ literal("live"),
4391
+ literal("reload"),
4392
+ literal("restart")
4385
4393
  ]).optional(),
4386
4394
  "restartReason": union([
4387
4395
  _undefined(),
@@ -4389,7 +4397,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4389
4397
  literal("not-simple"),
4390
4398
  literal("host-unsupported"),
4391
4399
  literal("timeout"),
4392
- literal("mount-failed")
4400
+ literal("mount-failed"),
4401
+ literal("client-half")
4393
4402
  ]).optional(),
4394
4403
  "detail": union([_undefined(), string()]).optional()
4395
4404
  });
@@ -4401,10 +4410,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4401
4410
  "name": string(),
4402
4411
  "enabled": boolean()
4403
4412
  });
4404
- const dsh_plugin_shop_shop_setEnabled_result$schema = object({
4405
- "ok": boolean(),
4406
- "detail": union([_undefined(), string()]).optional()
4407
- });
4413
+ const dsh_plugin_shop_shop_setEnabled_result$schema = union([object({
4414
+ "ok": literal(true),
4415
+ "activation": union([
4416
+ literal("live"),
4417
+ literal("reload"),
4418
+ literal("restart")
4419
+ ])
4420
+ }), object({
4421
+ "ok": literal(false),
4422
+ "detail": string()
4423
+ })]);
4408
4424
  const dsh_plugin_shop_shop_uninstallStart_parameter_0$schema = object({ "name": string() });
4409
4425
  const dsh_plugin_shop_shop_uninstallStart_result$schema = union([object({
4410
4426
  "ok": literal(true),
@@ -4416,7 +4432,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4416
4432
  const dsh_plugin_shop_shop_updateStart_parameter_0$schema = object({ "version": string() });
4417
4433
  const dsh_plugin_shop_shop_updateStart_result$schema = union([object({
4418
4434
  "ok": literal(true),
4419
- "installId": string()
4435
+ "installId": string(),
4436
+ "state": union([
4437
+ literal("downloading"),
4438
+ literal("running"),
4439
+ literal("done"),
4440
+ literal("failed")
4441
+ ])
4420
4442
  }), object({
4421
4443
  "ok": literal(false),
4422
4444
  "detail": string()
@@ -4425,7 +4447,12 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4425
4447
  "installed": string(),
4426
4448
  "latest": union([literal(null), string()]),
4427
4449
  "outdated": boolean(),
4428
- "restartSupported": boolean()
4450
+ "restartBlocked": union([
4451
+ literal(null),
4452
+ literal("windows"),
4453
+ literal("systemd"),
4454
+ literal("port-zero")
4455
+ ])
4429
4456
  });
4430
4457
  const TYPERT_REMOTE = {
4431
4458
  package: "dsh-plugin-shop",
@@ -4454,7 +4481,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4454
4481
  },
4455
4482
  sourceLocation: {
4456
4483
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4457
- "line": 733,
4484
+ "line": 867,
4458
4485
  "column": 9
4459
4486
  }
4460
4487
  },
@@ -4472,7 +4499,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4472
4499
  },
4473
4500
  sourceLocation: {
4474
4501
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4475
- "line": 966,
4502
+ "line": 1140,
4476
4503
  "column": 9
4477
4504
  }
4478
4505
  },
@@ -4490,7 +4517,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4490
4517
  },
4491
4518
  sourceLocation: {
4492
4519
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4493
- "line": 957,
4520
+ "line": 1131,
4494
4521
  "column": 9
4495
4522
  }
4496
4523
  },
@@ -4518,7 +4545,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4518
4545
  },
4519
4546
  sourceLocation: {
4520
4547
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4521
- "line": 781,
4548
+ "line": 915,
4522
4549
  "column": 9
4523
4550
  }
4524
4551
  },
@@ -4545,7 +4572,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4545
4572
  },
4546
4573
  sourceLocation: {
4547
4574
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4548
- "line": 933,
4575
+ "line": 1107,
4549
4576
  "column": 3
4550
4577
  }
4551
4578
  },
@@ -4563,7 +4590,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4563
4590
  },
4564
4591
  sourceLocation: {
4565
4592
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4566
- "line": 1124,
4593
+ "line": 1311,
4567
4594
  "column": 9
4568
4595
  }
4569
4596
  },
@@ -4590,7 +4617,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4590
4617
  },
4591
4618
  sourceLocation: {
4592
4619
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4593
- "line": 590,
4620
+ "line": 692,
4594
4621
  "column": 9
4595
4622
  }
4596
4623
  },
@@ -4618,7 +4645,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4618
4645
  },
4619
4646
  sourceLocation: {
4620
4647
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4621
- "line": 1059,
4648
+ "line": 1233,
4622
4649
  "column": 9
4623
4650
  }
4624
4651
  },
@@ -4645,7 +4672,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4645
4672
  },
4646
4673
  sourceLocation: {
4647
4674
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4648
- "line": 1209,
4675
+ "line": 1379,
4649
4676
  "column": 9
4650
4677
  }
4651
4678
  },
@@ -4663,7 +4690,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4663
4690
  },
4664
4691
  sourceLocation: {
4665
4692
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4666
- "line": 1192,
4693
+ "line": 1363,
4667
4694
  "column": 9
4668
4695
  }
4669
4696
  }
@@ -6237,20 +6264,71 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6237
6264
  return spec.length > HOLDER_SPEC_CAP ? `${spec.slice(0, 79)}…` : spec;
6238
6265
  }
6239
6266
  //#endregion
6267
+ //#region src/shared/install-state.ts
6268
+ /** Whether the host is done with this record: it will not change again, and a
6269
+ * poller may stop. */
6270
+ function isTerminalInstallState(state) {
6271
+ return state === "done" || state === "failed";
6272
+ }
6273
+ //#endregion
6240
6274
  //#region src/client/present.ts
6241
- /** Hot-mount reason code locale key, so the notice reads in the language
6242
- * the person set in dsh. An absent (or unrecognized) reason keeps the generic
6243
- * restart line rather than showing a bare code. */
6244
- function restartReasonKey(reason) {
6245
- switch (reason) {
6275
+ /** The done notice for one activation, as a locale key.
6276
+ *
6277
+ * A hot-mount reason code names WHY a restart is needed and is meaningful
6278
+ * only under `restart`; `live` and `reload` ignore any reason riding along,
6279
+ * because rendering one there would tell a reader their reload had failed.
6280
+ * An absent or unrecognized reason keeps the generic restart line rather
6281
+ * than showing a bare code. */
6282
+ function activationNoticeKey(activation, restartReason) {
6283
+ if (activation === "live") return "installedNoRestartNotice";
6284
+ if (activation === "reload") return "installedReloadNotice";
6285
+ switch (restartReason) {
6246
6286
  case "no-patch": return "hotNoPatchNotice";
6247
6287
  case "not-simple": return "hotNotSimpleNotice";
6248
6288
  case "host-unsupported": return "hotHostUnsupportedNotice";
6249
6289
  case "timeout": return "hotTimeoutNotice";
6250
6290
  case "mount-failed": return "hotMountFailedNotice";
6291
+ case "client-half": return "hotClientHalfNotice";
6251
6292
  default: return "installedRestartNotice";
6252
6293
  }
6253
6294
  }
6295
+ /** The same mapping for an UNINSTALL's done notice.
6296
+ *
6297
+ * A sibling rather than a `prefix` parameter on `activationNoticeKey`: the
6298
+ * two key sets are not parallel — `installedNoRestartNotice` against
6299
+ * `uninstalledLiveNotice` — so no prefix derives one from the other. It takes
6300
+ * no reason code, because a hot-mount reason explains why a MOUNT could not
6301
+ * happen and an uninstall mounts nothing.
6302
+ *
6303
+ * Here rather than in JSX because this is a pure Activation -> copy decision,
6304
+ * and in a component no fixture can reach it: the install side was covered
6305
+ * and this one was not, for the whole time both existed. */
6306
+ function uninstallActivationNoticeKey(activation) {
6307
+ if (activation === "restart") return "uninstalledRestartNotice";
6308
+ return activation === "reload" ? "uninstalledReloadNotice" : "uninstalledLiveNotice";
6309
+ }
6310
+ /** The copy key for one install phase. A function rather than an inline
6311
+ * ternary in the JSX so it is unit-testable: the tab's only test seam is a
6312
+ * whole-tab render driven by injected RPCs, which cannot be handed one view. */
6313
+ function installPhaseKey(phase) {
6314
+ return phase === "downloading" ? "downloading" : "installing";
6315
+ }
6316
+ /** Why the shop cannot restart dsh, as a locale key.
6317
+ *
6318
+ * A sibling of `activationNoticeKey` and exhaustive for the same reason: the
6319
+ * switch has no default, so a fourth reason added to `RestartBlockedReason` is
6320
+ * a type error here rather than a card that silently keeps the third one's
6321
+ * copy. That is the whole point of the type — the single boolean this replaced
6322
+ * had ONE string for three causes, and it named systemd, so a Windows reader
6323
+ * was told to restart a service they do not run and to set an override that
6324
+ * could not have helped. */
6325
+ function restartBlockedNoticeKey(reason) {
6326
+ switch (reason) {
6327
+ case "windows": return "restartBlockedWindowsNotice";
6328
+ case "systemd": return "restartBlockedSystemdNotice";
6329
+ case "port-zero": return "restartBlockedPortZeroNotice";
6330
+ }
6331
+ }
6254
6332
  /** Tier → locale key, for the entry-card tier badge (§6.2). */
6255
6333
  function tierKey(tier) {
6256
6334
  switch (tier) {
@@ -6327,6 +6405,20 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6327
6405
  function isCustomLicense(license) {
6328
6406
  return license !== null && /^SEE LICENSE IN /i.test(license);
6329
6407
  }
6408
+ /** The phase a non-terminal state renders as: the WIRE state the host
6409
+ * reported (`InstallState`) mapped onto the phase the view carries. Exported
6410
+ * because every hook that turns a start or a poll result into a view spells
6411
+ * this same mapping, and three hand-copied ternaries are three chances for
6412
+ * one of them to drift.
6413
+ *
6414
+ * Not to be confused with `installPhaseKey`, which is the OTHER direction and
6415
+ * a different pair of values: view phase → the locale key to render. They
6416
+ * happen to share two spellings today and that is a coincidence of naming,
6417
+ * not a reason to fuse them — the wire union and the locale key set are free
6418
+ * to diverge. */
6419
+ function phaseOf(state) {
6420
+ return state === "downloading" ? "downloading" : "installing";
6421
+ }
6330
6422
  /** §7.2 once-per-second poll cadence, as a named constant. */
6331
6423
  const INSTALL_POLL_MS = 1e3;
6332
6424
  /** How long the check-update button reports "up to date" after a re-check
@@ -6345,7 +6437,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6345
6437
  case "started": return {
6346
6438
  kind: "running",
6347
6439
  installId: event.installId,
6348
- log: []
6440
+ log: [],
6441
+ phase: phaseOf(event.state)
6349
6442
  };
6350
6443
  case "rejected": return {
6351
6444
  kind: "rejected",
@@ -6360,14 +6453,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6360
6453
  detail: status.detail ?? "install record lost",
6361
6454
  log: status.log
6362
6455
  };
6363
- if (status.state === "running") return {
6456
+ if (!isTerminalInstallState(status.state)) return {
6364
6457
  kind: "running",
6365
6458
  installId: state.installId,
6366
- log: status.log
6459
+ log: status.log,
6460
+ phase: phaseOf(status.state)
6367
6461
  };
6368
6462
  if (status.state === "done") return {
6369
6463
  kind: "done",
6370
- needsRestart: status.needsRestart ?? true,
6464
+ activation: status.activation ?? "restart",
6371
6465
  log: status.log,
6372
6466
  ...status.restartReason !== void 0 ? { restartReason: status.restartReason } : {}
6373
6467
  };
@@ -6596,9 +6690,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6596
6690
  install: "安装",
6597
6691
  confirm: "确认",
6598
6692
  cancel: "取消",
6693
+ dismiss: "知道了",
6599
6694
  acknowledgementTitle: "需要确认",
6600
6695
  acknowledgementBody: ACKNOWLEDGEMENT_ZH,
6601
6696
  installing: "正在安装…",
6697
+ downloading: "正在下载…",
6602
6698
  installedRestartNotice: "已安装;重启 dsh 后生效",
6603
6699
  incompatibleBadge: "不兼容",
6604
6700
  incompatibleDetail: "缺少组件/插件:{modules}\n此插件可能是为其他版本的 DSH 设计的。",
@@ -6607,7 +6703,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6607
6703
  hotHostUnsupportedNotice: "当前环境不支持热挂载;重启 dsh 后生效",
6608
6704
  hotTimeoutNotice: "热挂载超时;重启 dsh 后生效",
6609
6705
  hotMountFailedNotice: "热挂载失败;重启 dsh 后生效",
6706
+ hotClientHalfNotice: "已安装并在运行;它的界面要重启 dsh 才会更新——刷新页面取不到",
6610
6707
  installedNoRestartNotice: "已安装并热挂载;现在即可使用,无需重启",
6708
+ installedReloadNotice: "已生效;刷新页面即可看到",
6709
+ reload: "刷新页面",
6710
+ reloadNote: "服务器已经是新状态,当前页面显示的还是刷新前的内容。刷新会丢弃这个页面上未完成的内容。",
6611
6711
  installFailed: "安装失败",
6612
6712
  installTransportFailed: "安装请求未能送达。请稍后重试。",
6613
6713
  refresh: "刷新",
@@ -6654,6 +6754,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6654
6754
  uninstallTransportFailed: "卸载请求未能送达。请稍后重试。",
6655
6755
  uninstalledLiveNotice: "已卸载并立即停止。profile 的启动组合将在下次重启时应用该移除。",
6656
6756
  uninstalledRestartNotice: "已卸载;重启 dsh 后生效",
6757
+ uninstalledReloadNotice: "已卸载;刷新页面即可看到它消失。profile 的启动组合将在下次重启时应用该移除。",
6657
6758
  restart: "重启 dsh",
6658
6759
  restartTitle: "确认重启 dsh",
6659
6760
  restartBody: "重启会断开当前页面与服务器的连接,正在进行的对话和任务会中断;浏览器会自动跳转到重启后的地址,稍等片刻即可继续使用。",
@@ -6661,7 +6762,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6661
6762
  restarting: "正在重启,服务器就绪后页面会自动刷新…",
6662
6763
  restartFailedNotice: "重启后服务器没有回来。请手动启动:dsh web(新进程的日志在商店缓存目录的 restart.log)",
6663
6764
  restartTransportFailed: "重启请求未能送达。请稍后重试。",
6664
- restartDisabledNotice: "当前 dsh systemd 服务托管,商店无法安全地重启它。请手动重启服务,或在商店行配置中设置 allowRestart: true。",
6765
+ restartBlockedWindowsNotice: "商店暂不支持在 Windows 上重启 dsh。请退出 dsh 后重新启动,以应用更改。",
6766
+ restartBlockedSystemdNotice: "当前 dsh 由 systemd 服务托管,商店无法安全地重启它。请手动重启服务,或在商店行配置中设置 allowRestart: true。",
6767
+ restartBlockedPortZeroNotice: "此 dsh 以 --port 0 启动,重启后会绑定到另一个端口,本页面无法跟上。请手动重启 dsh,以应用更改。",
6665
6768
  updateFailed: "更新失败",
6666
6769
  updateTransportFailed: "更新请求未能送达。请稍后重试。",
6667
6770
  checkUpdate: "检查更新",
@@ -6670,7 +6773,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6670
6773
  github: "GitHub",
6671
6774
  enabledSwitch: "启用",
6672
6775
  toggleFailed: "设置失败,请重试。",
6673
- hotApplyNote: "无需重启,立即生效"
6776
+ hotApplyNote: "已生效,无需重启"
6674
6777
  };
6675
6778
  /** English dictionary checked against the Chinese key set. */
6676
6779
  const en = {
@@ -6699,9 +6802,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6699
6802
  install: "Install",
6700
6803
  confirm: "Confirm",
6701
6804
  cancel: "Cancel",
6805
+ dismiss: "Dismiss",
6702
6806
  acknowledgementTitle: "Acknowledgement required",
6703
6807
  acknowledgementBody: ACKNOWLEDGEMENT_EN,
6704
6808
  installing: "Installing…",
6809
+ downloading: "Downloading…",
6705
6810
  installedRestartNotice: "installed; restart dsh to activate",
6706
6811
  incompatibleBadge: "Incompatible",
6707
6812
  incompatibleDetail: "Missing components/plugins: {modules}\nThis plugin may be designed for another DSH version.",
@@ -6710,7 +6815,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6710
6815
  hotHostUnsupportedNotice: "this harness cannot hot-mount; restart dsh to activate",
6711
6816
  hotTimeoutNotice: "hot-mount timed out; restart dsh to activate",
6712
6817
  hotMountFailedNotice: "hot-mount failed; restart dsh to activate",
6818
+ hotClientHalfNotice: "installed and running; its interface needs a restart of dsh — a reload cannot fetch it",
6713
6819
  installedNoRestartNotice: "installed and hot-mounted; running now, no restart needed",
6820
+ installedReloadNotice: "already applied; reload the page to see it",
6821
+ reload: "Reload the page",
6822
+ reloadNote: "The server is already in the new state; this page is still showing what came before. Reloading discards anything in flight on this page.",
6714
6823
  installFailed: "Install failed",
6715
6824
  installTransportFailed: "The install request could not be delivered. Please retry.",
6716
6825
  refresh: "Refresh",
@@ -6752,6 +6861,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6752
6861
  uninstallTransportFailed: "The uninstall request could not be delivered. Please retry.",
6753
6862
  uninstalledLiveNotice: "Removed and stopped immediately. The profile's boot composition picks up the removal at the next restart.",
6754
6863
  uninstalledRestartNotice: "uninstalled; restart dsh to activate",
6864
+ uninstalledReloadNotice: "removed; reload the page to see it gone. The profile's boot composition picks up the removal at the next restart.",
6755
6865
  restart: "Restart dsh",
6756
6866
  restartTitle: "Restart dsh?",
6757
6867
  restartBody: "Restarting disconnects this page and interrupts any conversation or task in progress; the browser will jump to the restarted address automatically, so give it a few seconds.",
@@ -6759,7 +6869,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6759
6869
  restarting: "Restarting; the page will refresh once the server is back…",
6760
6870
  restartFailedNotice: "The server did not come back after the restart. Start it manually: dsh web (see restart.log in the shop cache directory)",
6761
6871
  restartTransportFailed: "The restart request could not be delivered. Please retry.",
6762
- restartDisabledNotice: "This dsh process runs as a systemd service, so the shop cannot restart it safely. Restart the service manually, or set allowRestart: true in the shop row config.",
6872
+ restartBlockedWindowsNotice: "The shop cannot restart dsh on Windows yet. Quit dsh and start it again to apply the change.",
6873
+ restartBlockedSystemdNotice: "This dsh process runs as a systemd service, so the shop cannot restart it safely. Restart the service manually, or set allowRestart: true in the shop row config.",
6874
+ restartBlockedPortZeroNotice: "This dsh was launched with --port 0, so a restart would bind a different port and this page could not follow it. Restart dsh manually to apply the change.",
6763
6875
  updateFailed: "Update failed",
6764
6876
  updateTransportFailed: "The update request could not be delivered. Please retry.",
6765
6877
  checkUpdate: "Check",
@@ -6768,40 +6880,45 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6768
6880
  github: "GitHub",
6769
6881
  enabledSwitch: "Enable",
6770
6882
  toggleFailed: "Failed to update. Please retry.",
6771
- hotApplyNote: "takes effect without a restart"
6883
+ hotApplyNote: "applied without a restart"
6772
6884
  };
6773
6885
  //#endregion
6774
- //#region src/client/useInstall.ts
6775
- /** Install driving hook for one entry: start, poll to terminal, reset. */
6776
- /** The poll loop shared by the install and uninstall drivers: while the view
6777
- * is `running`, poll once per second and fold each status through the
6778
- * reducer. A poll failure is transient — the host retains the record, so the
6779
- * next tick finds it. The rejection handler must be present: an unhandled
6780
- * rejection here would escape the poll loop. */
6781
- function usePollStatus(view, setView, installStatus) {
6782
- (0, react.useEffect)(() => {
6783
- if (view.kind !== "running") return;
6784
- const timer = setInterval(() => {
6785
- installStatus({ installId: view.installId }).then((status) => {
6786
- setView((current) => reduceInstall(current, {
6787
- type: "status",
6788
- status
6789
- }));
6790
- }, () => {});
6791
- }, INSTALL_POLL_MS);
6792
- return () => clearInterval(timer);
6793
- }, [
6794
- view,
6795
- setView,
6796
- installStatus
6797
- ]);
6798
- }
6886
+ //#region src/client/useFlows.ts
6799
6887
  /**
6800
- * Install flows owned by the tab and keyed by install identity. Keeping the
6801
- * state above individual cards preserves a running operation when filtering
6802
- * unmounts its card, and makes the shelf and Outdated panels agree.
6888
+ * One keyed registry of install-shaped flows: start, poll to terminal, reset.
6889
+ *
6890
+ * Install and uninstall are the same machine. Both fold a
6891
+ * `ShopInstallStatusResult` through `reduceInstall` into an `InstallView`,
6892
+ * both poll every running identity from one interval, and both must keep a
6893
+ * settled outcome after the installed projection that follows it drops the
6894
+ * row the panel was mounted on (I-1, §7.2) — which is why the state is keyed
6895
+ * by identity here rather than held by the card.
6896
+ *
6897
+ * They differ in exactly one place: what the STARTING RPC's answer means. An
6898
+ * install refusal is a `rejected` view carrying the gate's code; an uninstall
6899
+ * refusal is a `failed` view carrying the host's published detail, because
6900
+ * `ShopUninstallResult`'s failure variant has no code to populate and those
6901
+ * codes belong to the install gate. That difference is the `begin` parameter,
6902
+ * and it is the whole of it.
6903
+ *
6904
+ * The two used to be hand-synced copies — 71 of 84 lines identical. The cost
6905
+ * was not theoretical: a single review round had to make the same edit twice
6906
+ * (the settle signature, and its call), and the `pending` set below was added
6907
+ * to one of them and not the other before this module existed.
6803
6908
  */
6804
- function useInstallFlows(install, installStatus, onSettled) {
6909
+ /** Shared empty set, so an idle registry hands out one stable identity. */
6910
+ const NO_PENDING = /* @__PURE__ */ new Set();
6911
+ /**
6912
+ * @param begin the starting RPC and its answer, mapped to the first view.
6913
+ * Never rejects: a transport throw is the caller's to fold into a view,
6914
+ * because only the caller knows which localized line stands in for a detail
6915
+ * too private to render.
6916
+ * @param onSettled called once per identity that reaches a terminal state,
6917
+ * with WHICH terminal state. The outcome is load-bearing: a failed flow
6918
+ * changed nothing on the server, so a caller acting on "settled" alone
6919
+ * discards outcomes that are still true.
6920
+ */
6921
+ function useKeyedFlows(begin, installStatus, onSettled) {
6805
6922
  const [views, setViews] = (0, react.useState)(() => /* @__PURE__ */ new Map());
6806
6923
  const settled = (0, react.useRef)(onSettled);
6807
6924
  settled.current = onSettled;
@@ -6824,29 +6941,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6824
6941
  }, []);
6825
6942
  const start = (0, react.useCallback)(async (key, args) => {
6826
6943
  put(key, { kind: "idle" });
6827
- try {
6828
- const result = await install(args);
6829
- if (!result.ok) {
6830
- put(key, {
6831
- kind: "rejected",
6832
- code: result.code,
6833
- detail: result.detail
6834
- });
6835
- return;
6836
- }
6837
- put(key, {
6838
- kind: "running",
6839
- installId: result.installId,
6840
- log: []
6841
- });
6842
- } catch {
6843
- put(key, {
6844
- kind: "failed",
6845
- detail: "",
6846
- log: []
6847
- });
6848
- }
6849
- }, [install, put]);
6944
+ put(key, await begin(args));
6945
+ }, [begin, put]);
6850
6946
  const reset = (0, react.useCallback)((key) => {
6851
6947
  setViews((current) => {
6852
6948
  if (!current.has(key)) return current;
@@ -6865,7 +6961,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6865
6961
  type: "status",
6866
6962
  status
6867
6963
  });
6868
- if (status.found && status.state !== "running") settled.current?.(key);
6964
+ if (status.found && isTerminalInstallState(status.state)) settled.current?.(key, status.state);
6869
6965
  }, () => {});
6870
6966
  }, INSTALL_POLL_MS);
6871
6967
  return () => clearInterval(timer);
@@ -6874,58 +6970,130 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6874
6970
  installStatus,
6875
6971
  apply
6876
6972
  ]);
6877
- return { flowFor: (0, react.useCallback)((key) => ({
6878
- view: views.get(key) ?? { kind: "idle" },
6879
- start: (args) => start(key, args),
6880
- reset: () => reset(key)
6881
- }), [
6882
- views,
6883
- start,
6884
- reset
6885
- ]) };
6973
+ const pendingRef = (0, react.useRef)(NO_PENDING);
6974
+ const pending = (0, react.useMemo)(() => {
6975
+ const next = /* @__PURE__ */ new Set();
6976
+ for (const [key, view] of views) if (view.kind !== "idle") next.add(key);
6977
+ const previous = pendingRef.current;
6978
+ if (previous.size === next.size && [...next].every((key) => previous.has(key))) return previous;
6979
+ pendingRef.current = next;
6980
+ return next;
6981
+ }, [views]);
6982
+ return {
6983
+ flowFor: (0, react.useCallback)((key) => ({
6984
+ view: views.get(key) ?? { kind: "idle" },
6985
+ start: (args) => start(key, args),
6986
+ reset: () => reset(key)
6987
+ }), [
6988
+ views,
6989
+ start,
6990
+ reset
6991
+ ]),
6992
+ resetFlow: reset,
6993
+ pending
6994
+ };
6995
+ }
6996
+ //#endregion
6997
+ //#region src/client/useInstall.ts
6998
+ /** Install driving hooks: the shared poll loop, and the tab's keyed registry. */
6999
+ /** The single-view poll loop: while the view is `running`, poll once per
7000
+ * second and fold each status through the reducer. A poll failure is
7001
+ * transient — the host retains the record, so the next tick finds it. The
7002
+ * rejection handler must be present: an unhandled rejection here would
7003
+ * escape the poll loop.
7004
+ *
7005
+ * Kept for the self-update (`useUpdateSelf`), which drives ONE flow with no
7006
+ * identity to key it by. Everything per-plugin goes through `useKeyedFlows`,
7007
+ * whose own loop polls every running identity from one interval. */
7008
+ function usePollStatus(view, setView, installStatus) {
7009
+ (0, react.useEffect)(() => {
7010
+ if (view.kind !== "running") return;
7011
+ const timer = setInterval(() => {
7012
+ installStatus({ installId: view.installId }).then((status) => {
7013
+ setView((current) => reduceInstall(current, {
7014
+ type: "status",
7015
+ status
7016
+ }));
7017
+ }, () => {});
7018
+ }, INSTALL_POLL_MS);
7019
+ return () => clearInterval(timer);
7020
+ }, [
7021
+ view,
7022
+ setView,
7023
+ installStatus
7024
+ ]);
7025
+ }
7026
+ /**
7027
+ * Install flows owned by the tab and keyed by install identity. Keeping the
7028
+ * state above individual cards preserves a running operation when filtering
7029
+ * unmounts its card, and makes the shelf and Outdated panels agree.
7030
+ *
7031
+ * The registry itself is `useKeyedFlows`; this supplies the one thing that is
7032
+ * install-specific — what the starting RPC's answer means.
7033
+ */
7034
+ function useInstallFlows(install, installStatus, onSettled) {
7035
+ return useKeyedFlows((0, react.useCallback)(async (args) => {
7036
+ try {
7037
+ const result = await install(args);
7038
+ if (!result.ok) return {
7039
+ kind: "rejected",
7040
+ code: result.code,
7041
+ detail: result.detail
7042
+ };
7043
+ return {
7044
+ kind: "running",
7045
+ installId: result.installId,
7046
+ log: [],
7047
+ phase: phaseOf(result.state)
7048
+ };
7049
+ } catch {
7050
+ return {
7051
+ kind: "failed",
7052
+ detail: "",
7053
+ log: []
7054
+ };
7055
+ }
7056
+ }, [install]), installStatus, onSettled);
6886
7057
  }
6887
7058
  //#endregion
6888
7059
  //#region src/client/useUninstall.ts
6889
- /** Uninstall driving hook for one entry: start, poll to terminal. Shares the
6890
- * install view machine and poll loop; the only difference is the start
6891
- * mapping an uninstall business failure (not in the catalog / not
6892
- * installed) lands in the `failed` view with the host's published detail,
6893
- * never in the install `rejected` codes, which belong to the install gate
6894
- * (§7.2). */
6895
- /** Drive one uninstall: the host's business union, the shared polling loop,
6896
- * and terminal states. The `rejected` install state never occurs here. */
6897
- function useUninstall(uninstall, installStatus) {
6898
- const [view, setView] = (0, react.useState)({ kind: "idle" });
6899
- const start = (0, react.useCallback)(async (args) => {
6900
- setView({ kind: "idle" });
7060
+ /** Uninstall driving hook: the tab's keyed registry, one start mapping. */
7061
+ /**
7062
+ * Uninstall flows owned by the tab and keyed by install identity. Lifting the
7063
+ * state above individual cards is what lets a completed uninstall's outcome
7064
+ * survive the installed-projection refresh that follows it the row backing
7065
+ * the card disappears, but the flow keyed by identity does not.
7066
+ *
7067
+ * The registry itself is `useKeyedFlows`; this supplies the one thing that is
7068
+ * uninstall-specific what the starting RPC's answer means. An uninstall
7069
+ * business failure (not in the catalog / not installed) lands in the `failed`
7070
+ * view with the host's published detail, never in the install `rejected`
7071
+ * codes: those belong to the install gate, and `ShopUninstallResult`'s
7072
+ * failure variant has no `code` field to populate.
7073
+ */
7074
+ function useUninstallFlows(uninstall, installStatus, onSettled) {
7075
+ return useKeyedFlows((0, react.useCallback)(async (args) => {
6901
7076
  try {
6902
7077
  const result = await uninstall(args);
6903
- if (!result.ok) {
6904
- setView({
6905
- kind: "failed",
6906
- detail: result.detail,
6907
- log: []
6908
- });
6909
- return;
6910
- }
6911
- setView({
7078
+ if (!result.ok) return {
7079
+ kind: "failed",
7080
+ detail: result.detail,
7081
+ log: []
7082
+ };
7083
+ return {
6912
7084
  kind: "running",
6913
7085
  installId: result.installId,
6914
- log: []
6915
- });
7086
+ log: [],
7087
+ phase: "installing"
7088
+ };
6916
7089
  } catch {
6917
- setView({
7090
+ return {
6918
7091
  kind: "failed",
6919
7092
  detail: "",
6920
7093
  log: []
6921
- });
7094
+ };
6922
7095
  }
6923
- }, [uninstall]);
6924
- usePollStatus(view, setView, installStatus);
6925
- return {
6926
- view,
6927
- start
6928
- };
7096
+ }, [uninstall]), installStatus, onSettled);
6929
7097
  }
6930
7098
  //#endregion
6931
7099
  //#region src/client/useUpdateSelf.ts
@@ -6953,7 +7121,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6953
7121
  setView({
6954
7122
  kind: "running",
6955
7123
  installId: result.installId,
6956
- log: []
7124
+ log: [],
7125
+ phase: phaseOf(result.state)
6957
7126
  });
6958
7127
  } catch {
6959
7128
  setView({
@@ -6971,7 +7140,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
6971
7140
  }
6972
7141
  //#endregion
6973
7142
  //#region \0dsh-plugin-shop-css:e3675a89
6974
- const css = "/* Shop tab styles — a market shelf inside the settings surface. The visual\n * language is the dsh theme's alias tokens ONLY (the four tokens the previous\n * styles referenced that do not exist — bg-layer-3, interactive-bg-hover,\n * state-error, state-success — are corrected to bg-layer-2 and the\n * state-*-primary set). The signature is the category spine: one HUE per\n * category (seven fixed hues, not the brand token — the brand resolves to\n * near-black in the light theme, so seven brand opacities read as seven\n * shades of gray), with the category name as the cover label in the same hue. Class names are mapped to content-derived names at bundle time\n * (tsdown.s8583e3_client.s942a79_config.s5f67aa_ts) and stubbed in tests; only the keys are relied\n * on.\n *\n * One token rule the theme forces: the background LAYERS do not carry\n * contrast. In the light theme bg-base, bg-layer-1, bg-layer-2 and\n * bg-layer-3 all resolve to the same white; only the dark theme spreads them\n * (950/875/850/800). So a layer-2 fill on a layer-1 ground is invisible for\n * half of all users, and anything whose fill is its ONLY affordance — the\n * loading skeleton, the borderless capability chips — derives its fill from\n * label-primary instead, which inverts with the theme by construction.\n * Elements that also carry a border or text may keep a layer fill.\n * `css-tokens.s8583e3_client.sfd9d8a_spec.s5f67aa_ts` pins this. */\n\n.s255e5a_panel {\n display: flex;\n flex-direction: column;\n gap: 14px;\n /* The shelf never scrolls sideways: a card too wide for its track wraps\n * its badges rather than widening the panel.\n *\n * Clipped on BOTH axes, with a margin, because `.s232ec4_searchInput` sits at\n * exactly (0, 0) of this box — it is the first child of `.s646509_toolbar`, itself\n * the first child here, and this rule carries no padding — so its focus\n * ring paints ENTIRELY outside the panel.\n *\n * Both axes is the load-bearing part: `overflow-clip-margin` is inert\n * unless the element clips on both. Clipping x alone left the margin doing\n * nothing — measured against the live app on 0.7.1, the ring reached 0 of\n * its 3px on the left at every device pixel ratio (1, 1.25, 1.5, 2), while\n * keeping the full 3px on the right, where the 280px input stops short of\n * the panel and never meets the clip edge. Raising the margin to 6px\n * changed nothing; clipping both axes with the SAME 3px restored the whole\n * ring. A test that reads declarations cannot see that difference, which is\n * why the first attempt at this shipped broken.\n *\n * `clip` is not a scroll container, so this does not bring back the\n * accidental scrolling `hidden` caused by coercing the other axis to\n * `auto`. Nothing is lost vertically: the panel's scrollHeight equals its\n * height and no descendant paints outside the clip rect (measured).\n * `overflow-x: hidden` stays as the fallback for engines without `clip`,\n * where the ring is clipped as it was before, never worse. */\n overflow-x: hidden;\n overflow: clip;\n overflow-clip-margin: 3px;\n}\n\n.s44a3ec_stateLine {\n margin: 0;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n/* Visually hidden but screen-reader readable (the loading copy while the\n * skeleton stands in visually). */\n.s3a0ece_srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n border: 0;\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n overflow: hidden;\n white-space: nowrap;\n}\n\n/* The loading shelf: ghost cards with the same geometry as the real grid, so\n * the swap to content is a same-shape handoff instead of a jump. */\n.se0c3b4_skeletonGrid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 8px;\n}\n\n.s520416_skeletonCard {\n position: relative;\n display: flex;\n flex-direction: column;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n overflow: hidden;\n}\n\n/* The shimmer sweep: one gradient band travels across each ghost card while\n * the bars breathe, so the loading state reads as \"the shelf is scanning\". */\n.s520416_skeletonCard::after {\n content: '';\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 100deg,\n transparent 25%,\n color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent) 50%,\n transparent 75%\n );\n transform: translateX(-100%);\n animation: dshShopSkeletonSweep 1.4s ease-in-out infinite;\n pointer-events: none;\n}\n\n@keyframes dshShopSkeletonSweep {\n to { transform: translateX(100%); }\n}\n\n.s8c7d6f_skeletonName {\n width: 55%;\n height: 14px;\n margin: 10px 12px 0;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.sf5179e_skeletonSummary {\n width: 92%;\n height: 12px;\n margin: 10px 12px 0;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.sbb0d25_skeletonSummaryShort {\n width: 68%;\n height: 12px;\n margin: 6px 12px 10px;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n/* The ghost of the action line, right where the real buttons land. */\n.s96e910_skeletonActions {\n display: block;\n width: 84px;\n height: 24px;\n margin: 0 12px 10px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.s520416_skeletonCard span {\n animation: dshShopSkeletonPulse 1.6s ease-in-out infinite;\n}\n\n@keyframes dshShopSkeletonPulse {\n 0%, 100% { opacity: 0.5; }\n 50% { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s520416_skeletonCard span { animation: none; opacity: 0.75; }\n .s520416_skeletonCard::after { content: none; }\n}\n\n.s73242a_actionButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s73242a_actionButton:hover {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l2));\n color: var(--dsw-alias-brand-primary);\n}\n\n.s73242a_actionButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s646509_toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n}\n\n.s745e3c_toolbarRight {\n display: flex;\n align-items: center;\n gap: 12px;\n flex-wrap: wrap;\n}\n\n/* The shop's own version, right of the search box: `v0.4.4`, plus the\n * update button when the version check found a newer release. */\n.s4fe61f_versionBlock {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.sb9b924_versionText {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa04455_updateSelfButton {\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-brand-primary);\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n color: var(--dsw-alias-brand-primary);\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.sa04455_updateSelfButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 20%, transparent);\n}\n\n.sa04455_updateSelfButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The on-demand re-check next to the version number: a quiet pill; the\n * border and text carry it in the light theme where bg layers are white. */\n.s614a38_checkUpdateButton {\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n.s614a38_checkUpdateButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n color: var(--dsw-alias-label-primary);\n}\n\n.s614a38_checkUpdateButton:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.s614a38_checkUpdateButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The project's GitHub mark, right of the version row: a quiet icon link.\n * The octocat carries itself — color shift on hover, no text, no border. */\n.sa987f7_githubLink {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 3px;\n border-radius: 6px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa987f7_githubLink:hover {\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.sa987f7_githubLink:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The self-update's expanded states (running log / done + restart /\n * failure detail) sit under the toolbar, above the category bar. */\n.sb81ba5_selfUpdatePanel {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 8px;\n}\n\n.s232ec4_searchInput {\n width: min(280px, 100%);\n padding: 6px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n.s232ec4_searchInput:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s3946f1_staleBadge {\n padding: 2px 8px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);\n color: var(--dsw-alias-state-warn-primary);\n font-size: 12px;\n}\n\n.sb2c859_categoryBar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n margin-top: 6px;\n}\n\n.s26d58e_categoryButton {\n /* The hue this button carries when selected. `All` and `Installed` select by\n * something other than a category and keep the brand token; the six category\n * buttons override it from the shared hue table below, so a pressed Tool tab\n * turns the same blue as a tool card's spine. */\n --category-hue: var(--dsw-alias-brand-primary);\n padding: 3px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n/* Blend text with the theme foreground: darker on light backgrounds, lighter\n * on dark ones. The unblended category hue belongs on the border and fill. */\n.s26d58e_categoryButton:hover {\n border-color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-border-l2));\n color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-label-primary) 55%);\n}\n\n/* Selected. Written `.s26d58e_categoryButton.s5f68c4_categoryButtonOn` — TWO classes — so it\n * matches `.s26d58e_categoryButton:hover`'s specificity and, coming after it, wins.\n * As a bare `.s5f68c4_categoryButtonOn` it lost both `border-color` and `color` to the\n * hover rule for as long as the pointer stayed on the tab that had just been\n * clicked, which is precisely when a reader looks for confirmation.\n *\n * EVERY declaration here is a colour or a shadow: a pressed tab must occupy\n * exactly the same box as an unpressed one, so the bar does not reflow — and\n * with ~9 pills on a wrapping row, one that grows can push the rest onto\n * another line and move the tab out from under the pointer that just clicked\n * it. That is what `font-weight: 600` did (bold metrics are wider than\n * regular), and it is why nothing may be added here that resolves to a length:\n * no padding, no border-width, no font-size, no letter-spacing.\n * `css-tokens.s8583e3_client.sfd9d8a_spec.s5f67aa_ts` fails the build on one.\n *\n * `box-shadow` is the exception that earns its place: it paints outside the\n * layout box, so an inset ring doubles the border's weight at zero cost to the\n * metrics. It is what `font-weight: 600` used to be — a second, thickness-\n * based signal that hover does not also produce — and the `other` tab needs it\n * most: its hue IS the neutral gray this file uses for \"no category\", so on\n * the light theme hue alone barely separates pressed from unpressed. */\n.s26d58e_categoryButton.s5f68c4_categoryButtonOn {\n border-color: var(--category-hue);\n background: color-mix(in srgb, var(--category-hue) 14%, transparent);\n color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-label-primary) 55%);\n box-shadow: inset 0 0 0 1px var(--category-hue);\n}\n\n/* Forced colours replace every colour above with the system palette, which\n * would leave the three colour declarations saying nothing and the pressed tab\n * indistinguishable from its neighbours. `box-shadow` is dropped in this mode\n * too, so the ring has to come back as an outline. */\n@media (forced-colors: active) {\n .s26d58e_categoryButton.s5f68c4_categoryButtonOn {\n outline: 2px solid ButtonText;\n outline-offset: -4px;\n }\n}\n\n.s26d58e_categoryButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The incompatible filter, at the right edge of the category bar: `margin-left:\n * auto` pushes it past the pills, and the bar wraps, so on a narrow panel it\n * drops to its own line rather than squeezing them. It is a MODIFIER, not a\n * ninth category — the categories select what to show and this one subtracts\n * from whatever they selected — so it takes the error hue it filters on rather\n * than joining the hue table.\n *\n * And it is a SWITCH, which is the difference the shape now carries. It used\n * to wear `.s26d58e_categoryButton`: same pill, same row, same size as the eight tabs,\n * differing only in being red. That reads as a ninth tab, and it is not one —\n * the tabs are choose-one and this is on-or-off. So it drops the pill chrome\n * entirely (no border, no fill, nothing to compete with the row it sits\n * beside) and puts the state in a track, the same one every installed card\n * carries. Nothing here restates the switch geometry; `--switch-hue` is the\n * whole of what this control says about it, and it reaches the label too, so\n * one declaration keeps the word and the track the same colour. */\n.sfd887b_incompatibleFilter {\n --switch-hue: var(--dsw-alias-state-error-primary);\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n /* Matches the pills' vertical padding so the bar keeps one line box whether\n * this control wraps onto a row of its own or shares theirs. */\n padding: 3px 0;\n border: 0;\n background: none;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n/* Hover lifts the word to full contrast and warms the track's border. Scoped\n * to the OFF track: `.sfd887b_incompatibleFilter:hover .s11c019_switch` outranks `.s7580e8_switchOn`\n * by two classes, so unscoped it would strip the hue from the border at\n * exactly the moment a reader has the pointer on it to check the state. */\n.sfd887b_incompatibleFilter:hover {\n color: var(--dsw-alias-label-primary);\n}\n\n.sfd887b_incompatibleFilter:hover .s11c019_switch:not(.s7580e8_switchOn) {\n border-color: color-mix(in srgb, var(--switch-hue) 45%, var(--dsw-alias-border-l2));\n}\n\n/* On. Written with both classes for the reason `.s26d58e_categoryButton.s5f68c4_categoryButtonOn`\n * is: it has to match `:hover`'s specificity and come after it, or the colour\n * reverts under a pointer that has not moved since the click. */\n.sfd887b_incompatibleFilter.s6e4cba_incompatibleFilterOn {\n color: color-mix(in srgb, var(--switch-hue) 45%, var(--dsw-alias-label-primary) 55%);\n}\n\n/* Off here means \"nothing is being taken away\" — the resting state — so the\n * knob steps back to the secondary label. The enable switch keeps the full\n * strength dot, where off means \"this plugin is disabled\" and is worth\n * noticing. In this bar it would be the heaviest mark in a row of hairline\n * pills, and on the light theme it is the WHOLE control: bg-layer-2 is the\n * same white as the panel there, so an off track is a hairline outline with a\n * near-black dot sitting in it. */\n.sfd887b_incompatibleFilter .s11c019_switch:not(.s7580e8_switchOn) .saab2b3_switchKnob {\n background: var(--dsw-alias-label-secondary);\n}\n\n/* The ring has no border to trace, so it takes an offset and the pill radius\n * of the tabs it sits beside rather than hugging the text. */\n.sfd887b_incompatibleFilter:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n border-radius: 999px;\n}\n\n.s668774_catalogHeading {\n margin: 2px 0 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-primary);\n}\n\n.seb0a1e_catalogStatsRow {\n display: flex;\n align-items: center;\n gap: 10px;\n margin: 6px 0 0;\n}\n\n.sccc4d1_catalogStats {\n margin: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* Sits beside the 12px stats caption, so it is quieter than `.s73242a_actionButton`:\n * this is an affordance, not a call to action — the catalog reloads on its\n * own at mount and the shelf is already usable without it. */\n.s64a5ff_reloadButton {\n padding: 1px 9px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font-size: 11px;\n line-height: 17px;\n cursor: pointer;\n}\n\n.s64a5ff_reloadButton:hover:not(:disabled) {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l2));\n color: var(--dsw-alias-brand-primary);\n}\n\n.s64a5ff_reloadButton:disabled {\n cursor: default;\n opacity: 0.6;\n}\n\n.s64a5ff_reloadButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s4453f3_reloadFailed {\n font-size: 11px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.s6de1ab_emptyLine {\n margin: 0;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n/* The shelf: a responsive grid of cards. */\n/* Single-line rows: one full-width column, one plugin per line. */\n.sf657f5_cards {\n display: grid;\n grid-template-columns: 1fr;\n max-width: 100%;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n animation: dshShopCardsIn 220ms ease;\n}\n\n@keyframes dshShopCardsIn {\n from { opacity: 0; transform: translateY(3px); }\n to { opacity: 1; transform: none; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sf657f5_cards { animation: none; }\n}\n\n.s65f383_card {\n position: relative;\n display: flex;\n flex-direction: column;\n /* A grid item's min-width is auto by default: its content would widen the\n * 1fr track past the panel and overflow horizontally. */\n min-width: 0;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n overflow: hidden;\n transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;\n /* Off-screen cards skip layout and paint; the placeholder keeps scrollbar\n * estimation sane before the browser has measured the real height. */\n content-visibility: auto;\n contain-intrinsic-size: auto 140px;\n}\n\n/* The sentinel that triggers the next shelf batch: zero visual footprint. */\n.sd7ee01_cardsSentry {\n height: 1px;\n margin: 0;\n padding: 0;\n}\n\n.s705cc6_showingLine {\n margin: 0;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s65f383_card:hover {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l1));\n transform: translateY(-1px);\n box-shadow: 0 2px 8px color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s65f383_card { transition: none; }\n .s65f383_card:hover { transform: none; }\n}\n\n/* The signature: a spine and cover in the category's own hue. The hues are\n * fixed mid-bright colors that read on both themes; `other` deliberately\n * stays a neutral gray so the fallback category does not compete. */\n.s4ce25d_cardSpine {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 3px;\n background: var(--category-hue, #8B8E96);\n}\n\n/* The hue table, keyed by `data-category` and read by BOTH surfaces that\n * speak in category colour: a card's spine and badge, and the category bar's\n * own tab. One table, so a tab can never disagree with the cards it filters to\n * — which is the whole point of colouring the tab. `.s26d58e_categoryButton` sets the\n * brand fallback for `All`/`Installed`; these attribute selectors are one\n * specificity step above it and win wherever the attribute is present. */\n.s65f383_card[data-category='tool'], .s26d58e_categoryButton[data-category='tool'] { --category-hue: #4C8DFF; }\n.s65f383_card[data-category='provider'], .s26d58e_categoryButton[data-category='provider'] { --category-hue: #A78BFA; }\n.s65f383_card[data-category='ui'], .s26d58e_categoryButton[data-category='ui'] { --category-hue: #2DD4BF; }\n.s65f383_card[data-category='workflow'], .s26d58e_categoryButton[data-category='workflow'] { --category-hue: #F59E0B; }\n.s65f383_card[data-category='integration'], .s26d58e_categoryButton[data-category='integration'] { --category-hue: #34D399; }\n.s65f383_card[data-category='theme'], .s26d58e_categoryButton[data-category='theme'] { --category-hue: #F472B6; }\n.s65f383_card[data-category='other'], .s26d58e_categoryButton[data-category='other'] { --category-hue: #8B8E96; }\n\n/* The category sits in the badge row as a quiet chip in the category's own\n * hue — the border carries it where the light theme's white layers cannot\n * (the same lesson as the check button). */\n.s9c8eaa_categoryBadge {\n padding: 1px 7px;\n border: 1px solid color-mix(in srgb, var(--category-hue, #8B8E96) 45%, transparent);\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--category-hue, #8B8E96);\n white-space: nowrap;\n}\n\n.sa97234_entryHeader {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 12px;\n min-width: 0;\n padding: 8px 12px 2px;\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n\n.sa97234_entryHeader:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n\n/* The name fills the row and truncates rather than wraps; the badges stay\n * intact beside it. */\n.sc3f149_name {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 13px;\n font-weight: 600;\n flex: 1 1 auto;\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--dsw-alias-label-primary);\n}\n\n.s513026_badges {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n/* The action line: buttons side by side; an active install/uninstall flow\n * (gate, log, notices) takes the full width below them. */\n.s9b5048_cardActions {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 8px 12px 10px;\n}\n\n.s9b5048_cardActions > .sc4f2ac_gate,\n.s9b5048_cardActions > .s4d374c_installPanel,\n.s9b5048_cardActions > .sf53ca0_installedActions {\n flex-basis: 100%;\n}\n\n/* The entry's version, always visible on the card (the expanded detail\n * repeats it in the version row) — the same quiet mono as the toolbar. */\n.sc6ec6e_cardVersion {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* The octocat marking a github-sourced entry, beside its short commit. */\n.s71f10f_sourceBadge {\n display: inline-flex;\n align-items: center;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s5e82cb_tierBadge {\n padding: 1px 7px;\n border-radius: 999px;\n font-size: 11px;\n line-height: 1.6;\n}\n\n.s5e82cb_tierBadge[data-tier='verified'] {\n color: var(--dsw-alias-state-success-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 40%, transparent);\n}\n\n.s5e82cb_tierBadge[data-tier='verified-stale'] {\n color: var(--dsw-alias-state-warn-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);\n}\n\n.s5e82cb_tierBadge[data-tier='community'] {\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-layer-2);\n border: 1px solid var(--dsw-alias-border-l2);\n}\n\n.s4bbe02_incompatibleBadge {\n flex-shrink: 0;\n padding: 3px 9px;\n border-radius: 999px;\n font-size: 12px;\n white-space: nowrap;\n color: var(--dsw-alias-state-error-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent);\n}\n\n.sff43b4_starsBadge {\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s73f7fb_chevron, .s61393c_chevronOpen {\n color: var(--dsw-alias-label-secondary);\n transition: transform 120ms ease;\n}\n\n.s61393c_chevronOpen { transform: rotate(180deg); }\n\n@media (prefers-reduced-motion: reduce) {\n .s73f7fb_chevron, .s61393c_chevronOpen { transition: none; }\n}\n\n.s820ebf_body {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 0 12px;\n}\n\n.sb8fbc8_summary {\n margin: 0;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n.se507db_summaryZh {\n margin: -4px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-secondary);\n display: -webkit-box;\n -webkit-line-clamp: 1;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n/* Expanded cards show the full summary: the clamp is lifted, never raised —\n * a higher number would still truncate the author's text, just later. */\n.s10baf0_summaryExpanded {\n -webkit-line-clamp: unset;\n display: block;\n}\n\n.sb9dba9_summaryZhExpanded {\n -webkit-line-clamp: unset;\n display: block;\n}\n\n/* `white-space: pre-line` so the newline in the copy is the copy's own: the\n dictionary decides where the line breaks, and a translator changes it\n without touching the component. Collapses runs of spaces like `normal`, so\n only the explicit \\n survives. */\n.s6eca19_incompatibleDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n white-space: pre-line;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.s9d2029_capabilitiesBlock {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.s3bdbd5_capabilitiesNote {\n margin: 0;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s441702_capabilities {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.s441702_capabilities li {\n padding: 1px 6px;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sde3f0c_detail {\n border-top: 1px solid var(--dsw-alias-border-l1);\n padding-top: 8px;\n}\n\n.se98179_detailRows {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin: 0;\n}\n\n.sfb4d2f_detailRow {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n font-size: 12px;\n}\n\n.sfb4d2f_detailRow dt {\n color: var(--dsw-alias-label-secondary);\n}\n\n.sfb4d2f_detailRow dd {\n margin: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n overflow-wrap: anywhere;\n text-align: right;\n color: var(--dsw-alias-label-primary);\n}\n\n.sfb4d2f_detailRow a {\n color: var(--dsw-alias-brand-primary);\n text-decoration: none;\n}\n\n.sfb4d2f_detailRow a:hover {\n text-decoration: underline;\n}\n\n.sfb4d2f_detailRow a:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* What the entry IS rather than what you can do with it — how big it is and\n * who published it — at the right edge of the action row. `margin-left: auto`\n * is what pushes it there, and it lives on this wrapper rather than on either\n * label so the group stays flush right when only one of the two has a value:\n * a github entry has no npm size, and a packument that named no account has no\n * author. The row is a wrapping flex, so an active install flow (which claims\n * the full width) drops the whole group to the next line rather than squeezing\n * it. */\n.s5fbaf9_cardMeta {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n min-width: 0;\n}\n\n/* The unpacked size, left of the author. Mono and tabular so the digits of one\n * figure line up with the digits of another WHEREVER the two land: `.s5fbaf9_cardMeta`\n * is flush right and the author sits outside the size, so a size's x position\n * moves with the author's width and is different again on the many entries\n * that carry no author at all. There is no column to align to, which is why\n * the reason given here is per-figure legibility rather than the column the\n * first version of this comment claimed. Secondary colour, because it\n * qualifies the entry rather than announcing it.\n *\n * `font-variant-numeric` is safe HERE and would not be on a pill: this element\n * has no state rules, so nothing it does can reflow a wrapping row. */\n.s2d3c05_size {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-variant-numeric: tabular-nums;\n font-size: 12px;\n letter-spacing: 0.02em;\n white-space: nowrap;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* The publishing account, at the outer end of `.s5fbaf9_cardMeta` on the action row —\n * NOT beside the npm link in the expanded detail, which is where it used to\n * live and what this comment described until the accurate half was deleted\n * with the `margin-left: auto` that moved to `.s5fbaf9_cardMeta`. There is no middot:\n * `.s5fbaf9_cardMeta`'s 8px gap separates it from the size.\n *\n * Secondary label colour carries the distinction — never a background fill,\n * which is invisible in the light theme where every bg-layer token is the\n * same white. */\n.s1ff66f_author {\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n}\n\n.sd58c6d_reviewedLine {\n margin: 8px 0 0;\n padding: 6px 8px;\n border-radius: 6px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);\n font-size: 12px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n/* Install flow. */\n.s4d374c_installPanel {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 2px;\n}\n\n.se7aaed_installButton, .sb43a49_confirmButton, .s6e45d1_cancelButton {\n align-self: flex-start;\n padding: 5px 14px;\n border-radius: 6px;\n font-size: 13px;\n cursor: pointer;\n}\n\n.se7aaed_installButton {\n border: 1px solid var(--dsw-alias-brand-primary);\n background: var(--dsw-alias-brand-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.se7aaed_installButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));\n}\n\n.sb43a49_confirmButton {\n border: 1px solid var(--dsw-alias-brand-primary);\n background: var(--dsw-alias-brand-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.sb43a49_confirmButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));\n}\n\n.s6e45d1_cancelButton {\n border: 1px solid var(--dsw-alias-border-l2);\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n}\n\n.s6e45d1_cancelButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n}\n\n.se7aaed_installButton:focus-visible, .sb43a49_confirmButton:focus-visible, .s6e45d1_cancelButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The shelf card of a plugin that is installed and current carries this\n * non-interactive label instead of an install button. Border + text do the\n * work in the light theme, where every bg-layer token resolves to white. */\n.sb6fc74_installedLabel {\n align-self: flex-start;\n margin: 0;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n font-size: 13px;\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n/* An installed card's controls: the installed label or the update button,\n * plus the uninstall button. The install panel's running/failed states are\n * column layouts and sit as one item of this row. */\n.sf53ca0_installedActions {\n display: flex;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n/* Uninstall revokes privilege; the quiet outline keeps it from competing\n * with the primary action, and the error tint says \"removes\". */\n.s96967f_uninstallButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 55%, transparent);\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-state-error-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s96967f_uninstallButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\n}\n\n.s96967f_uninstallButton:focus-visible {\n outline: 2px solid var(--dsw-alias-state-error-primary);\n outline-offset: 1px;\n}\n\n/* The restart dsh button on a done install: quiet outline, same rank as\n * the other secondary actions. */\n.sb00c36_restartSelfButton {\n /* The version row's third state. Geometry matches .s614a38_checkUpdateButton and\n * .sa04455_updateSelfButton exactly — the row is one evolving control and a\n * different pill would read as a different kind of thing. It takes the\n * emphasised fill rather than the neutral one because at that moment the\n * restart is the only thing left to do, the same reason Update has it. */\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-brand-primary);\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n color: var(--dsw-alias-brand-primary);\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.sb00c36_restartSelfButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s6ac237_restartButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s6ac237_restartButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n}\n\n.s6ac237_restartButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.sc4f2ac_gate {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 10px;\n border: 1px solid var(--dsw-alias-state-warn-primary);\n border-radius: 8px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent);\n}\n\n.s559c38_gateTitle {\n margin: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.s3c0ace_gateBody {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n.sc323e5_gateWarning {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n white-space: pre-line;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.s4b2373_gateActions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.s52b5a5_installing {\n margin: 0;\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s64ab71_log {\n max-height: 160px;\n overflow: auto;\n margin: 0;\n padding: 8px;\n border-radius: 6px;\n background: var(--dsw-alias-bg-base);\n border: 1px solid var(--dsw-alias-border-l1);\n list-style: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n line-height: 1.5;\n}\n\n.s1531a8_logLine {\n white-space: pre-wrap;\n word-break: break-all;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sf20dda_notice {\n margin: 0;\n font-size: 12px;\n color: var(--dsw-alias-state-success-primary);\n}\n\n.seb9b08_failedHeading {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sfd3021_failedDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n.s50ba35_rejectedCode {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.se75489_rejectedDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n/* Installed section. */\n.sa276cf_outdatedSection {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 6px;\n}\n\n.sceae6e_outdatedList {\n display: flex;\n flex-direction: column;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.s8d2072_outdatedRow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-1);\n}\n\n.s5c2545_outdatedInfo {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n.s5c2545_outdatedInfo strong {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 13px;\n word-break: break-all;\n color: var(--dsw-alias-label-primary);\n}\n\n.s877c68_outdatedVersions {\n /* Two inline labels with nothing between them in the markup ran together as\n * `installed v1.0.0latest v2.0.0`: JSX drops the whitespace-only line, and\n * the per-label class the markup asked for was never defined (singular\n * against this rule's plural). The container owns the gap, the same idiom\n * as .s513026_badges — the labels need no class of their own. */\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa1f556_outdatedActions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n/* The shop's one switch. It is worn by two controls that mean different\n * things — the enable/disable toggle on an installed card, and the shelf's\n * incompatible filter — so the geometry lives here once and each caller states\n * only its hue.\n *\n * `--switch-hue` is that knob, and it is read from the ANCESTOR rather than\n * declared per state: `.sfd887b_incompatibleFilter` sets it once and it reaches the\n * track and the label together, which is what keeps a red switch from sitting\n * under a neutral word. A declaration on `.s11c019_switch` itself would shadow the\n * inherited value (an element's own rule beats what it inherits), so the\n * default rides in the `var()` fallback and is resolved once into\n * `--track-hue` instead of being spelled out at each use.\n *\n * `box-sizing` and `padding` are stated because the two callers are different\n * elements — a `<button>`, which the UA gives padding, and a `<span>`, which\n * it does not. Without them the same class renders two sizes, and the knob\n * sits 2px above centre in the taller one. */\n.s11c019_switch {\n --track-hue: var(--switch-hue, var(--dsw-alias-state-success-primary));\n box-sizing: border-box;\n display: inline-block;\n position: relative;\n width: 32px;\n height: 18px;\n padding: 0;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-2);\n cursor: pointer;\n transition: background 120ms ease, border-color 120ms ease;\n}\n\n/* The knob's POSITION is the state, which is the reason this control needs no\n * `forced-colors` fallback the way the category pills do: a system palette\n * replaces every colour below and moves nothing. */\n.s7580e8_switchOn {\n border-color: var(--track-hue);\n background: color-mix(in srgb, var(--track-hue) 55%, transparent);\n}\n\n.saab2b3_switchKnob {\n position: absolute;\n top: 2px;\n left: 2px;\n width: 12px;\n height: 12px;\n border-radius: 999px;\n background: var(--dsw-alias-label-primary);\n transition: transform 120ms ease, background 120ms ease;\n}\n\n.s7580e8_switchOn .saab2b3_switchKnob {\n transform: translateX(14px);\n background: var(--dsw-alias-bg-base);\n}\n\n.s11c019_switch:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s11c019_switch, .saab2b3_switchKnob { transition: none; }\n}\n\n/* The hot enable/disable switch on installed rows: the knob button plus the\n * §8 hot-apply / failure notices rendered beside it. */\n.sd9f9e4_switchWrap {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n";
7143
+ const css = "/* Shop tab styles — a market shelf inside the settings surface. The visual\n * language is the dsh theme's alias tokens ONLY (the four tokens the previous\n * styles referenced that do not exist — bg-layer-3, interactive-bg-hover,\n * state-error, state-success — are corrected to bg-layer-2 and the\n * state-*-primary set). The signature is the category spine: one HUE per\n * category (seven fixed hues, not the brand token — the brand resolves to\n * near-black in the light theme, so seven brand opacities read as seven\n * shades of gray), with the category name as the cover label in the same hue. Class names are mapped to content-derived names at bundle time\n * (tsdown.s8583e3_client.s942a79_config.s5f67aa_ts) and stubbed in tests; only the keys are relied\n * on.\n *\n * One token rule the theme forces: the background LAYERS do not carry\n * contrast. In the light theme bg-base, bg-layer-1, bg-layer-2 and\n * bg-layer-3 all resolve to the same white; only the dark theme spreads them\n * (950/875/850/800). So a layer-2 fill on a layer-1 ground is invisible for\n * half of all users, and anything whose fill is its ONLY affordance — the\n * loading skeleton, the borderless capability chips — derives its fill from\n * label-primary instead, which inverts with the theme by construction.\n * Elements that also carry a border or text may keep a layer fill.\n * `css-tokens.s8583e3_client.sfd9d8a_spec.s5f67aa_ts` pins this. */\n\n.s255e5a_panel {\n display: flex;\n flex-direction: column;\n gap: 14px;\n /* The shelf never scrolls sideways: a card too wide for its track wraps\n * its badges rather than widening the panel.\n *\n * Clipped on BOTH axes, with a margin, because `.s232ec4_searchInput` sits at\n * exactly (0, 0) of this box — it is the first child of `.s646509_toolbar`, itself\n * the first child here, and this rule carries no padding — so its focus\n * ring paints ENTIRELY outside the panel.\n *\n * Both axes is the load-bearing part: `overflow-clip-margin` is inert\n * unless the element clips on both. Clipping x alone left the margin doing\n * nothing — measured against the live app on 0.7.1, the ring reached 0 of\n * its 3px on the left at every device pixel ratio (1, 1.25, 1.5, 2), while\n * keeping the full 3px on the right, where the 280px input stops short of\n * the panel and never meets the clip edge. Raising the margin to 6px\n * changed nothing; clipping both axes with the SAME 3px restored the whole\n * ring. A test that reads declarations cannot see that difference, which is\n * why the first attempt at this shipped broken.\n *\n * `clip` is not a scroll container, so this does not bring back the\n * accidental scrolling `hidden` caused by coercing the other axis to\n * `auto`. Nothing is lost vertically: the panel's scrollHeight equals its\n * height and no descendant paints outside the clip rect (measured).\n * `overflow-x: hidden` stays as the fallback for engines without `clip`,\n * where the ring is clipped as it was before, never worse. */\n overflow-x: hidden;\n overflow: clip;\n overflow-clip-margin: 3px;\n}\n\n.s44a3ec_stateLine {\n margin: 0;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n/* Visually hidden but screen-reader readable (the loading copy while the\n * skeleton stands in visually). */\n.s3a0ece_srOnly {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n border: 0;\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n overflow: hidden;\n white-space: nowrap;\n}\n\n/* The loading shelf: ghost cards with the same geometry as the real grid, so\n * the swap to content is a same-shape handoff instead of a jump. */\n.se0c3b4_skeletonGrid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 8px;\n}\n\n.s520416_skeletonCard {\n position: relative;\n display: flex;\n flex-direction: column;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n overflow: hidden;\n}\n\n/* The shimmer sweep: one gradient band travels across each ghost card while\n * the bars breathe, so the loading state reads as \"the shelf is scanning\". */\n.s520416_skeletonCard::after {\n content: '';\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 100deg,\n transparent 25%,\n color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent) 50%,\n transparent 75%\n );\n transform: translateX(-100%);\n animation: dshShopSkeletonSweep 1.4s ease-in-out infinite;\n pointer-events: none;\n}\n\n@keyframes dshShopSkeletonSweep {\n to { transform: translateX(100%); }\n}\n\n.s8c7d6f_skeletonName {\n width: 55%;\n height: 14px;\n margin: 10px 12px 0;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.sf5179e_skeletonSummary {\n width: 92%;\n height: 12px;\n margin: 10px 12px 0;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.sbb0d25_skeletonSummaryShort {\n width: 68%;\n height: 12px;\n margin: 6px 12px 10px;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n/* The ghost of the action line, right where the real buttons land. */\n.s96e910_skeletonActions {\n display: block;\n width: 84px;\n height: 24px;\n margin: 0 12px 10px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\n.s520416_skeletonCard span {\n animation: dshShopSkeletonPulse 1.6s ease-in-out infinite;\n}\n\n@keyframes dshShopSkeletonPulse {\n 0%, 100% { opacity: 0.5; }\n 50% { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s520416_skeletonCard span { animation: none; opacity: 0.75; }\n .s520416_skeletonCard::after { content: none; }\n}\n\n.s73242a_actionButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s73242a_actionButton:hover {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l2));\n color: var(--dsw-alias-brand-primary);\n}\n\n.s73242a_actionButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s646509_toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n}\n\n.s745e3c_toolbarRight {\n display: flex;\n align-items: center;\n gap: 12px;\n flex-wrap: wrap;\n}\n\n/* The shop's own version, right of the search box: `v0.4.4`, plus the\n * update button when the version check found a newer release. */\n.s4fe61f_versionBlock {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.sb9b924_versionText {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa04455_updateSelfButton {\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-brand-primary);\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n color: var(--dsw-alias-brand-primary);\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.sa04455_updateSelfButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 20%, transparent);\n}\n\n.sa04455_updateSelfButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The on-demand re-check next to the version number: a quiet pill; the\n * border and text carry it in the light theme where bg layers are white. */\n.s614a38_checkUpdateButton {\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n.s614a38_checkUpdateButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n color: var(--dsw-alias-label-primary);\n}\n\n.s614a38_checkUpdateButton:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.s614a38_checkUpdateButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The project's GitHub mark, right of the version row: a quiet icon link.\n * The octocat carries itself — color shift on hover, no text, no border. */\n.sa987f7_githubLink {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 3px;\n border-radius: 6px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa987f7_githubLink:hover {\n color: var(--dsw-alias-label-primary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.sa987f7_githubLink:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The self-update's expanded states (running log / done + restart /\n * failure detail) sit under the toolbar, above the category bar. */\n.sb81ba5_selfUpdatePanel {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 8px;\n}\n\n.s232ec4_searchInput {\n width: min(280px, 100%);\n padding: 6px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n.s232ec4_searchInput:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s3946f1_staleBadge {\n padding: 2px 8px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);\n color: var(--dsw-alias-state-warn-primary);\n font-size: 12px;\n}\n\n.sb2c859_categoryBar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n margin-top: 6px;\n}\n\n.s26d58e_categoryButton {\n /* The hue this button carries when selected. `All` and `Installed` select by\n * something other than a category and keep the brand token; the six category\n * buttons override it from the shared hue table below, so a pressed Tool tab\n * turns the same blue as a tool card's spine. */\n --category-hue: var(--dsw-alias-brand-primary);\n padding: 3px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n/* Blend text with the theme foreground: darker on light backgrounds, lighter\n * on dark ones. The unblended category hue belongs on the border and fill. */\n.s26d58e_categoryButton:hover {\n border-color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-border-l2));\n color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-label-primary) 55%);\n}\n\n/* Selected. Written `.s26d58e_categoryButton.s5f68c4_categoryButtonOn` — TWO classes — so it\n * matches `.s26d58e_categoryButton:hover`'s specificity and, coming after it, wins.\n * As a bare `.s5f68c4_categoryButtonOn` it lost both `border-color` and `color` to the\n * hover rule for as long as the pointer stayed on the tab that had just been\n * clicked, which is precisely when a reader looks for confirmation.\n *\n * EVERY declaration here is a colour or a shadow: a pressed tab must occupy\n * exactly the same box as an unpressed one, so the bar does not reflow — and\n * with ~9 pills on a wrapping row, one that grows can push the rest onto\n * another line and move the tab out from under the pointer that just clicked\n * it. That is what `font-weight: 600` did (bold metrics are wider than\n * regular), and it is why nothing may be added here that resolves to a length:\n * no padding, no border-width, no font-size, no letter-spacing.\n * `css-tokens.s8583e3_client.sfd9d8a_spec.s5f67aa_ts` fails the build on one.\n *\n * `box-shadow` is the exception that earns its place: it paints outside the\n * layout box, so an inset ring doubles the border's weight at zero cost to the\n * metrics. It is what `font-weight: 600` used to be — a second, thickness-\n * based signal that hover does not also produce — and the `other` tab needs it\n * most: its hue IS the neutral gray this file uses for \"no category\", so on\n * the light theme hue alone barely separates pressed from unpressed. */\n.s26d58e_categoryButton.s5f68c4_categoryButtonOn {\n border-color: var(--category-hue);\n background: color-mix(in srgb, var(--category-hue) 14%, transparent);\n color: color-mix(in srgb, var(--category-hue) 45%, var(--dsw-alias-label-primary) 55%);\n box-shadow: inset 0 0 0 1px var(--category-hue);\n}\n\n/* Forced colours replace every colour above with the system palette, which\n * would leave the three colour declarations saying nothing and the pressed tab\n * indistinguishable from its neighbours. `box-shadow` is dropped in this mode\n * too, so the ring has to come back as an outline. */\n@media (forced-colors: active) {\n .s26d58e_categoryButton.s5f68c4_categoryButtonOn {\n outline: 2px solid ButtonText;\n outline-offset: -4px;\n }\n}\n\n.s26d58e_categoryButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The incompatible filter, at the right edge of the category bar: `margin-left:\n * auto` pushes it past the pills, and the bar wraps, so on a narrow panel it\n * drops to its own line rather than squeezing them. It is a MODIFIER, not a\n * ninth category — the categories select what to show and this one subtracts\n * from whatever they selected — so it takes the error hue it filters on rather\n * than joining the hue table.\n *\n * And it is a SWITCH, which is the difference the shape now carries. It used\n * to wear `.s26d58e_categoryButton`: same pill, same row, same size as the eight tabs,\n * differing only in being red. That reads as a ninth tab, and it is not one —\n * the tabs are choose-one and this is on-or-off. So it drops the pill chrome\n * entirely (no border, no fill, nothing to compete with the row it sits\n * beside) and puts the state in a track, the same one every installed card\n * carries. Nothing here restates the switch geometry; `--switch-hue` is the\n * whole of what this control says about it, and it reaches the label too, so\n * one declaration keeps the word and the track the same colour. */\n.sfd887b_incompatibleFilter {\n --switch-hue: var(--dsw-alias-state-error-primary);\n margin-left: auto;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n /* Matches the pills' vertical padding so the bar keeps one line box whether\n * this control wraps onto a row of its own or shares theirs. */\n padding: 3px 0;\n border: 0;\n background: none;\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n cursor: pointer;\n}\n\n/* Hover lifts the word to full contrast and warms the track's border. Scoped\n * to the OFF track: `.sfd887b_incompatibleFilter:hover .s11c019_switch` outranks `.s7580e8_switchOn`\n * by two classes, so unscoped it would strip the hue from the border at\n * exactly the moment a reader has the pointer on it to check the state. */\n.sfd887b_incompatibleFilter:hover {\n color: var(--dsw-alias-label-primary);\n}\n\n.sfd887b_incompatibleFilter:hover .s11c019_switch:not(.s7580e8_switchOn) {\n border-color: color-mix(in srgb, var(--switch-hue) 45%, var(--dsw-alias-border-l2));\n}\n\n/* On. Written with both classes for the reason `.s26d58e_categoryButton.s5f68c4_categoryButtonOn`\n * is: it has to match `:hover`'s specificity and come after it, or the colour\n * reverts under a pointer that has not moved since the click. */\n.sfd887b_incompatibleFilter.s6e4cba_incompatibleFilterOn {\n color: color-mix(in srgb, var(--switch-hue) 45%, var(--dsw-alias-label-primary) 55%);\n}\n\n/* Off here means \"nothing is being taken away\" — the resting state — so the\n * knob steps back to the secondary label. The enable switch keeps the full\n * strength dot, where off means \"this plugin is disabled\" and is worth\n * noticing. In this bar it would be the heaviest mark in a row of hairline\n * pills, and on the light theme it is the WHOLE control: bg-layer-2 is the\n * same white as the panel there, so an off track is a hairline outline with a\n * near-black dot sitting in it. */\n.sfd887b_incompatibleFilter .s11c019_switch:not(.s7580e8_switchOn) .saab2b3_switchKnob {\n background: var(--dsw-alias-label-secondary);\n}\n\n/* The ring has no border to trace, so it takes an offset and the pill radius\n * of the tabs it sits beside rather than hugging the text. */\n.sfd887b_incompatibleFilter:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 2px;\n border-radius: 999px;\n}\n\n.s668774_catalogHeading {\n margin: 2px 0 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-primary);\n}\n\n.seb0a1e_catalogStatsRow {\n display: flex;\n align-items: center;\n gap: 10px;\n margin: 6px 0 0;\n}\n\n.sccc4d1_catalogStats {\n margin: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* Sits beside the 12px stats caption, so it is quieter than `.s73242a_actionButton`:\n * this is an affordance, not a call to action — the catalog reloads on its\n * own at mount and the shelf is already usable without it. Named\n * `catalogRefresh*` (not `reload*`) so it cannot collide with the §4 page\n * reload offer below, which reloads the whole tab rather than refetching\n * the catalog data. */\n.sff243d_catalogRefreshButton {\n padding: 1px 9px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font-size: 11px;\n line-height: 17px;\n cursor: pointer;\n}\n\n.sff243d_catalogRefreshButton:hover:not(:disabled) {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l2));\n color: var(--dsw-alias-brand-primary);\n}\n\n.sff243d_catalogRefreshButton:disabled {\n cursor: default;\n opacity: 0.6;\n}\n\n.sff243d_catalogRefreshButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n.s194168_catalogRefreshFailed {\n font-size: 11px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.s6de1ab_emptyLine {\n margin: 0;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n/* The shelf: a responsive grid of cards. */\n/* Single-line rows: one full-width column, one plugin per line. */\n.sf657f5_cards {\n display: grid;\n grid-template-columns: 1fr;\n max-width: 100%;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n animation: dshShopCardsIn 220ms ease;\n}\n\n@keyframes dshShopCardsIn {\n from { opacity: 0; transform: translateY(3px); }\n to { opacity: 1; transform: none; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .sf657f5_cards { animation: none; }\n}\n\n.s65f383_card {\n position: relative;\n display: flex;\n flex-direction: column;\n /* A grid item's min-width is auto by default: its content would widen the\n * 1fr track past the panel and overflow horizontally. */\n min-width: 0;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n overflow: hidden;\n transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;\n /* Off-screen cards skip layout and paint; the placeholder keeps scrollbar\n * estimation sane before the browser has measured the real height. */\n content-visibility: auto;\n contain-intrinsic-size: auto 140px;\n}\n\n/* The sentinel that triggers the next shelf batch: zero visual footprint. */\n.sd7ee01_cardsSentry {\n height: 1px;\n margin: 0;\n padding: 0;\n}\n\n.s705cc6_showingLine {\n margin: 0;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s65f383_card:hover {\n border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, var(--dsw-alias-border-l1));\n transform: translateY(-1px);\n box-shadow: 0 2px 8px color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s65f383_card { transition: none; }\n .s65f383_card:hover { transform: none; }\n}\n\n/* The signature: a spine and cover in the category's own hue. The hues are\n * fixed mid-bright colors that read on both themes; `other` deliberately\n * stays a neutral gray so the fallback category does not compete. */\n.s4ce25d_cardSpine {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 3px;\n background: var(--category-hue, #8B8E96);\n}\n\n/* The hue table, keyed by `data-category` and read by BOTH surfaces that\n * speak in category colour: a card's spine and badge, and the category bar's\n * own tab. One table, so a tab can never disagree with the cards it filters to\n * — which is the whole point of colouring the tab. `.s26d58e_categoryButton` sets the\n * brand fallback for `All`/`Installed`; these attribute selectors are one\n * specificity step above it and win wherever the attribute is present. */\n.s65f383_card[data-category='tool'], .s26d58e_categoryButton[data-category='tool'] { --category-hue: #4C8DFF; }\n.s65f383_card[data-category='provider'], .s26d58e_categoryButton[data-category='provider'] { --category-hue: #A78BFA; }\n.s65f383_card[data-category='ui'], .s26d58e_categoryButton[data-category='ui'] { --category-hue: #2DD4BF; }\n.s65f383_card[data-category='workflow'], .s26d58e_categoryButton[data-category='workflow'] { --category-hue: #F59E0B; }\n.s65f383_card[data-category='integration'], .s26d58e_categoryButton[data-category='integration'] { --category-hue: #34D399; }\n.s65f383_card[data-category='theme'], .s26d58e_categoryButton[data-category='theme'] { --category-hue: #F472B6; }\n.s65f383_card[data-category='other'], .s26d58e_categoryButton[data-category='other'] { --category-hue: #8B8E96; }\n\n/* The category sits in the badge row as a quiet chip in the category's own\n * hue — the border carries it where the light theme's white layers cannot\n * (the same lesson as the check button). */\n.s9c8eaa_categoryBadge {\n padding: 1px 7px;\n border: 1px solid color-mix(in srgb, var(--category-hue, #8B8E96) 45%, transparent);\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--category-hue, #8B8E96);\n white-space: nowrap;\n}\n\n.sa97234_entryHeader {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 12px;\n min-width: 0;\n padding: 8px 12px 2px;\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n\n.sa97234_entryHeader:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n\n/* The name fills the row and truncates rather than wraps; the badges stay\n * intact beside it. */\n.sc3f149_name {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 13px;\n font-weight: 600;\n flex: 1 1 auto;\n min-width: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--dsw-alias-label-primary);\n}\n\n.s513026_badges {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n/* The action line: buttons side by side; an active install/uninstall flow\n * (gate, log, notices) takes the full width below them. */\n.s9b5048_cardActions {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 8px 12px 10px;\n}\n\n.s9b5048_cardActions > .sc4f2ac_gate,\n.s9b5048_cardActions > .s4d374c_installPanel,\n.s9b5048_cardActions > .sf53ca0_installedActions {\n flex-basis: 100%;\n}\n\n/* The entry's version, always visible on the card (the expanded detail\n * repeats it in the version row) — the same quiet mono as the toolbar. */\n.sc6ec6e_cardVersion {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* The octocat marking a github-sourced entry, beside its short commit. */\n.s71f10f_sourceBadge {\n display: inline-flex;\n align-items: center;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s5e82cb_tierBadge {\n padding: 1px 7px;\n border-radius: 999px;\n font-size: 11px;\n line-height: 1.6;\n}\n\n.s5e82cb_tierBadge[data-tier='verified'] {\n color: var(--dsw-alias-state-success-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 40%, transparent);\n}\n\n.s5e82cb_tierBadge[data-tier='verified-stale'] {\n color: var(--dsw-alias-state-warn-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);\n}\n\n.s5e82cb_tierBadge[data-tier='community'] {\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-layer-2);\n border: 1px solid var(--dsw-alias-border-l2);\n}\n\n.s4bbe02_incompatibleBadge {\n flex-shrink: 0;\n padding: 3px 9px;\n border-radius: 999px;\n font-size: 12px;\n white-space: nowrap;\n color: var(--dsw-alias-state-error-primary);\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent);\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent);\n}\n\n.sff43b4_starsBadge {\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s73f7fb_chevron, .s61393c_chevronOpen {\n color: var(--dsw-alias-label-secondary);\n transition: transform 120ms ease;\n}\n\n.s61393c_chevronOpen { transform: rotate(180deg); }\n\n@media (prefers-reduced-motion: reduce) {\n .s73f7fb_chevron, .s61393c_chevronOpen { transition: none; }\n}\n\n.s820ebf_body {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 0 12px;\n}\n\n.sb8fbc8_summary {\n margin: 0;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n.se507db_summaryZh {\n margin: -4px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-secondary);\n display: -webkit-box;\n -webkit-line-clamp: 1;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n/* Expanded cards show the full summary: the clamp is lifted, never raised —\n * a higher number would still truncate the author's text, just later. */\n.s10baf0_summaryExpanded {\n -webkit-line-clamp: unset;\n display: block;\n}\n\n.sb9dba9_summaryZhExpanded {\n -webkit-line-clamp: unset;\n display: block;\n}\n\n/* `white-space: pre-line` so the newline in the copy is the copy's own: the\n dictionary decides where the line breaks, and a translator changes it\n without touching the component. Collapses runs of spaces like `normal`, so\n only the explicit \\n survives. */\n.s6eca19_incompatibleDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n white-space: pre-line;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.s9d2029_capabilitiesBlock {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.s3bdbd5_capabilitiesNote {\n margin: 0;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s441702_capabilities {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.s441702_capabilities li {\n padding: 1px 6px;\n border-radius: 4px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sde3f0c_detail {\n border-top: 1px solid var(--dsw-alias-border-l1);\n padding-top: 8px;\n}\n\n.se98179_detailRows {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin: 0;\n}\n\n.sfb4d2f_detailRow {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n font-size: 12px;\n}\n\n.sfb4d2f_detailRow dt {\n color: var(--dsw-alias-label-secondary);\n}\n\n.sfb4d2f_detailRow dd {\n margin: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n overflow-wrap: anywhere;\n text-align: right;\n color: var(--dsw-alias-label-primary);\n}\n\n.sfb4d2f_detailRow a {\n color: var(--dsw-alias-brand-primary);\n text-decoration: none;\n}\n\n.sfb4d2f_detailRow a:hover {\n text-decoration: underline;\n}\n\n.sfb4d2f_detailRow a:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* What the entry IS rather than what you can do with it — how big it is and\n * who published it — at the right edge of the action row. `margin-left: auto`\n * is what pushes it there, and it lives on this wrapper rather than on either\n * label so the group stays flush right when only one of the two has a value:\n * a github entry has no npm size, and a packument that named no account has no\n * author. The row is a wrapping flex, so an active install flow (which claims\n * the full width) drops the whole group to the next line rather than squeezing\n * it. */\n.s5fbaf9_cardMeta {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n min-width: 0;\n}\n\n/* The unpacked size, left of the author. Mono and tabular so the digits of one\n * figure line up with the digits of another WHEREVER the two land: `.s5fbaf9_cardMeta`\n * is flush right and the author sits outside the size, so a size's x position\n * moves with the author's width and is different again on the many entries\n * that carry no author at all. There is no column to align to, which is why\n * the reason given here is per-figure legibility rather than the column the\n * first version of this comment claimed. Secondary colour, because it\n * qualifies the entry rather than announcing it.\n *\n * `font-variant-numeric` is safe HERE and would not be on a pill: this element\n * has no state rules, so nothing it does can reflow a wrapping row. */\n.s2d3c05_size {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-variant-numeric: tabular-nums;\n font-size: 12px;\n letter-spacing: 0.02em;\n white-space: nowrap;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* The publishing account, at the outer end of `.s5fbaf9_cardMeta` on the action row —\n * NOT beside the npm link in the expanded detail, which is where it used to\n * live and what this comment described until the accurate half was deleted\n * with the `margin-left: auto` that moved to `.s5fbaf9_cardMeta`. There is no middot:\n * `.s5fbaf9_cardMeta`'s 8px gap separates it from the size.\n *\n * Secondary label colour carries the distinction — never a background fill,\n * which is invisible in the light theme where every bg-layer token is the\n * same white. */\n.s1ff66f_author {\n color: var(--dsw-alias-label-secondary);\n font-size: 12px;\n}\n\n.sd58c6d_reviewedLine {\n margin: 8px 0 0;\n padding: 6px 8px;\n border-radius: 6px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);\n font-size: 12px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n/* Install flow. */\n.s4d374c_installPanel {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 2px;\n}\n\n.se7aaed_installButton, .sb43a49_confirmButton, .s6e45d1_cancelButton {\n align-self: flex-start;\n padding: 5px 14px;\n border-radius: 6px;\n font-size: 13px;\n cursor: pointer;\n}\n\n.se7aaed_installButton {\n border: 1px solid var(--dsw-alias-brand-primary);\n background: var(--dsw-alias-brand-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.se7aaed_installButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));\n}\n\n.sb43a49_confirmButton {\n border: 1px solid var(--dsw-alias-brand-primary);\n background: var(--dsw-alias-brand-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.sb43a49_confirmButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));\n}\n\n.s6e45d1_cancelButton {\n border: 1px solid var(--dsw-alias-border-l2);\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n}\n\n.s6e45d1_cancelButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n}\n\n.se7aaed_installButton:focus-visible, .sb43a49_confirmButton:focus-visible, .s6e45d1_cancelButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The shelf card of a plugin that is installed and current carries this\n * non-interactive label instead of an install button. Border + text do the\n * work in the light theme, where every bg-layer token resolves to white. */\n.sb6fc74_installedLabel {\n align-self: flex-start;\n margin: 0;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n font-size: 13px;\n color: var(--dsw-alias-label-secondary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n/* An installed card's controls: the installed label or the update button,\n * plus the uninstall button. The install panel's running/failed states are\n * column layouts and sit as one item of this row. */\n.sf53ca0_installedActions {\n display: flex;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n/* Uninstall revokes privilege; the quiet outline keeps it from competing\n * with the primary action, and the error tint says \"removes\". */\n.s96967f_uninstallButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 55%, transparent);\n border-radius: 6px;\n background: transparent;\n color: var(--dsw-alias-state-error-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s96967f_uninstallButton:hover {\n background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);\n}\n\n.s96967f_uninstallButton:focus-visible {\n outline: 2px solid var(--dsw-alias-state-error-primary);\n outline-offset: 1px;\n}\n\n/* The restart dsh button on a done install: quiet outline, same rank as\n * the other secondary actions. */\n.sb00c36_restartSelfButton {\n /* The version row's third state. Geometry matches .s614a38_checkUpdateButton and\n * .sa04455_updateSelfButton exactly — the row is one evolving control and a\n * different pill would read as a different kind of thing. It takes the\n * emphasised fill rather than the neutral one because at that moment the\n * restart is the only thing left to do, the same reason Update has it. */\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-brand-primary);\n border-radius: 999px;\n background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);\n color: var(--dsw-alias-brand-primary);\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.sb00c36_restartSelfButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The two activation offers share one recipe: a border in an alias token,\n * never a `bg-layer` fill alone — all four bg-layer tokens collapse to the\n * same white under `body` in the light theme, and a fill-only button would\n * vanish there. Grouped rather than copied, because a copy is a rule that\n * can be corrected on one button and left wrong on the other, silently. */\n.s6ac237_restartButton,\n.s64a5ff_reloadButton {\n align-self: flex-start;\n padding: 5px 14px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 6px;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n cursor: pointer;\n}\n\n.s6ac237_restartButton:hover,\n.s64a5ff_reloadButton:hover {\n border-color: var(--dsw-alias-label-secondary);\n}\n\n.s6ac237_restartButton:focus-visible,\n.s64a5ff_reloadButton:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n/* The §4 reload offer: a notice plus its button, one item of\n * `.sf53ca0_installedActions` the way the restart gate is — a column of its own\n * rather than inline with the row. */\n.sf79aef_reloadPanel {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 6px;\n}\n\n\n.sc4f2ac_gate {\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 10px;\n border: 1px solid var(--dsw-alias-state-warn-primary);\n border-radius: 8px;\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent);\n}\n\n.s559c38_gateTitle {\n margin: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.s3c0ace_gateBody {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n.sc323e5_gateWarning {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n white-space: pre-line;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.s4b2373_gateActions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.s52b5a5_installing {\n margin: 0;\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.s64ab71_log {\n max-height: 160px;\n overflow: auto;\n margin: 0;\n padding: 8px;\n border-radius: 6px;\n background: var(--dsw-alias-bg-base);\n border: 1px solid var(--dsw-alias-border-l1);\n list-style: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n line-height: 1.5;\n}\n\n.s1531a8_logLine {\n white-space: pre-wrap;\n word-break: break-all;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sf20dda_notice {\n margin: 0;\n font-size: 12px;\n color: var(--dsw-alias-state-success-primary);\n}\n\n.seb9b08_failedHeading {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.sfd3021_failedDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n.s50ba35_rejectedCode {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n color: var(--dsw-alias-state-error-primary);\n}\n\n.se75489_rejectedDetail {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-primary);\n}\n\n/* Installed section. */\n.sa276cf_outdatedSection {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 6px;\n}\n\n.sceae6e_outdatedList {\n display: flex;\n flex-direction: column;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.s8d2072_outdatedRow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n flex-wrap: wrap;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-1);\n}\n\n.s5c2545_outdatedInfo {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n.s5c2545_outdatedInfo strong {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 13px;\n word-break: break-all;\n color: var(--dsw-alias-label-primary);\n}\n\n.s877c68_outdatedVersions {\n /* Two inline labels with nothing between them in the markup ran together as\n * `installed v1.0.0latest v2.0.0`: JSX drops the whitespace-only line, and\n * the per-label class the markup asked for was never defined (singular\n * against this rule's plural). The container owns the gap, the same idiom\n * as .s513026_badges — the labels need no class of their own. */\n display: inline-flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.sa1f556_outdatedActions {\n display: flex;\n align-items: center;\n /* The reload offer (§4) mounts inside the switch beside the Update button\n * and is a two-line block, not a control: without wrapping, a row reading\n * [switch][notice + Reload][Update] overflows the settings dialog. */\n flex-wrap: wrap;\n gap: 10px;\n}\n\n/* The shop's one switch. It is worn by two controls that mean different\n * things — the enable/disable toggle on an installed card, and the shelf's\n * incompatible filter — so the geometry lives here once and each caller states\n * only its hue.\n *\n * `--switch-hue` is that knob, and it is read from the ANCESTOR rather than\n * declared per state: `.sfd887b_incompatibleFilter` sets it once and it reaches the\n * track and the label together, which is what keeps a red switch from sitting\n * under a neutral word. A declaration on `.s11c019_switch` itself would shadow the\n * inherited value (an element's own rule beats what it inherits), so the\n * default rides in the `var()` fallback and is resolved once into\n * `--track-hue` instead of being spelled out at each use.\n *\n * `box-sizing` and `padding` are stated because the two callers are different\n * elements — a `<button>`, which the UA gives padding, and a `<span>`, which\n * it does not. Without them the same class renders two sizes, and the knob\n * sits 2px above centre in the taller one. */\n.s11c019_switch {\n --track-hue: var(--switch-hue, var(--dsw-alias-state-success-primary));\n box-sizing: border-box;\n display: inline-block;\n position: relative;\n width: 32px;\n height: 18px;\n padding: 0;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 999px;\n background: var(--dsw-alias-bg-layer-2);\n cursor: pointer;\n transition: background 120ms ease, border-color 120ms ease;\n}\n\n/* The knob's POSITION is the state, which is the reason this control needs no\n * `forced-colors` fallback the way the category pills do: a system palette\n * replaces every colour below and moves nothing. */\n.s7580e8_switchOn {\n border-color: var(--track-hue);\n background: color-mix(in srgb, var(--track-hue) 55%, transparent);\n}\n\n.saab2b3_switchKnob {\n position: absolute;\n top: 2px;\n left: 2px;\n width: 12px;\n height: 12px;\n border-radius: 999px;\n background: var(--dsw-alias-label-primary);\n transition: transform 120ms ease, background 120ms ease;\n}\n\n.s7580e8_switchOn .saab2b3_switchKnob {\n transform: translateX(14px);\n background: var(--dsw-alias-bg-base);\n}\n\n.s11c019_switch:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: 1px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .s11c019_switch, .saab2b3_switchKnob { transition: none; }\n}\n\n/* The hot enable/disable switch on installed rows: the knob button plus the\n * §8 hot-apply / failure notices rendered beside it. */\n.sd9f9e4_switchWrap {\n display: inline-flex;\n align-items: center;\n /* Same reason as `.sa1f556_outdatedActions`: `.sf79aef_reloadPanel`'s own comment calls\n * itself \"a column of its own rather than inline with the row\", which held\n * for `.sf53ca0_installedActions` and not here until this line. */\n flex-wrap: wrap;\n gap: 8px;\n}\n";
6975
7144
  if (typeof document !== "undefined" && !document.querySelector("style[data-plugin-css=\"e3675a89\"]")) {
6976
7145
  const tag = document.createElement("style");
6977
7146
  tag.dataset.pluginCss = "e3675a89";
@@ -7014,8 +7183,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7014
7183
  "catalogHeading": "s668774_catalogHeading",
7015
7184
  "catalogStatsRow": "seb0a1e_catalogStatsRow",
7016
7185
  "catalogStats": "sccc4d1_catalogStats",
7017
- "reloadButton": "s64a5ff_reloadButton",
7018
- "reloadFailed": "s4453f3_reloadFailed",
7186
+ "catalogRefreshButton": "sff243d_catalogRefreshButton",
7187
+ "catalogRefreshFailed": "s194168_catalogRefreshFailed",
7019
7188
  "emptyLine": "s6de1ab_emptyLine",
7020
7189
  "cards": "sf657f5_cards",
7021
7190
  "card": "s65f383_card",
@@ -7060,6 +7229,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7060
7229
  "uninstallButton": "s96967f_uninstallButton",
7061
7230
  "restartSelfButton": "sb00c36_restartSelfButton",
7062
7231
  "restartButton": "s6ac237_restartButton",
7232
+ "reloadButton": "s64a5ff_reloadButton",
7233
+ "reloadPanel": "sf79aef_reloadPanel",
7063
7234
  "gateTitle": "s559c38_gateTitle",
7064
7235
  "gateBody": "s3c0ace_gateBody",
7065
7236
  "gateWarning": "sc323e5_gateWarning",
@@ -7108,7 +7279,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7108
7279
  * install controls. An installed plugin's card carries its installed row:
7109
7280
  * current → the non-interactive installed label, behind → the update button;
7110
7281
  * uninstalled → the install button. */
7111
- const EntryCard = (0, react.memo)(function EntryCard({ entry, stars, installed, missing, nameTakenBy, t, flowFor, installStatus, uninstall, restart, restartSupported, setEnabled, onSettled }) {
7282
+ const EntryCard = (0, react.memo)(function EntryCard({ entry, stars, installed, missing, nameTakenBy, t, flowFor, uninstallFlowFor, restart, restartBlocked, reload, setEnabled, inInstalledView }) {
7112
7283
  const [open, setOpen] = (0, react.useState)(false);
7113
7284
  const blockers = blockersOf(missing, nameTakenBy, t);
7114
7285
  const detailId = (0, react.useId)();
@@ -7125,10 +7296,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7125
7296
  subdir: entry.subdir
7126
7297
  };
7127
7298
  const flow = flowFor(entryKey(entry));
7128
- const uninstallSettled = (0, react.useCallback)(() => {
7129
- flow.reset();
7130
- onSettled();
7131
- }, [flow, onSettled]);
7299
+ const uninstallFlow = uninstallFlowFor(entryKey(entry));
7132
7300
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7133
7301
  className: _dsh_plugin_shop_css_e3675a89_default.card,
7134
7302
  "data-shop-entry": entry.name,
@@ -7277,7 +7445,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7277
7445
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7278
7446
  className: _dsh_plugin_shop_css_e3675a89_default.cardActions,
7279
7447
  "data-shop-actions": true,
7280
- children: [installed === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstallPanel, {
7448
+ children: [installed === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [!inInstalledView && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstallPanel, {
7281
7449
  target: installTarget,
7282
7450
  tier: entry.tier,
7283
7451
  missing,
@@ -7286,8 +7454,16 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7286
7454
  flow,
7287
7455
  t,
7288
7456
  restart,
7289
- restartSupported
7290
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
7457
+ restartBlocked,
7458
+ reload
7459
+ }), uninstallFlow.view.kind !== "idle" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UninstallPanel, {
7460
+ name: entry.name,
7461
+ t,
7462
+ restart,
7463
+ restartBlocked,
7464
+ reload,
7465
+ flow: uninstallFlow
7466
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
7291
7467
  installed.outdated || flow.view.kind !== "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstallPanel, {
7292
7468
  target: installTarget,
7293
7469
  tier: entry.tier,
@@ -7298,7 +7474,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7298
7474
  flow,
7299
7475
  t,
7300
7476
  restart,
7301
- restartSupported
7477
+ restartBlocked,
7478
+ reload
7302
7479
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7303
7480
  className: _dsh_plugin_shop_css_e3675a89_default.installedLabel,
7304
7481
  "data-shop-installed": true,
@@ -7310,16 +7487,16 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7310
7487
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EnabledSwitch, {
7311
7488
  row: installed,
7312
7489
  t,
7313
- setEnabled
7490
+ setEnabled,
7491
+ reload
7314
7492
  }),
7315
7493
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UninstallPanel, {
7316
7494
  name: entry.name,
7317
7495
  t,
7318
- uninstall,
7319
- installStatus,
7320
7496
  restart,
7321
- restartSupported,
7322
- onSettled: uninstallSettled
7497
+ restartBlocked,
7498
+ reload,
7499
+ flow: uninstallFlow
7323
7500
  })
7324
7501
  ] }), (size !== void 0 || author !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7325
7502
  className: _dsh_plugin_shop_css_e3675a89_default.cardMeta,
@@ -7380,17 +7557,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7380
7557
  }
7381
7558
  /** One entry's install flow: the button, the §9.3 acknowledgement gate for
7382
7559
  * community-tier entries, and the live view — running log, restart notice,
7383
- * failure detail, rejection detail — driven by `useInstall`. Shared by the
7560
+ * failure detail, rejection detail — driven by `useInstallFlows`. Shared by the
7384
7561
  * catalog cards (`variant: 'install'`) and the outdated rows' update button
7385
7562
  * (`variant: 'update'`, which drives the same install flow for `name@latest`). */
7386
- function InstallPanel({ target, tier, missing, blockers, missingStated = false, variant = "install", flow, t, restart, restartSupported }) {
7563
+ function InstallPanel({ target, tier, missing, blockers, missingStated = false, variant = "install", flow, t, restart, restartBlocked, reload }) {
7387
7564
  const [gateOpen, setGateOpen] = (0, react.useState)(false);
7388
7565
  const { view, start } = flow;
7389
7566
  if (view.kind === "running") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7390
7567
  className: _dsh_plugin_shop_css_e3675a89_default.installPanel,
7391
7568
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7392
7569
  className: _dsh_plugin_shop_css_e3675a89_default.installing,
7393
- children: t("installing")
7570
+ children: t(installPhaseKey(view.phase))
7394
7571
  }), view.log.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7395
7572
  className: _dsh_plugin_shop_css_e3675a89_default.log,
7396
7573
  children: view.log.map((line, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -7412,16 +7589,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7412
7589
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7413
7590
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
7414
7591
  "data-shop-restart-notice": true,
7415
- children: view.needsRestart ? t(restartReasonKey(view.restartReason)) : t("installedNoRestartNotice")
7592
+ children: t(activationNoticeKey(view.activation, view.restartReason))
7416
7593
  }),
7417
- view.needsRestart && restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartPanel, {
7594
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActivationOffer, {
7595
+ activation: view.activation,
7418
7596
  t,
7419
- restart
7420
- }),
7421
- view.needsRestart && !restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7422
- className: _dsh_plugin_shop_css_e3675a89_default.notice,
7423
- "data-shop-restart-disabled": true,
7424
- children: t("restartDisabledNotice")
7597
+ restart,
7598
+ restartBlocked,
7599
+ reload,
7600
+ where: "install"
7425
7601
  })
7426
7602
  ]
7427
7603
  });
@@ -7521,13 +7697,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7521
7697
  * §9.3 is about granting. A business failure (not in the catalog / not
7522
7698
  * installed) lands in the failed view with the host's published detail; a
7523
7699
  * transport failure carries the empty detail and the localized fallback. */
7524
- function UninstallPanel({ name, t, uninstall, installStatus, restart, restartSupported, onSettled }) {
7525
- const { view, start } = useUninstall(uninstall, installStatus);
7526
- const settled = (0, react.useRef)(onSettled);
7527
- settled.current = onSettled;
7528
- (0, react.useEffect)(() => {
7529
- if (view.kind === "done") settled.current();
7530
- }, [view.kind]);
7700
+ function UninstallPanel({ name, t, restart, restartBlocked, reload, flow }) {
7701
+ const { view, start, reset } = flow;
7531
7702
  if (view.kind === "running") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7532
7703
  className: _dsh_plugin_shop_css_e3675a89_default.installPanel,
7533
7704
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -7547,16 +7718,22 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7547
7718
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7548
7719
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
7549
7720
  "data-shop-uninstall-done": true,
7550
- children: view.needsRestart ? t("uninstalledRestartNotice") : t("uninstalledLiveNotice")
7721
+ children: t(uninstallActivationNoticeKey(view.activation))
7551
7722
  }),
7552
- view.needsRestart && restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartPanel, {
7723
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActivationOffer, {
7724
+ activation: view.activation,
7553
7725
  t,
7554
- restart
7726
+ restart,
7727
+ restartBlocked,
7728
+ reload,
7729
+ where: "uninstall"
7555
7730
  }),
7556
- view.needsRestart && !restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7557
- className: _dsh_plugin_shop_css_e3675a89_default.notice,
7558
- "data-shop-restart-disabled": true,
7559
- children: t("restartDisabledNotice")
7731
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7732
+ type: "button",
7733
+ className: _dsh_plugin_shop_css_e3675a89_default.cancelButton,
7734
+ "data-shop-uninstall-dismiss": true,
7735
+ onClick: reset,
7736
+ children: t("dismiss")
7560
7737
  })
7561
7738
  ]
7562
7739
  });
@@ -7577,6 +7754,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7577
7754
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7578
7755
  className: _dsh_plugin_shop_css_e3675a89_default.failedDetail,
7579
7756
  children: view.detail === "" ? t("uninstallTransportFailed") : view.detail
7757
+ }),
7758
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7759
+ type: "button",
7760
+ className: _dsh_plugin_shop_css_e3675a89_default.uninstallButton,
7761
+ "data-shop-uninstall-retry": true,
7762
+ onClick: () => void start({ name }),
7763
+ children: t("retry")
7580
7764
  })
7581
7765
  ]
7582
7766
  });
@@ -7692,15 +7876,64 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7692
7876
  });
7693
7877
  }
7694
7878
  /**
7879
+ * The §4/§8 offer under a settled install, update or uninstall: a reload
7880
+ * when the change is already live on the server and only this page is
7881
+ * stale, a restart when the host actually needs one — and can restart at
7882
+ * all (§C-1), otherwise the disabled notice says why — and neither when the
7883
+ * change landed needing neither.
7884
+ *
7885
+ * One component rather than the same three lines in each panel. The
7886
+ * precedence between the three values is a single rule, and it was written
7887
+ * out twice: two places to find when a fourth activation value arrives or
7888
+ * the `restartBlocked` gate changes, with nothing to make the second one
7889
+ * fail if it were missed.
7890
+ */
7891
+ function ActivationOffer({ activation, t, restart, restartBlocked, reload, where }) {
7892
+ if (activation === "reload") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReloadPanel, {
7893
+ t,
7894
+ reload,
7895
+ where
7896
+ });
7897
+ if (activation !== "restart") return null;
7898
+ return restartBlocked === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartPanel, {
7899
+ t,
7900
+ restart
7901
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7902
+ className: _dsh_plugin_shop_css_e3675a89_default.notice,
7903
+ "data-shop-restart-disabled": true,
7904
+ children: t(restartBlockedNoticeKey(restartBlocked))
7905
+ });
7906
+ }
7907
+ /** The §4 reload offer: the server already holds the new state and this tab
7908
+ * does not. Never automatic — a reload discards whatever the reader had in
7909
+ * flight (a conversation, a form, an upload), and the shop knows a reload
7910
+ * would help without knowing that now is a good time. */
7911
+ function ReloadPanel({ t, reload, where }) {
7912
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7913
+ className: _dsh_plugin_shop_css_e3675a89_default.reloadPanel,
7914
+ "data-shop-reload": where,
7915
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7916
+ className: _dsh_plugin_shop_css_e3675a89_default.notice,
7917
+ children: t("reloadNote")
7918
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7919
+ type: "button",
7920
+ className: _dsh_plugin_shop_css_e3675a89_default.reloadButton,
7921
+ onClick: reload,
7922
+ children: t("reload")
7923
+ })]
7924
+ });
7925
+ }
7926
+ /**
7695
7927
  * The hot enable/disable switch (§8). The initial state is the Host's
7696
7928
  * inventory verdict carried on the installed row; the click is optimistic
7697
7929
  * (the value flips on success) and a success renders the §8 hot note. A
7698
7930
  * transport throw renders the localized failure line — its private detail
7699
7931
  * (which can name hosts and ports) never reaches the UI.
7700
7932
  */
7701
- function EnabledSwitch({ row, t, setEnabled }) {
7933
+ function EnabledSwitch({ row, t, setEnabled, reload }) {
7702
7934
  const [enabled, setEnabledState] = (0, react.useState)(row.enabled);
7703
7935
  const [toggle, setToggle] = (0, react.useState)({ kind: "idle" });
7936
+ const [needsReload, setNeedsReload] = (0, react.useState)(false);
7704
7937
  const onToggle = async () => {
7705
7938
  if (toggle.kind === "saving") return;
7706
7939
  const next = !enabled;
@@ -7713,9 +7946,10 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7713
7946
  if (result.ok) {
7714
7947
  setEnabledState(next);
7715
7948
  setToggle({ kind: "saved" });
7949
+ if (result.activation !== "live") setNeedsReload(true);
7716
7950
  } else setToggle({
7717
7951
  kind: "error",
7718
- detail: result.detail ?? t("toggleFailed")
7952
+ detail: result.detail || t("toggleFailed")
7719
7953
  });
7720
7954
  } catch {
7721
7955
  setToggle({
@@ -7739,11 +7973,16 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7739
7973
  disabled: toggle.kind === "saving",
7740
7974
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: _dsh_plugin_shop_css_e3675a89_default.switchKnob })
7741
7975
  }),
7742
- toggle.kind === "saved" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7976
+ toggle.kind === "saved" && !needsReload && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7743
7977
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
7744
7978
  "data-shop-hot-apply": true,
7745
7979
  children: t("hotApplyNote")
7746
7980
  }),
7981
+ needsReload && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReloadPanel, {
7982
+ t,
7983
+ reload,
7984
+ where: "toggle"
7985
+ }),
7747
7986
  toggle.kind === "error" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7748
7987
  className: _dsh_plugin_shop_css_e3675a89_default.failedDetail,
7749
7988
  "data-shop-toggle-error": true,
@@ -7755,7 +7994,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7755
7994
  /** One outdated install row (§7.3): the name, the installed and latest
7756
7995
  * versions, the hot enable/disable switch, and the update button (the
7757
7996
  * install flow for `name@latest`, reusing `InstallPanel`). */
7758
- function OutdatedRow({ row, tier, missing, t, setEnabled, flowFor, restart, restartSupported }) {
7997
+ function OutdatedRow({ row, tier, missing, t, setEnabled, flowFor, restart, restartBlocked, reload }) {
7759
7998
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7760
7999
  className: _dsh_plugin_shop_css_e3675a89_default.outdatedRow,
7761
8000
  "data-shop-outdated-entry": row.name,
@@ -7773,7 +8012,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7773
8012
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(EnabledSwitch, {
7774
8013
  row,
7775
8014
  t,
7776
- setEnabled
8015
+ setEnabled,
8016
+ reload
7777
8017
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InstallPanel, {
7778
8018
  target: {
7779
8019
  name: row.name,
@@ -7789,7 +8029,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7789
8029
  flow: flowFor(identityKey(row)),
7790
8030
  t,
7791
8031
  restart,
7792
- restartSupported
8032
+ restartBlocked,
8033
+ reload
7793
8034
  })]
7794
8035
  })]
7795
8036
  });
@@ -7801,7 +8042,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7801
8042
  * tier for the update gate is looked up from the catalog by name (community →
7802
8043
  * acknowledgement); an entry absent from the catalog defaults to the
7803
8044
  * community gate (the safer read). */
7804
- function OutdatedSection({ state, entriesByKey, missingByKey, t, setEnabled, flowFor, restart, restartSupported }) {
8045
+ function OutdatedSection({ state, entriesByKey, missingByKey, t, setEnabled, flowFor, restart, restartBlocked, reload }) {
7805
8046
  if (state.kind === "loading") return null;
7806
8047
  if (state.kind === "error") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7807
8048
  className: _dsh_plugin_shop_css_e3675a89_default.stateLine,
@@ -7826,7 +8067,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7826
8067
  setEnabled,
7827
8068
  flowFor,
7828
8069
  restart,
7829
- restartSupported
8070
+ restartBlocked,
8071
+ reload
7830
8072
  }) }, identityKey(row)))
7831
8073
  })]
7832
8074
  });
@@ -7834,7 +8076,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7834
8076
  /** The shop tab root: browse, search, refresh, and render one card per
7835
8077
  * entry. Data attributes on the e2e-relevant nodes follow the Task 3 list. */
7836
8078
  function ShopTab(props) {
7837
- const { t, catalog, install, installStatus, setEnabled, installed, installedSpecs, uninstall, restart, version, updateStart } = props;
8079
+ const { t, catalog, install, installStatus, setEnabled, installed, installedSpecs, uninstall, restart, version, updateStart, reload: injectedReload } = props;
7838
8080
  const [catalogState, setCatalogState] = (0, react.useState)({ kind: "loading" });
7839
8081
  const [installedState, setInstalledState] = (0, react.useState)({ kind: "loading" });
7840
8082
  /** The install gate's own input, or undefined while it is unknown — loading,
@@ -7843,7 +8085,10 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7843
8085
  * about a conflict it could not check. */
7844
8086
  const [specs, setSpecs] = (0, react.useState)(void 0);
7845
8087
  const [selfVersion, setSelfVersion] = (0, react.useState)(null);
7846
- const restartSupported = selfVersion?.restartSupported ?? true;
8088
+ const restartBlocked = selfVersion?.restartBlocked ?? null;
8089
+ const reload = (0, react.useMemo)(() => injectedReload ?? (() => {
8090
+ globalThis.location.reload();
8091
+ }), [injectedReload]);
7847
8092
  const [selfRestartGate, setSelfRestartGate] = (0, react.useState)(false);
7848
8093
  const selfUpdate = useUpdateSelf(updateStart, installStatus);
7849
8094
  const [request, setRequest] = (0, react.useState)({ kind: "initial" });
@@ -7851,7 +8096,17 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7851
8096
  const noteMutation = (0, react.useCallback)(() => {
7852
8097
  setMutations((current) => current + 1);
7853
8098
  }, []);
7854
- const flows = useInstallFlows(install, installStatus, noteMutation);
8099
+ const uninstallResetRef = (0, react.useRef)(() => {});
8100
+ const flows = useInstallFlows(install, installStatus, (0, react.useCallback)((key, outcome) => {
8101
+ if (outcome === "done") uninstallResetRef.current(key);
8102
+ noteMutation();
8103
+ }, [noteMutation]));
8104
+ const uninstallFlows = useUninstallFlows(uninstall, installStatus, (0, react.useCallback)((key, outcome) => {
8105
+ if (outcome !== "done") return;
8106
+ flows.flowFor(key).reset();
8107
+ noteMutation();
8108
+ }, [flows, noteMutation]));
8109
+ uninstallResetRef.current = uninstallFlows.resetFlow;
7855
8110
  const [reloading, setReloading] = (0, react.useState)(false);
7856
8111
  const [reloadFailed, setReloadFailed] = (0, react.useState)(false);
7857
8112
  const [query, setQuery] = (0, react.useState)("");
@@ -7974,7 +8229,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7974
8229
  const q = query.trim().toLowerCase();
7975
8230
  return sortedBrowsable.filter((entry) => {
7976
8231
  if (category === "installed") {
7977
- if (!installedByKey.has(entryKey(entry))) return false;
8232
+ const key = entryKey(entry);
8233
+ if (!installedByKey.has(key) && !uninstallFlows.pending.has(key)) return false;
7978
8234
  } else if (category !== null && categoryKey(entry) !== categoryLocaleKey(category)) return false;
7979
8235
  if (q === "") return true;
7980
8236
  const summaryEn = entry.catalog?.summary.en ?? "";
@@ -7985,7 +8241,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
7985
8241
  sortedBrowsable,
7986
8242
  query,
7987
8243
  category,
7988
- installedByKey
8244
+ installedByKey,
8245
+ uninstallFlows.pending
7989
8246
  ]);
7990
8247
  const filtered = (0, react.useMemo)(() => hideIncompatible && category !== "installed" ? matched.filter((entry) => !badgedIncompatible(entryKey(entry))) : matched, [
7991
8248
  matched,
@@ -8093,7 +8350,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8093
8350
  className: _dsh_plugin_shop_css_e3675a89_default.versionText,
8094
8351
  "data-shop-version": true,
8095
8352
  children: ["v", selfVersion.installed]
8096
- }), selfUpdate.view.kind === "done" && restartSupported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8353
+ }), selfUpdate.view.kind === "done" && restartBlocked === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8097
8354
  type: "button",
8098
8355
  className: _dsh_plugin_shop_css_e3675a89_default.restartSelfButton,
8099
8356
  "data-shop-restart": true,
@@ -8141,7 +8398,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8141
8398
  "data-shop-self-updating": true,
8142
8399
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
8143
8400
  className: _dsh_plugin_shop_css_e3675a89_default.installing,
8144
- children: t("installing")
8401
+ children: t(installPhaseKey(selfUpdate.view.phase))
8145
8402
  }), selfUpdate.view.log.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
8146
8403
  className: _dsh_plugin_shop_css_e3675a89_default.log,
8147
8404
  children: selfUpdate.view.log.map((line, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -8160,7 +8417,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8160
8417
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
8161
8418
  children: t("installedRestartNotice")
8162
8419
  }),
8163
- restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartPanel, {
8420
+ restartBlocked === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RestartPanel, {
8164
8421
  t,
8165
8422
  restart,
8166
8423
  gate: {
@@ -8168,10 +8425,10 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8168
8425
  close: () => setSelfRestartGate(false)
8169
8426
  }
8170
8427
  }),
8171
- !restartSupported && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
8428
+ restartBlocked !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
8172
8429
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
8173
8430
  "data-shop-restart-disabled": true,
8174
- children: t("restartDisabledNotice")
8431
+ children: t(restartBlockedNoticeKey(restartBlocked))
8175
8432
  })
8176
8433
  ]
8177
8434
  })
@@ -8280,8 +8537,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8280
8537
  }),
8281
8538
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8282
8539
  type: "button",
8283
- className: _dsh_plugin_shop_css_e3675a89_default.reloadButton,
8284
- "data-shop-reload": true,
8540
+ className: _dsh_plugin_shop_css_e3675a89_default.catalogRefreshButton,
8541
+ "data-shop-catalog-refresh": true,
8285
8542
  disabled: reloading,
8286
8543
  onClick: () => {
8287
8544
  setReloading(true);
@@ -8291,8 +8548,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8291
8548
  children: reloading ? t("refreshing") : t("refresh")
8292
8549
  }),
8293
8550
  reloadFailed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8294
- className: _dsh_plugin_shop_css_e3675a89_default.reloadFailed,
8295
- "data-shop-reload-failed": true,
8551
+ className: _dsh_plugin_shop_css_e3675a89_default.catalogRefreshFailed,
8552
+ "data-shop-catalog-refresh-failed": true,
8296
8553
  children: t("refreshFailed")
8297
8554
  })
8298
8555
  ]
@@ -8316,12 +8573,12 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8316
8573
  nameTakenBy: nameTakenByKey.get(key),
8317
8574
  t,
8318
8575
  flowFor: flows.flowFor,
8319
- installStatus,
8320
- uninstall,
8576
+ uninstallFlowFor: uninstallFlows.flowFor,
8321
8577
  restart,
8322
- restartSupported,
8578
+ restartBlocked,
8579
+ reload,
8323
8580
  setEnabled,
8324
- onSettled: noteMutation
8581
+ inInstalledView: category === "installed"
8325
8582
  }) }, key);
8326
8583
  }), incremental && visibleCount < filtered.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
8327
8584
  ref: sentinelRef,
@@ -8345,7 +8602,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
8345
8602
  setEnabled,
8346
8603
  flowFor: flows.flowFor,
8347
8604
  restart,
8348
- restartSupported
8605
+ restartBlocked,
8606
+ reload
8349
8607
  })
8350
8608
  ]
8351
8609
  });