@cowwo/dsh-icon-custom 0.9.0 → 0.9.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/README.zh.md CHANGED
@@ -58,12 +58,12 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
58
58
  规则:
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
- - 正在看的那个会话、以及子代理会话一律不计。
61
+ - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;要清掉它,切走再切回来即可。
62
62
  - **已读记在每个浏览器自己身上**(localStorage):打开过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
63
63
  - 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
64
64
  - 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
65
65
  - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
66
- - **想知道具体是哪个会话?** 侧栏底部(设置按钮旁边)有一个**铃铛按钮**,上面带待处理数量;点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。侧栏折叠成竖条时只显示图标和数字。
66
+ - **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。
67
67
 
68
68
  ## 支持的格式
69
69
 
package/client/client.js CHANGED
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
62
62
  unreadPanelOther: "其他",
63
63
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
64
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
65
- badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。正在看的会话和子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
65
+ badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
66
66
  };
67
67
  const en = {
68
68
  nav: "Favicon",
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  unreadPanelOther: "Other",
118
118
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
119
119
  workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
120
- badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing and sub-agents never count. Manual = type a number to preview. Resets to Real on reload."
120
+ badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing counts too — the window may be minimised or the page in the background, so this half cannot tell whether you are looking, and does not pretend to. Only sub-agents never count. Manual = type a number to preview. Resets to Real on reload."
121
121
  };
122
122
  //#endregion
123
123
 
