@cowwo/dsh-icon-custom 0.8.2 → 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.
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) {
@@ -482,16 +485,66 @@ window.__ModuleLoader__.load({
482
485
  if (iconState.active !== true) return "image/svg+xml";
483
486
  return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
484
487
  }
485
- function iconLink() {
486
- let link = document.querySelector('link[rel="icon"]');
487
- if (!link) {
488
- link = document.createElement("link");
489
- link.rel = "icon";
490
- link.type = "image/svg+xml";
491
- link.href = "/favicon.svg";
492
- document.head.appendChild(link);
488
+ /**
489
+ * Every `<link rel="icon">` the document carries, creating one if the page
490
+ * has none.
491
+ *
492
+ * DSH 0.2.x ships TWO of them — a dark and a light one, each behind a
493
+ * `media` query. Touching only `querySelector`'s first match left the light
494
+ * one pointing at the shipped favicon, and the browser honours the LAST
495
+ * suitable candidate, so a custom icon silently failed to apply in light
496
+ * mode (the tab kept the platform whale and the feature looked dead).
497
+ * Every write below therefore goes to all of them.
498
+ */
499
+ function iconLinks() {
500
+ const links = Array.from(document.querySelectorAll('link[rel="icon"]'));
501
+ if (links.length > 0) return links;
502
+ const link = document.createElement("link");
503
+ link.rel = "icon";
504
+ link.type = "image/svg+xml";
505
+ link.href = "/favicon.svg";
506
+ document.head.appendChild(link);
507
+ return [link];
508
+ }
509
+ /** Authored href/type, remembered before the first write so a reset can put the shipped pair back. */
510
+ const ICON_ORIGINALS = new WeakMap();
511
+ function rememberIcon(link) {
512
+ if (ICON_ORIGINALS.has(link)) return;
513
+ ICON_ORIGINALS.set(link, { href: link.getAttribute("href"), type: link.getAttribute("type") });
514
+ }
515
+ /**
516
+ * Point every icon link at one image, so no scheme-specific sibling is left stale.
517
+ * Writes only on change: the head MutationObserver feeds `reconcileFavicon`,
518
+ * so a redundant write would re-arm the watchdog on every tick.
519
+ */
520
+ function setIconEverywhere(href, type) {
521
+ for (const link of iconLinks()) {
522
+ rememberIcon(link);
523
+ if (link.getAttribute("href") !== href) link.setAttribute("href", href);
524
+ if (typeof type === "string" && type !== "") {
525
+ if (link.getAttribute("type") !== type) link.setAttribute("type", type);
526
+ } else if (link.getAttribute("type") !== null) link.removeAttribute("type");
493
527
  }
494
- return link;
528
+ }
529
+ /**
530
+ * Put the authored hrefs back; a link this plugin created falls back to the
531
+ * platform default. Idempotent for the same watchdog reason as above, and a
532
+ * no-op for any link the plugin never touched — which is what keeps the
533
+ * documented "zero side effects while no custom icon is set" true.
534
+ */
535
+ function restoreShippedIcons() {
536
+ for (const link of iconLinks()) {
537
+ const original = ICON_ORIGINALS.get(link);
538
+ if (original === undefined) continue; // never touched → leave the shipped tag alone
539
+ const href = original.href === null ? "/favicon.svg" : original.href;
540
+ if (link.getAttribute("href") !== href) link.setAttribute("href", href);
541
+ if (original.type === null) {
542
+ if (link.getAttribute("type") !== null) link.removeAttribute("type");
543
+ } else if (link.getAttribute("type") !== original.type) link.setAttribute("type", original.type);
544
+ }
545
+ }
546
+ function iconLink() {
547
+ return iconLinks()[0];
495
548
  }
496
549
  function roundRect(c2d, x, y, w, h) {
497
550
  const r = Math.min(h / 2, w / 2);
@@ -535,15 +588,14 @@ window.__ModuleLoader__.load({
535
588
  });
536
589
  }
537
590
  async function applyBadgeToFavicon(count, size) {
538
- const link = iconLink();
539
591
  const base = desiredIconHref();
540
592
  if (count <= 0) {
541
- // Nothing to show: make sure the page carries the plugin's own icon
542
- // (this also repairs a stale/proxied link that still points elsewhere).
543
- if (link.getAttribute("href") !== base) {
544
- link.setAttribute("href", base);
545
- link.setAttribute("type", desiredIconType());
546
- }
593
+ // No badge to draw. With a custom icon active the page must carry it on
594
+ // EVERY link (a stale scheme-specific sibling outranks the one we set);
595
+ // with none, the shipped dark/light pair goes back untouched — the
596
+ // plugin promises zero side effects while it has no icon of its own.
597
+ if (iconState.active === true) setIconEverywhere(base, desiredIconType());
598
+ else restoreShippedIcons();
547
599
  faviconBase = null;
548
600
  return;
549
601
  }
@@ -566,8 +618,7 @@ window.__ModuleLoader__.load({
566
618
  const dh = ih * scale;
567
619
  c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
568
620
  drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
569
- link.setAttribute("href", canvas.toDataURL("image/png"));
570
- link.setAttribute("type", "image/png");
621
+ setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
571
622
  } catch (error) {
572
623
  // Never silent: a failure here is invisible in the UI (the tab simply
573
624
  // keeps its previous icon), which once cost a whole debugging session.
@@ -588,13 +639,16 @@ window.__ModuleLoader__.load({
588
639
  * composite was replaced and has to be rebuilt from the new base.
589
640
  */
590
641
  function reconcileFavicon() {
591
- const link = document.querySelector('link[rel="icon"]');
592
- const href = link === null ? null : link.getAttribute("href");
593
- const ours = typeof href === "string" && href.slice(0, 5) === "data:";
594
- const wanted = desiredIconHref();
642
+ // Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
643
+ // left stale by anything outside the plugin must be repaired too — that
644
+ // stale sibling is exactly what used to outrank the icon we set.
645
+ const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
646
+ const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
595
647
  if (effectiveCount() <= 0) {
596
- // No badge to draw, but the page must still carry the plugin's icon.
597
- if (ours || href !== wanted) applyBadgeToFavicon(0);
648
+ // Carries the custom icon, or puts the shipped pair back. Both writers
649
+ // only touch a link that actually differs, so this stays a no-op once
650
+ // the page is correct and cannot re-arm the head observer.
651
+ applyBadgeToFavicon(0);
598
652
  return;
599
653
  }
600
654
  if (ours || faviconComposing) return; // already showing, or a build is running
@@ -961,48 +1015,218 @@ window.__ModuleLoader__.load({
961
1015
  }
962
1016
 
963
1017
  /**
964
- * The sidebar-foot entry: a bell carrying the unread count, beside the
965
- * shipped Settings and Cordis buttons. Both that seat and the overlay list
966
- * are additive (`replaceRisk: none`) — a fresh id is added beside the others,
967
- * 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.
1038
+ */
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.
968
1099
  */
969
- function UnreadFooterButton(props) {
970
- const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
971
- const [open, setOpen] = React.useState(unreadPanelOpen);
972
- const ref = React.useRef(null);
973
- React.useEffect(() => subscribeBadge(setSnapshot), []);
974
- React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
975
- const t = props.t;
976
- const count = snapshot.count;
977
- const wide = props.wide !== false;
978
- const label = count > 0 ? t("unreadPanelCount").replace("{n}", String(count)) : t("unreadPanelNone");
979
- const onClick = () => {
980
- const node = ref.current;
981
- const rect = node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
982
- toggleUnreadPanel(rect === null ? null : { left: rect.left, top: rect.top });
983
- };
984
- return React.createElement("button", {
985
- ref, type: "button", onClick, title: label, "aria-label": label, "aria-expanded": open,
986
- style: {
987
- position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
988
- height: "32px", width: wide ? "auto" : "32px", padding: wide ? "0 10px" : "0",
989
- border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", background: "transparent",
990
- color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "12px",
991
- opacity: count > 0 ? 1 : 0.55
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 };
992
1117
  }
993
- },
994
- React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", style: { flex: "none" } },
995
- 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" }),
996
- React.createElement("path", { d: "M6.4 12.8a1.6 1.6 0 0 0 3.2 0", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
997
- ),
998
- wide ? React.createElement("span", null, t("unreadPanelShort")) : null,
999
- count > 0 ? React.createElement("span", {
1000
- "aria-hidden": "true",
1001
- 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" }
1002
- }, badgeLabel(count)) : null
1003
- );
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 {}
1004
1133
  }
1005
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
+ }
1006
1230
  /**
1007
1231
  * The list behind that number, in the frame-wide overlay layer so it escapes
1008
1232
  * the sidebar's clipping and scroll container. Clicking a row opens that
@@ -1169,30 +1393,18 @@ window.__ModuleLoader__.load({
1169
1393
  // visible in this very tab; other tabs pick it up on their next render.
1170
1394
  const applyLive = React.useCallback((s) => {
1171
1395
  if (!s || s.active !== true) return;
1172
- const link = document.querySelector('link[rel="icon"]');
1173
1396
  const href = s.png === true ? `/icon-custom-192.png?v=${s.rev}` : `/icon-custom.svg?v=${s.rev}`;
1174
1397
  const type = s.png === true ? "image/png" : (s.mime || "image/svg+xml");
1175
- if (link) {
1176
- link.setAttribute("href", href);
1177
- link.setAttribute("type", type);
1178
- } else {
1179
- const el = document.createElement("link");
1180
- el.rel = "icon";
1181
- el.type = type;
1182
- el.href = href;
1183
- document.head.appendChild(el);
1184
- }
1398
+ setIconEverywhere(href, type);
1185
1399
  // The base icon just changed: rebuild the composite from it.
1186
1400
  refreshFaviconBase();
1187
1401
  }, []);
1188
1402
 
1189
- // When cleared, point the icon back at the platform default.
1403
+ // When cleared, put the shipped dark/light pair back. Restoring the
1404
+ // authored hrefs (rather than forcing one href on every link) keeps the
1405
+ // platform's scheme-aware icons working after a reset.
1190
1406
  const applyDefault = React.useCallback(() => {
1191
- const link = document.querySelector('link[rel="icon"]');
1192
- if (link) {
1193
- link.setAttribute("href", "/favicon.svg");
1194
- link.setAttribute("type", "image/svg+xml");
1195
- }
1407
+ restoreShippedIcons();
1196
1408
  refreshFaviconBase();
1197
1409
  }, []);
1198
1410
 
@@ -1538,9 +1750,11 @@ window.__ModuleLoader__.load({
1538
1750
  inject: () => ({ t })
1539
1751
  }, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
1540
1752
 
1541
- // Additive seats for "where exactly?" — a foot entry beside the shipped
1542
- // Settings/Cordis buttons, and the frame-wide overlay for its list. A fresh
1543
- // 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.
1544
1758
  //
1545
1759
  // 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
1546
1760
  // retain/using/retainInfo/refreshProjections/search/fork/scope/binding — no
@@ -1551,10 +1765,9 @@ window.__ModuleLoader__.load({
1551
1765
  const openSession = (id) => {
1552
1766
  try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
1553
1767
  };
1554
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
1555
- { name: "sidebar.footer.action", id: "icon-custom-unread", order: 20, label: () => t("unreadPanelShort") },
1556
- (props) => React.createElement(UnreadFooterButton, { ...props, t, onOpen: openSession })
1557
- ));
1768
+ // The injected node labels itself for screen readers, so it needs the
1769
+ // locale binder that only exists inside `apply()`.
1770
+ badgeT = t;
1558
1771
  ctx.slots.inject("shell.overlay", () => ctx.slots.register(
1559
1772
  { name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
1560
1773
  () => React.createElement(UnreadPopup, { t, onOpen: openSession })
@@ -1617,7 +1830,13 @@ window.__ModuleLoader__.load({
1617
1830
  const schedule = () => {
1618
1831
  if (queued) return;
1619
1832
  queued = true;
1620
- 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);
1621
1840
  };
1622
1841
  const attach = () => {
1623
1842
  const container = workspaceDotContainer();
@@ -1644,6 +1863,7 @@ window.__ModuleLoader__.load({
1644
1863
  offDots();
1645
1864
  offConfig();
1646
1865
  clearWorkspaceDots();
1866
+ clearHeaderBadge();
1647
1867
  };
1648
1868
  }, "dsh-icon-custom: workspace dots");
1649
1869
  // The platform favicon.svg repaints itself for dark mode through
package/lib/index.js CHANGED
@@ -302,6 +302,20 @@ async function deriveSizes(source) {
302
302
  return out
303
303
  }
304
304
 
305
+ /**
306
+ * Insert markup immediately after the document's opening `<head …>` tag.
307
+ *
308
+ * Tolerates attributes on `<head>` (a bare `/<head>/` test silently missed those,
309
+ * which would drop the icon tag entirely). Returns the input unchanged when the
310
+ * document has no head to anchor to.
311
+ * @param html - full document source.
312
+ * @param markup - tag(s) to inject.
313
+ * @returns the rewritten document.
314
+ */
315
+ function insertAfterHead(html, markup) {
316
+ return html.replace(/<head[^>]*>/i, (open) => `${open}${markup}`)
317
+ }
318
+
305
319
  /**
306
320
  * 图标替换服务:保存/读取用户上传的 favicon,并通过 webServer 提供
307
321
  * `/icon-custom.svg` 路由与 index 头部改写。通过 typert
@@ -718,12 +732,16 @@ export class FaviconCustomService extends TypertRemoteService {
718
732
  : `/icon-custom.svg?v=${c.rev}`
719
733
  const favType = c.pngBaseline !== null ? 'image/png' : c.mime
720
734
  const tag = `<link rel="icon" type="${favType}" href="${favHref}" />`
721
- const re = /<link\s+rel="icon"[^>]*\/?>/i
722
- out = re.test(out) ? out.replace(re, tag) : out.replace(/<head>/i, `<head>${tag}`)
735
+ // DSH 0.2.x ships TWO `<link rel="icon">` tags — a dark and a light one, each
736
+ // behind a `media` query. Replacing only the first left the light one still
737
+ // serving the platform favicon, and the browser honours the LAST suitable
738
+ // candidate, so a custom icon silently failed to apply in light mode (the tab
739
+ // kept the whale) while looking fine in dark mode. Drop every icon link and
740
+ // insert one scheme-agnostic tag, which wins under either `prefers-color-scheme`.
741
+ out = insertAfterHead(out.replace(/<link\s+rel="icon"[^>]*\/?>/gi, ''), tag)
723
742
  if (c.pwa === true && c.pngBaseline !== null) {
724
743
  const tag2 = `<link rel="apple-touch-icon" href="/icon-custom-apple-180.png?v=${c.rev}" />`
725
- const re2 = /<link\s+rel="apple-touch-icon"[^>]*\/?>/i
726
- out = re2.test(out) ? out.replace(re2, tag2) : out.replace(/<head>/i, `<head>${tag2}`)
744
+ out = insertAfterHead(out.replace(/<link\s+rel="apple-touch-icon"[^>]*\/?>/gi, ''), tag2)
727
745
  }
728
746
  return out
729
747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.8.2",
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",