@cowwo/dsh-icon-custom 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh.md CHANGED
@@ -58,12 +58,13 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
58
58
  规则:
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
- - 正在看的那个会话、以及子代理会话一律不计。
62
- - **已读记在每个浏览器自己身上**(localStorage):打开过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
61
+ - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;要清掉它,切走再切回来即可。
62
+ - **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
63
+ - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过——中途切走、或它又跑完一轮,都清零重数,刷新页面也重新数。上限 600 秒。
63
64
  - 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
64
65
  - 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
65
66
  - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
66
- - **想知道具体是哪个会话?** 侧栏底部(设置按钮旁边)有一个**铃铛按钮**,上面带待处理数量;点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。侧栏折叠成竖条时只显示图标和数字。
67
+ - **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**。
67
68
 
68
69
  ## 支持的格式
69
70
 
package/client/client.js CHANGED
@@ -62,7 +62,10 @@ window.__ModuleLoader__.load({
62
62
  unreadPanelOther: "其他",
63
63
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
64
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
65
- badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。正在看的会话和子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
65
+ badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
66
+ clearDelayLabel: "进入会话后多久算已读:",
67
+ clearDelayUnit: "秒",
68
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
66
69
  };
67
70
  const en = {
68
71
  nav: "Favicon",
@@ -117,7 +120,10 @@ window.__ModuleLoader__.load({
117
120
  unreadPanelOther: "Other",
118
121
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
119
122
  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."
123
+ 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.",
124
+ clearDelayLabel: "Mark read after staying:",
125
+ clearDelayUnit: "seconds",
126
+ clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it, or a fresh ending in it, resets the clock, and so does a reload. Capped at 600 seconds."
121
127
  };
122
128
  //#endregion
123
129
 
@@ -231,8 +237,18 @@ window.__ModuleLoader__.load({
231
237
  };
232
238
  /** Mirror of the host defaults; only used before the first status arrives. */
233
239
  const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
234
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true };
240
+ /** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
241
+ const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
242
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, clearDelaySec: 0 };
235
243
  const unreadConfigListeners = new Set();
244
+ /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
245
+ function normalizeClearDelay(input) {
246
+ const value = typeof input === "string" && input.trim() !== "" ? Number(input) : input;
247
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
248
+ const seconds = Math.floor(value);
249
+ if (seconds <= 0) return 0;
250
+ return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC);
251
+ }
236
252
  function normalizeUnreadConfig(input) {
237
253
  const source = input !== null && typeof input === "object" ? input : {};
238
254
  const raw = source.reasons !== null && typeof source.reasons === "object" ? source.reasons : {};
@@ -243,7 +259,8 @@ window.__ModuleLoader__.load({
243
259
  return {
244
260
  reasons,
245
261
  pending: typeof source.pending === "boolean" ? source.pending : true,
246
- workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true
262
+ workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
263
+ clearDelaySec: normalizeClearDelay(source.clearDelaySec)
247
264
  };
248
265
  }
249
266
  function emitUnreadConfig(input) {
@@ -326,11 +343,41 @@ window.__ModuleLoader__.load({
326
343
  }
327
344
  return undefined;
328
345
  }
346
+ /**
347
+ * What the "stay here long enough" timer is armed for, or "" when nothing
348
+ * should be armed.
349
+ *
350
+ * Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
351
+ * collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
352
+ * which changes on every evaluation — keying on that would tear the timer
353
+ * down and re-arm it forever, and a waiting session could never be marked
354
+ * read. Including `endAt` is also what makes a NEW ending restart the clock
355
+ * instead of inheriting the previous one's progress.
356
+ * @param sessionId - the session being viewed.
357
+ * @param list - the session list snapshot.
358
+ * @returns the key, or "" when there is no session to arm for.
359
+ */
360
+ function stayKeyFor(sessionId, list) {
361
+ if (typeof sessionId !== "string" || sessionId === "") return "";
362
+ const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
363
+ const entry = byId[sessionId];
364
+ const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
365
+ const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
366
+ const endAt = value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
367
+ return `${sessionId}\u0000${endAt}`;
368
+ }
329
369
  /**
330
370
  * How many sessions deserve the badge right now: endings whose reason is
331
371
  * 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.
372
+ * for you (when that source is enabled). Only sub-agent sessions never count.
373
+ *
374
+ * The session you are LOOKING AT counts too. It used to be excluded, on the
375
+ * theory that "you have it open, so you have seen it" — but that premise is
376
+ * not knowable from here: the window may be minimised, the tab may be in the
377
+ * background, the page may be on another desktop. The browser half cannot
378
+ * tell "watching" from "left open", and a rule that cannot tell two cases
379
+ * apart should not pretend to: a session counts from the moment it ends until
380
+ * it is opened again. Leaving and re-entering it is what clears the entry.
334
381
  * @param list - the session list snapshot (may be absent).
335
382
  * @param pending - the pending-interaction map (may be absent).
336
383
  * @param config - the unread rule to apply.
@@ -344,7 +391,6 @@ window.__ModuleLoader__.load({
344
391
  /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
345
392
  const hits = [];
346
393
  const ids = new Set();
347
- const current = currentSessionId(list);
348
394
  const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
349
395
  const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
350
396
  // Which workspace the session lives in. The sidebar's own workspace rows are
@@ -363,7 +409,6 @@ window.__ModuleLoader__.load({
363
409
  const entry = byId[id];
364
410
  if (entry === null || typeof entry !== "object") continue;
365
411
  if (entry.origin === "subagent") continue;
366
- if (id === current) continue;
367
412
  const values = entry.projectionValues;
368
413
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
369
414
  if (value === null || typeof value !== "object") continue;
@@ -380,7 +425,6 @@ window.__ModuleLoader__.load({
380
425
  if (config === null || typeof config !== "object" || config.pending !== false) {
381
426
  if (pending !== null && pending !== undefined && typeof pending.forEach === "function") {
382
427
  pending.forEach((value, id) => {
383
- if (id === current) return;
384
428
  const kind = value !== null && typeof value === "object" && typeof value.kind === "string" ? value.kind : "";
385
429
  if (ids.has(id)) {
386
430
  // Already unread by its last turn: it is waiting too, which wins.
@@ -987,13 +1031,26 @@ window.__ModuleLoader__.load({
987
1031
  pruneSeenState();
988
1032
  saveSeenState();
989
1033
  }, [list]);
990
- // Looking at a session marks it seen; the count also excludes it outright.
991
1034
  const currentId = currentSessionId(list);
992
- React.useEffect(() => {
993
- if (noteSeen(currentId)) saveSeenState();
994
- }, [currentId]);
995
1035
  const items = collectUnread(list, pending, config, seenState.seen);
996
1036
  const count = items.length;
1037
+ // Being in a session is what marks it read — but only once you have STAYED
1038
+ // `clearDelaySec` seconds (0 = the moment you enter, the original
1039
+ // behaviour). The timer is torn down whenever you leave, or whenever that
1040
+ // session becomes unread again from a fresh ending, so the clock restarts;
1041
+ // that teardown IS the "continuous stay, leaving resets it" rule. A reload
1042
+ // restarts it too, since the timer only lives in this page.
1043
+ const currentUnread = items.some((item) => item.id === currentId);
1044
+ const stayKey = currentUnread ? stayKeyFor(currentId, list) : "";
1045
+ const delayMs = config.clearDelaySec * 1000;
1046
+ const [, bumpStay] = React.useState(0);
1047
+ React.useEffect(() => {
1048
+ if (stayKey === "") return undefined;
1049
+ const markRead = () => { if (noteSeen(currentId)) { saveSeenState(); bumpStay((n) => n + 1); } };
1050
+ if (delayMs <= 0) { markRead(); return undefined; }
1051
+ const timer = window.setTimeout(markRead, delayMs);
1052
+ return () => window.clearTimeout(timer);
1053
+ }, [stayKey, currentId, delayMs]);
997
1054
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
998
1055
  // The workspace rows are labelled with the workspace TITLE, which is not
999
1056
  // always the cwd basename, so map session → title from the official snapshot.
@@ -1369,6 +1426,11 @@ window.__ModuleLoader__.load({
1369
1426
  const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1370
1427
  const [unread, setUnread] = React.useState(unreadConfig);
1371
1428
  React.useEffect(() => subscribeUnreadConfig(setUnread), []);
1429
+ // Draft text for the delay box: typing must not save on every keystroke,
1430
+ // and the box has to show the CLAMPED value once the save lands (type
1431
+ // 99999 and it settles on the 600 cap instead of lying about it).
1432
+ const [clearDelayDraft, setClearDelayDraft] = React.useState(String(unreadConfig.clearDelaySec));
1433
+ React.useEffect(() => { setClearDelayDraft(String(unread.clearDelaySec)); }, [unread.clearDelaySec]);
1372
1434
  const inputRef = React.useRef(null);
1373
1435
 
1374
1436
  const load = React.useCallback(async () => {
@@ -1426,28 +1488,35 @@ window.__ModuleLoader__.load({
1426
1488
  setBadgeSourceValue(value);
1427
1489
  emitBadgeSource(value);
1428
1490
  }, []);
1429
- // Toggle one unread reason. Applies locally at once; the host keeps the
1430
- // durable copy so the rule survives a restart and other browsers.
1431
- const onUnreadToggle = React.useCallback((key, checked) => {
1432
- const current = unreadConfig;
1433
- const reasons = Object.assign({}, current.reasons);
1434
- let pending = current.pending;
1435
- if (key === "pending") pending = checked;
1436
- else reasons[key] = checked;
1437
- const next = normalizeUnreadConfig({ reasons, pending, workspaceDot: current.workspaceDot });
1491
+ // One save path for the whole unread rule: the patch is merged onto the
1492
+ // current config, so every field is carried over by construction. Letting
1493
+ // each control build its own request is how a new field gets silently
1494
+ // dropped by whichever caller forgot it.
1495
+ const saveUnreadRule = React.useCallback((patch) => {
1496
+ const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
1438
1497
  emitUnreadConfig(next);
1439
1498
  rpc.call("/api", "iconCustom/setUnreadRule", {
1440
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1499
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, clearDelaySec: next.clearDelaySec } }
1441
1500
  }).catch(() => {});
1442
1501
  }, [rpc]);
1502
+ // Toggle one unread reason. Applies locally at once; the host keeps the
1503
+ // durable copy so the rule survives a restart and other browsers.
1504
+ const onUnreadToggle = React.useCallback((key, checked) => {
1505
+ if (key === "pending") { saveUnreadRule({ pending: checked }); return; }
1506
+ const reasons = Object.assign({}, unreadConfig.reasons);
1507
+ reasons[key] = checked;
1508
+ saveUnreadRule({ reasons });
1509
+ }, [saveUnreadRule]);
1443
1510
  // The fuse for the one fragile piece (see the workspace-dots region).
1444
1511
  const onWorkspaceDotToggle = React.useCallback((checked) => {
1445
- const next = normalizeUnreadConfig({ reasons: unreadConfig.reasons, pending: unreadConfig.pending, workspaceDot: checked });
1446
- emitUnreadConfig(next);
1447
- rpc.call("/api", "iconCustom/setUnreadRule", {
1448
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1449
- }).catch(() => {});
1450
- }, [rpc]);
1512
+ saveUnreadRule({ workspaceDot: checked });
1513
+ }, [saveUnreadRule]);
1514
+ // Seconds you must stay in a session before it counts as read; 0 = as soon
1515
+ // as you enter. Clamped by the shared normalizer, so a stray keystroke can
1516
+ // never persist an absurd delay.
1517
+ const onClearDelayChange = React.useCallback((value) => {
1518
+ saveUnreadRule({ clearDelaySec: normalizeClearDelay(value) });
1519
+ }, [saveUnreadRule]);
1451
1520
  /** One unread checkbox; `key === "pending"` is the "someone is waiting" row. */
1452
1521
  const renderReasonCheckbox = (key, labelKey) => React.createElement("label", {
1453
1522
  key,
@@ -1683,6 +1752,22 @@ window.__ModuleLoader__.load({
1683
1752
  renderReasonCheckbox("pending", "reasonPending")
1684
1753
  )
1685
1754
  ),
1755
+ React.createElement("div", { style: { marginTop: "12px" } },
1756
+ React.createElement("div", { style: style.desc }, t("clearDelayLabel")),
1757
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)" } },
1758
+ React.createElement("input", {
1759
+ type: "number", min: 0, max: UNREAD_CLEAR_DELAY_MAX_SEC, step: 1, inputMode: "numeric",
1760
+ "aria-label": t("clearDelayLabel"),
1761
+ value: clearDelayDraft,
1762
+ onChange: (event) => setClearDelayDraft(event.target.value),
1763
+ onBlur: () => onClearDelayChange(clearDelayDraft),
1764
+ onKeyDown: (event) => { if (event.key === "Enter") onClearDelayChange(clearDelayDraft); },
1765
+ style: { width: "76px", padding: "3px 6px", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "6px", background: "transparent", color: "inherit", font: "inherit", fontSize: 12 }
1766
+ }),
1767
+ React.createElement("span", null, t("clearDelayUnit"))
1768
+ ),
1769
+ React.createElement("div", { style: style.hint }, t("clearDelayHint"))
1770
+ ),
1686
1771
  React.createElement("div", { style: { marginTop: "10px" } },
1687
1772
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
1688
1773
  React.createElement("input", {
package/lib/index.js CHANGED
@@ -462,14 +462,14 @@ export class FaviconCustomService extends TypertRemoteService {
462
462
  * @returns `{ reasons, pending }`.
463
463
  */
464
464
  async getUnreadRule() {
465
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
465
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
466
466
  }
467
467
 
468
468
  /**
469
469
  * typert endpoint: replace the unread-badge rule — which turn-end reasons
470
470
  * light the badge. The rule is applied on the client, so this takes effect
471
471
  * without re-folding any session log.
472
- * @param request - `{ reasons, pending? }`; unknown or missing keys fall back to defaults.
472
+ * @param request - `{ reasons, pending?, workspaceDot?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
473
473
  * @returns the stored rule.
474
474
  */
475
475
  async setUnreadRule(request) {
@@ -478,7 +478,7 @@ export class FaviconCustomService extends TypertRemoteService {
478
478
  }
479
479
  this.unread = normalizeUnreadConfig(request)
480
480
  await this._saveUnread().catch(() => {}) // rule file is advisory
481
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
481
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
482
482
  }
483
483
 
484
484
  /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
@@ -52,13 +52,16 @@ const unreadRuleSchema = z.object({
52
52
  reasons: z.record(z.string(), z.boolean()),
53
53
  pending: z.boolean(),
54
54
  /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
55
- workspaceDot: z.boolean()
55
+ workspaceDot: z.boolean(),
56
+ /** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
57
+ clearDelaySec: z.number().int().nonnegative().max(600)
56
58
  })
57
59
 
58
60
  const setUnreadRuleRequestSchema = z.object({
59
61
  reasons: z.record(z.string(), z.boolean()),
60
62
  pending: z.boolean().optional(),
61
- workspaceDot: z.boolean().optional()
63
+ workspaceDot: z.boolean().optional(),
64
+ clearDelaySec: z.number().int().nonnegative().max(600).optional()
62
65
  })
63
66
 
64
67
  const setIconResultSchema = statusSchema
@@ -269,7 +272,7 @@ export const TYPERT = {
269
272
  {
270
273
  kind: 'method',
271
274
  name: 'getUnreadRule',
272
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
275
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
273
276
  description: "Read the unread-badge rule.",
274
277
  summary: "Read the unread-badge rule.",
275
278
  jsDoc: "/** Read the unread-badge rule. */",
@@ -278,7 +281,7 @@ export const TYPERT = {
278
281
  {
279
282
  kind: 'method',
280
283
  name: 'setUnreadRule',
281
- signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
284
+ signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
282
285
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
283
286
  summary: "Set the unread-badge rule.",
284
287
  jsDoc: "/** Set the unread-badge rule. */",
package/lib/unread.js CHANGED
@@ -52,6 +52,29 @@ export const UNREAD_PENDING_DEFAULT = true
52
52
  */
53
53
  export const UNREAD_WORKSPACE_DOT_DEFAULT = true
54
54
 
55
+ /**
56
+ * 默认:进入会话即视为已读(0 秒)。
57
+ * 大于 0 时,你必须在那个会话里**连续**待满这么多秒它才算"看过";中途切走会
58
+ * 清零重数。填 0 就是"不设置",行为跟没有这一项完全一样。
59
+ */
60
+ export const UNREAD_CLEAR_DELAY_DEFAULT = 0
61
+
62
+ /**
63
+ * 秒数上限:10 分钟。
64
+ * 挡住误填的大数(想填 5 却打成 50000)——那会让红点实际上永远清不掉,
65
+ * 而症状是"点进去也不消",很难联想到是这里填错了。
66
+ */
67
+ export const UNREAD_CLEAR_DELAY_MAX_SEC = 600
68
+
69
+ /** 把任意输入夹成 `0..上限` 的整数秒(小数向下取整,脏值回落默认)。 */
70
+ export function normalizeClearDelay(input) {
71
+ const value = typeof input === 'string' && input.trim() !== '' ? Number(input) : input
72
+ if (typeof value !== 'number' || !Number.isFinite(value)) return UNREAD_CLEAR_DELAY_DEFAULT
73
+ const seconds = Math.floor(value)
74
+ if (seconds <= 0) return 0
75
+ return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC)
76
+ }
77
+
55
78
  /** 把任意输入夹成一张完整、只有布尔值的开关表(缺项取默认)。 */
56
79
  export function normalizeReasons(input) {
57
80
  const out = {}
@@ -64,15 +87,16 @@ export function normalizeReasons(input) {
64
87
 
65
88
  /**
66
89
  * 把任意输入夹成完整设置。
67
- * @param input - `{ reasons?, pending? }`,可以是脏的、缺项的、null
68
- * @returns `{ reasons, pending }` —— 两张表都完整
90
+ * @param input - `{ reasons?, pending?, workspaceDot?, clearDelaySec? }`,可以是脏的、缺项的、null
91
+ * @returns `{ reasons, pending, workspaceDot, clearDelaySec }` —— 每一项都完整
69
92
  */
70
93
  export function normalizeUnreadConfig(input) {
71
94
  const source = input !== null && typeof input === 'object' ? input : {}
72
95
  return {
73
96
  reasons: normalizeReasons(source.reasons),
74
97
  pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
75
- workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT
98
+ workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
99
+ clearDelaySec: normalizeClearDelay(source.clearDelaySec)
76
100
  }
77
101
  }
78
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
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",