@@ -329,8 +329,15 @@ window.__ModuleLoader__.load({
329
329
  /**
330
330
  * How many sessions deserve the badge right now: endings whose reason is
331
331
  * enabled and that this browser has not seen since, plus sessions waiting
332
- * for you (when that source is enabled). Sub-agent sessions and the session
333
- * you are looking at never count.
332
+ * for you (when that source is enabled). Only sub-agent sessions never count.
333
+ *
334
+ * The session you are LOOKING AT counts too. It used to be excluded, on the
335
+ * theory that "you have it open, so you have seen it" — but that premise is
336
+ * not knowable from here: the window may be minimised, the tab may be in the
337
+ * background, the page may be on another desktop. The browser half cannot
338
+ * tell "watching" from "left open", and a rule that cannot tell two cases
339
+ * apart should not pretend to: a session counts from the moment it ends until
340
+ * it is opened again. Leaving and re-entering it is what clears the entry.
334
341
  * @param list - the session list snapshot (may be absent).
335
342
  * @param pending - the pending-interaction map (may be absent).
336
343
  * @param config - the unread rule to apply.
@@ -344,7 +351,6 @@ window.__ModuleLoader__.load({
344
351
  /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
345
352
  const hits = [];
346
353
  const ids = new Set();
347
- const current = currentSessionId(list);
348
354
  const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
349
355
  const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
350
356
  // Which workspace the session lives in. The sidebar's own workspace rows are
@@ -363,7 +369,6 @@ window.__ModuleLoader__.load({
363
369
  const entry = byId[id];
364
370
  if (entry === null || typeof entry !== "object") continue;
365
371
  if (entry.origin === "subagent") continue;
366
- if (id === current) continue;
367
372
  const values = entry.projectionValues;
368
373
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
369
374
  if (value === null || typeof value !== "object") continue;
@@ -380,7 +385,6 @@ window.__ModuleLoader__.load({
380
385
  if (config === null || typeof config !== "object" || config.pending !== false) {
381
386
  if (pending !== null && pending !== undefined && typeof pending.forEach === "function") {
382
387
  pending.forEach((value, id) => {
383
- if (id === current) return;
384
388
  const kind = value !== null && typeof value === "object" && typeof value.kind === "string" ? value.kind : "";
385
389
  if (ids.has(id)) {
386
390
  // Already unread by its last turn: it is waiting too, which wins.
@@ -426,6 +430,9 @@ window.__ModuleLoader__.load({
426
430
  function notifyBadge() {
427
431
  const snapshot = badgeSnapshot();
428
432
  badgeListeners.forEach((fn) => { try { fn(snapshot); } catch {} });
433
+ // The header badge is a DOM node, not a listener, so it is reconciled
434
+ // here as well as from the mutation watchdog. Idempotent either way.
435
+ paintHeaderBadge();
429
436
  }
430
437
  /** Manual test input. */
431
438
  function emitBadge(value) {
@@ -1012,48 +1019,218 @@ window.__ModuleLoader__.load({
1012
1019
  }
1013
1020
 
1014
1021
  /**
1015
- * The sidebar-foot entry: a bell carrying the unread count, beside the
1016
- * shipped Settings and Cordis buttons. Both that seat and the overlay list
1017
- * are additive (`replaceRisk: none`) — a fresh id is added beside the others,
1018
- * nothing shipped is shadowed.
1022
+ * The unread badge, injected into the sidebar's section header.
1023
+ *
1024
+ * There is no slot to register into: `sidebar.workspaces` is a SINGLE slot
1025
+ * owned by ui-workspace that covers the whole browsing region *including* its
1026
+ * header, and shadowing it would mean reimplementing the shipped browser. So
1027
+ * the badge is injected into the DOM — the same technique, the same contract,
1028
+ * and the same failure posture as the workspace-row dots: decorative, never
1029
+ * throws, and after repeated failures it gives up on its own with one warning.
1030
+ *
1031
+ * Placement is measured, not inherited, because the shipped CSS forbids the
1032
+ * obvious approach: `.sectionHeader` is 36px tall with `overflow:hidden`, the
1033
+ * label inside it is a 20px line box that is ALSO `overflow:hidden`, so a badge
1034
+ * parented to the label and raised clear of the glyph is clipped to a sliver by
1035
+ * the label's own overflow. The badge is therefore a SIBLING of the label,
1036
+ * absolutely positioned over the label's top-right corner from its measured
1037
+ * rect, and clamped so it never leaves the header's 36px box.
1038
+ *
1039
+ * The count is shown for EVERY value including zero: a grey 0 keeps the
1040
+ * position from jumping, and doubles as the only way back to the panel once
1041
+ * the old sidebar-foot button is gone.
1019
1042
  */
1020
- function UnreadFooterButton(props) {
1021
- const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
1022
- const [open, setOpen] = React.useState(unreadPanelOpen);
1023
- const ref = React.useRef(null);
1024
- React.useEffect(() => subscribeBadge(setSnapshot), []);
1025
- React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
1026
- const t = props.t;
1027
- const count = snapshot.count;
1028
- const wide = props.wide !== false;
1029
- const label = count > 0 ? t("unreadPanelCount").replace("{n}", String(count)) : t("unreadPanelNone");
1030
- const onClick = () => {
1031
- const node = ref.current;
1032
- const rect = node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
1033
- toggleUnreadPanel(rect === null ? null : { left: rect.left, top: rect.top });
1034
- };
1035
- return React.createElement("button", {
1036
- ref, type: "button", onClick, title: label, "aria-label": label, "aria-expanded": open,
1037
- style: {
1038
- position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
1039
- height: "32px", width: wide ? "auto" : "32px", padding: wide ? "0 10px" : "0",
1040
- border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", background: "transparent",
1041
- color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "12px",
1042
- opacity: count > 0 ? 1 : 0.55
1043
+ const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
1044
+ const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
1045
+ const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
1046
+ const HEAD_BADGE_MAX_FAILURES = 40;
1047
+ /** How many ancestors to walk up looking for the header row. */
1048
+ const HEAD_BADGE_MAX_STEPS = 6;
1049
+ /** Raise above the label's top edge; the header only leaves 8px of headroom. */
1050
+ const HEAD_BADGE_RAISE = 7;
1051
+ /** Overhang past the label's right edge. */
1052
+ const HEAD_BADGE_OVERHANG = 9;
1053
+ let headerBadgeHost = null;
1054
+ let headerBadgeHostPosition = "";
1055
+ let headerBadgeObserved = null;
1056
+ let headerBadgeObserver = null;
1057
+ let headerBadgeFailures = 0;
1058
+ let headerBadgeGivenUp = false;
1059
+ /** Locale binder, installed by `apply()` so the injected node can label itself. */
1060
+ let badgeT = null;
1061
+
1062
+ /**
1063
+ * Rules for the injected badge. A stylesheet rather than inline styles because
1064
+ * hover and the theme-following ring cannot be expressed inline. Colours come
1065
+ * from the official tokens wherever one exists:
1066
+ * * the red is the one this plugin has always drawn;
1067
+ * * the "nothing pending" grey is the official idle-state colour, so it
1068
+ * follows light/dark without naming either;
1069
+ * * the ring is the sidebar's own fill — that is what makes a badge sitting
1070
+ * on top of a glyph read as a badge instead of a smudge.
1071
+ * The hit area grows sideways and downward only: there are just 8px above the
1072
+ * label, so anything reaching further up is clipped by the header's overflow.
1073
+ */
1074
+ const HEAD_BADGE_CSS = [
1075
+ `[${HEAD_BADGE_ATTR}]{position:absolute;display:none;align-items:center;justify-content:center;min-width:15px;height:15px;padding:0 3.5px;box-sizing:border-box;border:0;border-radius:999px;background:#e5484d;color:#fff;font:inherit;font-size:9.5px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.02em;cursor:pointer;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`,
1076
+ `[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
1077
+ `[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
1078
+ `[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`
1079
+ ].join("");
1080
+
1081
+ /** Install the badge rules once per page. */
1082
+ function ensureHeaderBadgeStyle() {
1083
+ try {
1084
+ if (document.getElementById(HEAD_BADGE_STYLE_ID) !== null) return;
1085
+ const style = document.createElement("style");
1086
+ style.id = HEAD_BADGE_STYLE_ID;
1087
+ style.textContent = HEAD_BADGE_CSS;
1088
+ document.head.appendChild(style);
1089
+ } catch { /* styling is cosmetic; never let it reach a render tree */ }
1090
+ }
1091
+
1092
+ /**
1093
+ * The section-header row and the label inside it.
1094
+ *
1095
+ * The title TEXT is deliberately not matched: it flips between 工作区 and 会话
1096
+ * with the group-by mode and is localized, so any string table would be both
1097
+ * incomplete and wrong half the time. Structure is stable instead —
1098
+ * `sidebar.workspaces` is a slot root (the official renderer stamps
1099
+ * `data-slot` on those), the header row's first button is the search control,
1100
+ * and the label is the row's first text-bearing child that owns no button.
1101
+ * @param container - the browsing region root.
1102
+ * @returns `{ row, label }`, or null when this build's DOM is not recognised.
1103
+ */
1104
+ function sectionHeaderParts(container) {
1105
+ // Our own badge is a button too; never let it stand in for the search
1106
+ // control, or the row anchor would resolve to the wrong element.
1107
+ let firstButton = null;
1108
+ for (const candidate of container.querySelectorAll("button")) {
1109
+ if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
1110
+ firstButton = candidate;
1111
+ break;
1112
+ }
1113
+ if (firstButton === null) return null;
1114
+ let row = firstButton.parentElement;
1115
+ for (let step = 0; step < HEAD_BADGE_MAX_STEPS && row !== null && row !== container; step++) {
1116
+ for (const child of row.children) {
1117
+ if (child === firstButton || child.contains(firstButton)) continue;
1118
+ if (child.tagName === "BUTTON") continue;
1119
+ if (child.querySelector("button") !== null) continue;
1120
+ if ((child.textContent ?? "").trim() !== "") return { row, label: child };
1043
1121
  }
1044
- },
1045
- React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", style: { flex: "none" } },
1046
- React.createElement("path", { d: "M8 2.2a3.6 3.6 0 0 0-3.6 3.6v2.4L3.2 11h9.6l-1.2-2.8V5.8A3.6 3.6 0 0 0 8 2.2Z", stroke: "currentColor", strokeWidth: 1.3, strokeLinejoin: "round" }),
1047
- React.createElement("path", { d: "M6.4 12.8a1.6 1.6 0 0 0 3.2 0", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
1048
- ),
1049
- wide ? React.createElement("span", null, t("unreadPanelShort")) : null,
1050
- count > 0 ? React.createElement("span", {
1051
- "aria-hidden": "true",
1052
- style: { position: "absolute", top: "-6px", right: "-6px", minWidth: "16px", height: "16px", padding: "0 4px", boxSizing: "border-box", borderRadius: "999px", background: "#e5484d", color: "#fff", fontSize: "10px", fontWeight: 700, lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", fontVariantNumeric: "tabular-nums", pointerEvents: "none" }
1053
- }, badgeLabel(count)) : null
1054
- );
1122
+ row = row.parentElement;
1123
+ }
1124
+ return null;
1125
+ }
1126
+
1127
+ /** Warn once, then give up for this page — the row dots' exact posture. */
1128
+ function noteHeaderBadgeFailure(why) {
1129
+ headerBadgeFailures++;
1130
+ if (headerBadgeFailures === 1) {
1131
+ try { console.warn("dsh-icon-custom: 工作区标题红点定位失败,已降级(不影响其他功能):", why); } catch {}
1132
+ return;
1133
+ }
1134
+ if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
1135
+ headerBadgeGivenUp = true;
1136
+ try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
1055
1137
  }
1056
1138
 
1139
+ /**
1140
+ * Reconcile the header badge with the current count and the label's position.
1141
+ *
1142
+ * Driven by the same mutation watchdog as the row dots, so it must be
1143
+ * idempotent: it writes only what actually differs, or the observer that
1144
+ * called it would re-arm on its own output. Geometry is read from the live
1145
+ * rects, so the badge follows a sidebar resize, a locale change and the
1146
+ * label's own collapse animation without any hardcoded offsets.
1147
+ */
1148
+ function paintHeaderBadge() {
1149
+ if (headerBadgeGivenUp) return;
1150
+ const container = workspaceDotContainer();
1151
+ if (container === null) return; // start-up race, not a failure
1152
+ const parts = sectionHeaderParts(container);
1153
+ if (parts === null) { noteHeaderBadgeFailure("找不到分区标题"); return; }
1154
+ const row = parts.row;
1155
+ const label = parts.label;
1156
+ ensureHeaderBadgeStyle();
1157
+ if (headerBadgeHost !== row) {
1158
+ if (headerBadgeHost !== null) { try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {} }
1159
+ headerBadgeHost = row;
1160
+ headerBadgeHostPosition = row.style.position;
1161
+ try { if (window.getComputedStyle(row).position === "static") row.style.position = "relative"; } catch {}
1162
+ }
1163
+ let node = row.querySelector(`[${HEAD_BADGE_ATTR}]`);
1164
+ if (node === null) {
1165
+ node = document.createElement("button");
1166
+ node.type = "button";
1167
+ node.setAttribute(HEAD_BADGE_ATTR, "1");
1168
+ // Appended AFTER the shipped controls, so it can never be mistaken for
1169
+ // the header's first button.
1170
+ row.appendChild(node);
1171
+ }
1172
+ if (node.getAttribute(HEAD_BADGE_WIRED) !== "1") {
1173
+ node.setAttribute(HEAD_BADGE_WIRED, "1");
1174
+ node.addEventListener("click", (event) => {
1175
+ // The row belongs to the sidebar; keep the click to ourselves.
1176
+ event.preventDefault();
1177
+ event.stopPropagation();
1178
+ const rect = node.getBoundingClientRect();
1179
+ toggleUnreadPanel({ left: rect.left, top: rect.top });
1180
+ });
1181
+ }
1182
+ const count = effectiveCount();
1183
+ const text = badgeLabel(count);
1184
+ const zero = count > 0 ? "0" : "1";
1185
+ if (node.getAttribute("data-zero") !== zero) node.setAttribute("data-zero", zero);
1186
+ if (typeof badgeT === "function") {
1187
+ const spoken = count > 0 ? badgeT("unreadPanelCount").replace("{n}", String(count)) : badgeT("unreadPanelNone");
1188
+ if (node.getAttribute("aria-label") !== spoken) {
1189
+ node.setAttribute("aria-label", spoken);
1190
+ node.setAttribute("title", spoken);
1191
+ }
1192
+ }
1193
+ if (node.textContent !== text) node.textContent = text;
1194
+ // The label collapses to nothing while the search box is open; the badge
1195
+ // has no meaning then, and the label's own visibility cannot hide it
1196
+ // because it is a sibling.
1197
+ const rowRect = row.getBoundingClientRect();
1198
+ const labelRect = label.getBoundingClientRect();
1199
+ if (labelRect.width < 8) {
1200
+ if (node.style.display !== "none") node.style.display = "none";
1201
+ } else {
1202
+ const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
1203
+ const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
1204
+ // Clamped: the header clips its overflow, and only ~8px sit above the label.
1205
+ const top = Math.max(0, Math.round(labelRect.top - rowRect.top - HEAD_BADGE_RAISE));
1206
+ if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
1207
+ if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
1208
+ if (node.style.display !== "flex") node.style.display = "flex";
1209
+ }
1210
+ // The label animates its own width; follow it instead of waiting for the
1211
+ // next mutation, which a CSS transition never produces.
1212
+ if (typeof window.ResizeObserver === "function") {
1213
+ if (headerBadgeObserver === null) headerBadgeObserver = new window.ResizeObserver(() => { paintHeaderBadge(); });
1214
+ if (headerBadgeObserved !== label) {
1215
+ try { headerBadgeObserver.disconnect(); headerBadgeObserver.observe(label); headerBadgeObserved = label; } catch {}
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ /** Take the badge back out and restore the row's own positioning. */
1221
+ function clearHeaderBadge() {
1222
+ try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}]`).forEach((node) => node.remove()); } catch {}
1223
+ if (headerBadgeObserver !== null) {
1224
+ try { headerBadgeObserver.disconnect(); } catch {}
1225
+ headerBadgeObserver = null;
1226
+ headerBadgeObserved = null;
1227
+ }
1228
+ if (headerBadgeHost !== null) {
1229
+ try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {}
1230
+ }
1231
+ headerBadgeHost = null;
1232
+ headerBadgeHostPosition = "";
1233
+ }
1057
1234
  /**
1058
1235
  * The list behind that number, in the frame-wide overlay layer so it escapes
1059
1236
  * the sidebar's clipping and scroll container. Clicking a row opens that
@@ -1577,9 +1754,11 @@ window.__ModuleLoader__.load({
1577
1754
  inject: () => ({ t })
1578
1755
  }, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
1579
1756
 
1580
- // Additive seats for "where exactly?" — a foot entry beside the shipped
1581
- // Settings/Cordis buttons, and the frame-wide overlay for its list. A fresh
1582
- // id is added beside the others; nothing shipped is shadowed.
1757
+ // The badge itself is NOT a slot occupant: it is injected into the
1758
+ // sidebar's section header by `paintHeaderBadge()` (see above), because
1759
+ // `sidebar.workspaces` is a single slot covering the whole region. Only
1760
+ // the list it opens still rides a slot — the frame-wide overlay, which
1761
+ // escapes the sidebar's clipping.
1583
1762
  //
1584
1763
  // 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
1585
1764
  // retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
@@ -1590,10 +1769,9 @@ window.__ModuleLoader__.load({
1590
1769
  const openSession = (id) => {
1591
1770
  try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
1592
1771
  };
1593
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
1594
- { name: "sidebar.footer.action", id: "icon-custom-unread", order: 20, label: () => t("unreadPanelShort") },
1595
- (props) => React.createElement(UnreadFooterButton, { ...props, t, onOpen: openSession })
1596
- ));
1772
+ // The injected node labels itself for screen readers, so it needs the
1773
+ // locale binder that only exists inside `apply()`.
1774
+ badgeT = t;
1597
1775
  ctx.slots.inject("shell.overlay", () => ctx.slots.register(
1598
1776
  { name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
1599
1777
  () => React.createElement(UnreadPopup, { t, onOpen: openSession })
@@ -1656,7 +1834,13 @@ window.__ModuleLoader__.load({
1656
1834
  const schedule = () => {
1657
1835
  if (queued) return;
1658
1836
  queued = true;
1659
- window.setTimeout(() => { queued = false; paintWorkspaceDots(); }, 200);
1837
+ window.setTimeout(() => {
1838
+ queued = false;
1839
+ paintWorkspaceDots();
1840
+ // Same region, same watchdog: the section-header badge is
1841
+ // re-injected whenever the shipped UI re-renders over it.
1842
+ paintHeaderBadge();
1843
+ }, 200);
1660
1844
  };
1661
1845
  const attach = () => {
1662
1846
  const container = workspaceDotContainer();
@@ -1683,6 +1867,7 @@ window.__ModuleLoader__.load({
1683
1867
  offDots();
1684
1868
  offConfig();
1685
1869
  clearWorkspaceDots();
1870
+ clearHeaderBadge();
1686
1871
  };
1687
1872
  }, "dsh-icon-custom: workspace dots");
1688
1873
  // The platform favicon.svg repaints itself for dark mode through
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",