@cowwo/dsh-icon-custom 0.9.0 → 0.9.1

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.
Files changed (2) hide show
  1. package/client/client.js +227 -46
  2. package/package.json +1 -1
package/client/client.js CHANGED
@@ -426,6 +426,9 @@ window.__ModuleLoader__.load({
426
426
  function notifyBadge() {
427
427
  const snapshot = badgeSnapshot();
428
428
  badgeListeners.forEach((fn) => { try { fn(snapshot); } catch {} });
429
+ // The header badge is a DOM node, not a listener, so it is reconciled
430
+ // here as well as from the mutation watchdog. Idempotent either way.
431
+ paintHeaderBadge();
429
432
  }
430
433
  /** Manual test input. */
431
434
  function emitBadge(value) {
@@ -1012,48 +1015,218 @@ window.__ModuleLoader__.load({
1012
1015
  }
1013
1016
 
1014
1017
  /**
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.
1018
+ * The unread badge, injected into the sidebar's section header.
1019
+ *
1020
+ * There is no slot to register into: `sidebar.workspaces` is a SINGLE slot
1021
+ * owned by ui-workspace that covers the whole browsing region *including* its
1022
+ * header, and shadowing it would mean reimplementing the shipped browser. So
1023
+ * the badge is injected into the DOM — the same technique, the same contract,
1024
+ * and the same failure posture as the workspace-row dots: decorative, never
1025
+ * throws, and after repeated failures it gives up on its own with one warning.
1026
+ *
1027
+ * Placement is measured, not inherited, because the shipped CSS forbids the
1028
+ * obvious approach: `.sectionHeader` is 36px tall with `overflow:hidden`, the
1029
+ * label inside it is a 20px line box that is ALSO `overflow:hidden`, so a badge
1030
+ * parented to the label and raised clear of the glyph is clipped to a sliver by
1031
+ * the label's own overflow. The badge is therefore a SIBLING of the label,
1032
+ * absolutely positioned over the label's top-right corner from its measured
1033
+ * rect, and clamped so it never leaves the header's 36px box.
1034
+ *
1035
+ * The count is shown for EVERY value including zero: a grey 0 keeps the
1036
+ * position from jumping, and doubles as the only way back to the panel once
1037
+ * the old sidebar-foot button is gone.
1019
1038
  */
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
1039
+ const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
1040
+ const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
1041
+ const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
1042
+ const HEAD_BADGE_MAX_FAILURES = 40;
1043
+ /** How many ancestors to walk up looking for the header row. */
1044
+ const HEAD_BADGE_MAX_STEPS = 6;
1045
+ /** Raise above the label's top edge; the header only leaves 8px of headroom. */
1046
+ const HEAD_BADGE_RAISE = 7;
1047
+ /** Overhang past the label's right edge. */
1048
+ const HEAD_BADGE_OVERHANG = 9;
1049
+ let headerBadgeHost = null;
1050
+ let headerBadgeHostPosition = "";
1051
+ let headerBadgeObserved = null;
1052
+ let headerBadgeObserver = null;
1053
+ let headerBadgeFailures = 0;
1054
+ let headerBadgeGivenUp = false;
1055
+ /** Locale binder, installed by `apply()` so the injected node can label itself. */
1056
+ let badgeT = null;
1057
+
1058
+ /**
1059
+ * Rules for the injected badge. A stylesheet rather than inline styles because
1060
+ * hover and the theme-following ring cannot be expressed inline. Colours come
1061
+ * from the official tokens wherever one exists:
1062
+ * * the red is the one this plugin has always drawn;
1063
+ * * the "nothing pending" grey is the official idle-state colour, so it
1064
+ * follows light/dark without naming either;
1065
+ * * the ring is the sidebar's own fill — that is what makes a badge sitting
1066
+ * on top of a glyph read as a badge instead of a smudge.
1067
+ * The hit area grows sideways and downward only: there are just 8px above the
1068
+ * label, so anything reaching further up is clipped by the header's overflow.
1069
+ */
1070
+ const HEAD_BADGE_CSS = [
1071
+ `[${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)}`,
1072
+ `[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
1073
+ `[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
1074
+ `[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`
1075
+ ].join("");
1076
+
1077
+ /** Install the badge rules once per page. */
1078
+ function ensureHeaderBadgeStyle() {
1079
+ try {
1080
+ if (document.getElementById(HEAD_BADGE_STYLE_ID) !== null) return;
1081
+ const style = document.createElement("style");
1082
+ style.id = HEAD_BADGE_STYLE_ID;
1083
+ style.textContent = HEAD_BADGE_CSS;
1084
+ document.head.appendChild(style);
1085
+ } catch { /* styling is cosmetic; never let it reach a render tree */ }
1086
+ }
1087
+
1088
+ /**
1089
+ * The section-header row and the label inside it.
1090
+ *
1091
+ * The title TEXT is deliberately not matched: it flips between 工作区 and 会话
1092
+ * with the group-by mode and is localized, so any string table would be both
1093
+ * incomplete and wrong half the time. Structure is stable instead —
1094
+ * `sidebar.workspaces` is a slot root (the official renderer stamps
1095
+ * `data-slot` on those), the header row's first button is the search control,
1096
+ * and the label is the row's first text-bearing child that owns no button.
1097
+ * @param container - the browsing region root.
1098
+ * @returns `{ row, label }`, or null when this build's DOM is not recognised.
1099
+ */
1100
+ function sectionHeaderParts(container) {
1101
+ // Our own badge is a button too; never let it stand in for the search
1102
+ // control, or the row anchor would resolve to the wrong element.
1103
+ let firstButton = null;
1104
+ for (const candidate of container.querySelectorAll("button")) {
1105
+ if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
1106
+ firstButton = candidate;
1107
+ break;
1108
+ }
1109
+ if (firstButton === null) return null;
1110
+ let row = firstButton.parentElement;
1111
+ for (let step = 0; step < HEAD_BADGE_MAX_STEPS && row !== null && row !== container; step++) {
1112
+ for (const child of row.children) {
1113
+ if (child === firstButton || child.contains(firstButton)) continue;
1114
+ if (child.tagName === "BUTTON") continue;
1115
+ if (child.querySelector("button") !== null) continue;
1116
+ if ((child.textContent ?? "").trim() !== "") return { row, label: child };
1043
1117
  }
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
- );
1118
+ row = row.parentElement;
1119
+ }
1120
+ return null;
1121
+ }
1122
+
1123
+ /** Warn once, then give up for this page — the row dots' exact posture. */
1124
+ function noteHeaderBadgeFailure(why) {
1125
+ headerBadgeFailures++;
1126
+ if (headerBadgeFailures === 1) {
1127
+ try { console.warn("dsh-icon-custom: 工作区标题红点定位失败,已降级(不影响其他功能):", why); } catch {}
1128
+ return;
1129
+ }
1130
+ if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
1131
+ headerBadgeGivenUp = true;
1132
+ try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
1055
1133
  }
1056
1134
 
1135
+ /**
1136
+ * Reconcile the header badge with the current count and the label's position.
1137
+ *
1138
+ * Driven by the same mutation watchdog as the row dots, so it must be
1139
+ * idempotent: it writes only what actually differs, or the observer that
1140
+ * called it would re-arm on its own output. Geometry is read from the live
1141
+ * rects, so the badge follows a sidebar resize, a locale change and the
1142
+ * label's own collapse animation without any hardcoded offsets.
1143
+ */
1144
+ function paintHeaderBadge() {
1145
+ if (headerBadgeGivenUp) return;
1146
+ const container = workspaceDotContainer();
1147
+ if (container === null) return; // start-up race, not a failure
1148
+ const parts = sectionHeaderParts(container);
1149
+ if (parts === null) { noteHeaderBadgeFailure("找不到分区标题"); return; }
1150
+ const row = parts.row;
1151
+ const label = parts.label;
1152
+ ensureHeaderBadgeStyle();
1153
+ if (headerBadgeHost !== row) {
1154
+ if (headerBadgeHost !== null) { try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {} }
1155
+ headerBadgeHost = row;
1156
+ headerBadgeHostPosition = row.style.position;
1157
+ try { if (window.getComputedStyle(row).position === "static") row.style.position = "relative"; } catch {}
1158
+ }
1159
+ let node = row.querySelector(`[${HEAD_BADGE_ATTR}]`);
1160
+ if (node === null) {
1161
+ node = document.createElement("button");
1162
+ node.type = "button";
1163
+ node.setAttribute(HEAD_BADGE_ATTR, "1");
1164
+ // Appended AFTER the shipped controls, so it can never be mistaken for
1165
+ // the header's first button.
1166
+ row.appendChild(node);
1167
+ }
1168
+ if (node.getAttribute(HEAD_BADGE_WIRED) !== "1") {
1169
+ node.setAttribute(HEAD_BADGE_WIRED, "1");
1170
+ node.addEventListener("click", (event) => {
1171
+ // The row belongs to the sidebar; keep the click to ourselves.
1172
+ event.preventDefault();
1173
+ event.stopPropagation();
1174
+ const rect = node.getBoundingClientRect();
1175
+ toggleUnreadPanel({ left: rect.left, top: rect.top });
1176
+ });
1177
+ }
1178
+ const count = effectiveCount();
1179
+ const text = badgeLabel(count);
1180
+ const zero = count > 0 ? "0" : "1";
1181
+ if (node.getAttribute("data-zero") !== zero) node.setAttribute("data-zero", zero);
1182
+ if (typeof badgeT === "function") {
1183
+ const spoken = count > 0 ? badgeT("unreadPanelCount").replace("{n}", String(count)) : badgeT("unreadPanelNone");
1184
+ if (node.getAttribute("aria-label") !== spoken) {
1185
+ node.setAttribute("aria-label", spoken);
1186
+ node.setAttribute("title", spoken);
1187
+ }
1188
+ }
1189
+ if (node.textContent !== text) node.textContent = text;
1190
+ // The label collapses to nothing while the search box is open; the badge
1191
+ // has no meaning then, and the label's own visibility cannot hide it
1192
+ // because it is a sibling.
1193
+ const rowRect = row.getBoundingClientRect();
1194
+ const labelRect = label.getBoundingClientRect();
1195
+ if (labelRect.width < 8) {
1196
+ if (node.style.display !== "none") node.style.display = "none";
1197
+ } else {
1198
+ const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
1199
+ const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
1200
+ // Clamped: the header clips its overflow, and only ~8px sit above the label.
1201
+ const top = Math.max(0, Math.round(labelRect.top - rowRect.top - HEAD_BADGE_RAISE));
1202
+ if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
1203
+ if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
1204
+ if (node.style.display !== "flex") node.style.display = "flex";
1205
+ }
1206
+ // The label animates its own width; follow it instead of waiting for the
1207
+ // next mutation, which a CSS transition never produces.
1208
+ if (typeof window.ResizeObserver === "function") {
1209
+ if (headerBadgeObserver === null) headerBadgeObserver = new window.ResizeObserver(() => { paintHeaderBadge(); });
1210
+ if (headerBadgeObserved !== label) {
1211
+ try { headerBadgeObserver.disconnect(); headerBadgeObserver.observe(label); headerBadgeObserved = label; } catch {}
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ /** Take the badge back out and restore the row's own positioning. */
1217
+ function clearHeaderBadge() {
1218
+ try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}]`).forEach((node) => node.remove()); } catch {}
1219
+ if (headerBadgeObserver !== null) {
1220
+ try { headerBadgeObserver.disconnect(); } catch {}
1221
+ headerBadgeObserver = null;
1222
+ headerBadgeObserved = null;
1223
+ }
1224
+ if (headerBadgeHost !== null) {
1225
+ try { headerBadgeHost.style.position = headerBadgeHostPosition; } catch {}
1226
+ }
1227
+ headerBadgeHost = null;
1228
+ headerBadgeHostPosition = "";
1229
+ }
1057
1230
  /**
1058
1231
  * The list behind that number, in the frame-wide overlay layer so it escapes
1059
1232
  * the sidebar's clipping and scroll container. Clicking a row opens that
@@ -1577,9 +1750,11 @@ window.__ModuleLoader__.load({
1577
1750
  inject: () => ({ t })
1578
1751
  }, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
1579
1752
 
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.
1753
+ // The badge itself is NOT a slot occupant: it is injected into the
1754
+ // sidebar's section header by `paintHeaderBadge()` (see above), because
1755
+ // `sidebar.workspaces` is a single slot covering the whole region. Only
1756
+ // the list it opens still rides a slot — the frame-wide overlay, which
1757
+ // escapes the sidebar's clipping.
1583
1758
  //
1584
1759
  // 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
1585
1760
  // retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
@@ -1590,10 +1765,9 @@ window.__ModuleLoader__.load({
1590
1765
  const openSession = (id) => {
1591
1766
  try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
1592
1767
  };
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
- ));
1768
+ // The injected node labels itself for screen readers, so it needs the
1769
+ // locale binder that only exists inside `apply()`.
1770
+ badgeT = t;
1597
1771
  ctx.slots.inject("shell.overlay", () => ctx.slots.register(
1598
1772
  { name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
1599
1773
  () => React.createElement(UnreadPopup, { t, onOpen: openSession })
@@ -1656,7 +1830,13 @@ window.__ModuleLoader__.load({
1656
1830
  const schedule = () => {
1657
1831
  if (queued) return;
1658
1832
  queued = true;
1659
- window.setTimeout(() => { queued = false; paintWorkspaceDots(); }, 200);
1833
+ window.setTimeout(() => {
1834
+ queued = false;
1835
+ paintWorkspaceDots();
1836
+ // Same region, same watchdog: the section-header badge is
1837
+ // re-injected whenever the shipped UI re-renders over it.
1838
+ paintHeaderBadge();
1839
+ }, 200);
1660
1840
  };
1661
1841
  const attach = () => {
1662
1842
  const container = workspaceDotContainer();
@@ -1683,6 +1863,7 @@ window.__ModuleLoader__.load({
1683
1863
  offDots();
1684
1864
  offConfig();
1685
1865
  clearWorkspaceDots();
1866
+ clearHeaderBadge();
1686
1867
  };
1687
1868
  }, "dsh-icon-custom: workspace dots");
1688
1869
  // 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.1",
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",