dsh-focus-overlay 0.9.0 → 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.
Files changed (2) hide show
  1. package/lib/client.js +108 -13
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -314,6 +314,41 @@ window.__ModuleLoader__.load({
314
314
  if (distance >= 160) return false;
315
315
  return prev;
316
316
  }
317
+ /**
318
+ * Index of the conversation-turn dot the nav rail should highlight, from live
319
+ * scroll geometry. The rail is a position indicator, so the decision is pure
320
+ * math over measured anchor tops (document order) plus the distance to the
321
+ * scroll end:
322
+ *
323
+ * 1. Bottom force: within {@link BOTTOM_ENTER_PX} of the scroll end the last
324
+ * turn owns the highlight, whatever its height. Without this rule a short
325
+ * final turn could never light its own dot — with less than a viewport of
326
+ * content below its anchor, that anchor physically cannot reach the top
327
+ * threshold even pinned at the very bottom, so the highlight would stick
328
+ * to an earlier turn forever.
329
+ * 2. Geometric scan: the last anchor whose top sits at/above the
330
+ * {@link NAV_TOP_PX} line — "which turn is at the top of the viewport".
331
+ * Non-finite entries (a stale anchor slot after the item list shifted) are
332
+ * skipped without stopping the scan, matching the old ref-null behavior.
333
+ * 3. Top clamp: every real anchor still below the line means the reader is
334
+ * above the first message's threshold (scrollTop ≈ 0 with leading
335
+ * padding), and the first turn owns the position.
336
+ *
337
+ * A bottom-forced index may land on a stale slot; the caller maps it back to
338
+ * the nearest existing anchor.
339
+ */
340
+ function activeNavIndex(tops, bottomDistance, topPx = 96, bottomPx = 48) {
341
+ if (!tops.length) return -1;
342
+ if (bottomDistance <= bottomPx) return tops.length - 1;
343
+ let cur = -1;
344
+ for (let i = 0; i < tops.length; i++) {
345
+ const top = tops[i];
346
+ if (!Number.isFinite(top)) continue;
347
+ if (top <= topPx) cur = i;
348
+ else break;
349
+ }
350
+ return cur >= 0 ? cur : 0;
351
+ }
317
352
  /** A question is answered when at least one option is selected or the custom
318
353
  * text is non-blank — the same completeness rule the official question
319
354
  * composer applies before enabling submit. */
@@ -1000,20 +1035,19 @@ window.__ModuleLoader__.load({
1000
1035
  const focusBarOnce = (0, react.useRef)(false);
1001
1036
  const computeActive = () => {
1002
1037
  if (!bodyEl) return null;
1003
- let cur = null;
1004
- for (const k of navKeys) {
1038
+ const bodyTop = bodyEl.getBoundingClientRect().top;
1039
+ const i = activeNavIndex(navKeys.map((k) => {
1005
1040
  const el = anchors[k];
1006
- if (!el) continue;
1007
- if (el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top <= 96) cur = k;
1008
- else break;
1009
- }
1010
- return cur;
1041
+ return el ? el.getBoundingClientRect().top - bodyTop : Number.POSITIVE_INFINITY;
1042
+ }), bottomDistance());
1043
+ for (let j = i; j >= 0; j--) if (anchors[navKeys[j]]) return navKeys[j];
1044
+ return null;
1011
1045
  };
1012
1046
  const bottomDistance = () => {
1013
1047
  if (!bodyEl) return Number.POSITIVE_INFINITY;
1014
1048
  return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight;
1015
1049
  };
1016
- const handleScroll = () => {
1050
+ const scheduleRecompute = () => {
1017
1051
  if (rafId.current !== null) return;
1018
1052
  rafId.current = requestAnimationFrame(() => {
1019
1053
  rafId.current = null;
@@ -1021,6 +1055,10 @@ window.__ModuleLoader__.load({
1021
1055
  setZone((prev) => bottomZoneAfter(prev, bottomDistance()));
1022
1056
  });
1023
1057
  };
1058
+ const scheduleRef = (0, react.useRef)(scheduleRecompute);
1059
+ (0, react.useEffect)(() => {
1060
+ scheduleRef.current = scheduleRecompute;
1061
+ });
1024
1062
  const scrollToKey = (key, smooth) => {
1025
1063
  const el = anchors[key];
1026
1064
  if (!el || !bodyEl) return;
@@ -1081,6 +1119,22 @@ window.__ModuleLoader__.load({
1081
1119
  (0, react.useEffect)(() => {
1082
1120
  setZone(bodyEl ? bottomZoneAfter(false, bottomDistance()) : true);
1083
1121
  }, []);
1122
+ (0, react.useEffect)(() => {
1123
+ const el = bodyEl;
1124
+ if (!el || typeof ResizeObserver === "undefined") return;
1125
+ const ro = new ResizeObserver(() => scheduleRef.current());
1126
+ ro.observe(el);
1127
+ return () => ro.disconnect();
1128
+ }, []);
1129
+ (0, react.useEffect)(() => {
1130
+ scheduleRef.current();
1131
+ }, [snap, prefs.width]);
1132
+ (0, react.useEffect)(() => () => {
1133
+ if (rafId.current !== null) {
1134
+ cancelAnimationFrame(rafId.current);
1135
+ rafId.current = null;
1136
+ }
1137
+ }, []);
1084
1138
  (0, react.useEffect)(() => {
1085
1139
  overlayRef.current?.focus();
1086
1140
  }, []);
@@ -1272,7 +1326,23 @@ window.__ModuleLoader__.load({
1272
1326
  }, "fm-partial"));
1273
1327
  if (running) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1274
1328
  className: "fm-running",
1275
- children: t("running")
1329
+ role: "status",
1330
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1331
+ className: "fm-running-text",
1332
+ children: [
1333
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1334
+ className: "fm-running-dash",
1335
+ "aria-hidden": "true",
1336
+ children: "–"
1337
+ }),
1338
+ t("running"),
1339
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1340
+ className: "fm-running-dash",
1341
+ "aria-hidden": "true",
1342
+ children: "–"
1343
+ })
1344
+ ]
1345
+ })
1276
1346
  }, "fm-running"));
1277
1347
  body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1278
1348
  className: "fm-inner",
@@ -1371,7 +1441,7 @@ window.__ModuleLoader__.load({
1371
1441
  ref: (el) => {
1372
1442
  bodyEl = el;
1373
1443
  },
1374
- onScroll: handleScroll,
1444
+ onScroll: scheduleRecompute,
1375
1445
  children: body
1376
1446
  }),
1377
1447
  nav,
@@ -1651,7 +1721,32 @@ window.__ModuleLoader__.load({
1651
1721
  .fm-image{max-width:100%;border-radius:8px;margin:8px 0}
1652
1722
  .fm-hidden{text-align:center;color:var(--dsw-alias-label-secondary,#888);font-size:12px;line-height:1.6;margin:6px 0 18px;user-select:none}
1653
1723
  .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
1654
- .fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
1724
+ /* "正在工作" live line — the shimmer is a 1:1 replica of the official
1725
+ generating indicator in dsh-client-ui-conversation (the "Deep Diving"
1726
+ turn-status): brand-blue text (--dsw-static-deepseek-500) with a light
1727
+ highlight (--dsw-static-deepseek-200) sweeping through — gradient stops
1728
+ 0/40/50/60/100 on a 250% tile, background-position 100%→0, 1.8s linear,
1729
+ font --dsw-font-s-strong-14 (500 14px/22px). The official class name
1730
+ (Md3f7G_turnStatus) is a per-build CSS-modules hash, so reusing it would
1731
+ break silently on dsh updates — the declarations are copied verbatim onto
1732
+ our own class using the same theme tokens instead. The flanking short
1733
+ dashes are part of the same shimmer span, so the sweep covers dashes and
1734
+ label as one unit. Fallbacks: no background-clip:text → secondary gray
1735
+ label; prefers-reduced-motion → animation off, label stays solid
1736
+ deepseek-500 (the official base color). */
1737
+ .fm-running{text-align:center;margin:10px 0 20px;user-select:none;animation:fm-up .18s ease}
1738
+ .fm-running-text{color:var(--dsw-alias-label-secondary,#888);font:var(--dsw-font-s-strong-14,500 14px/22px var(--dsw-font-family,sans-serif));white-space:nowrap}
1739
+ /* Dashes: separate spans with fixed margins instead of space characters — in
1740
+ CJK/Latin mixed runs the browser itemizes the two U+0020 spaces into
1741
+ different fonts (one Latin-narrow, one Han-wide), and the Han font's en
1742
+ dash glyph is left-biased, which made the two gaps visually unequal. The
1743
+ forced Latin-first stack renders a symmetric en dash; margins are exact. */
1744
+ .fm-running-dash{font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;margin:0 8px}
1745
+ @supports ((-webkit-background-clip:text) or (background-clip:text)){
1746
+ .fm-running-text{background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4176e6) 0%,var(--dsw-static-deepseek-500,#4176e6) 40%,var(--dsw-static-deepseek-200,#d3e2ff) 50%,var(--dsw-static-deepseek-500,#4176e6) 60%,var(--dsw-static-deepseek-500,#4176e6) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;animation:fm-sweep 1.8s linear infinite}
1747
+ }
1748
+ @keyframes fm-sweep{to{background-position:0 0}}
1749
+ @media (prefers-reduced-motion:reduce){.fm-running{animation:none}.fm-running-text{background:0 0;color:var(--dsw-static-deepseek-500,#4176e6);-webkit-text-fill-color:currentColor;animation:none}}
1655
1750
  .fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
1656
1751
  .fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:block;width:16px;z-index:2;pointer-events:auto;transition:transform .18s ease}
1657
1752
  .fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
@@ -1796,7 +1891,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1796
1891
  "settings.width.hint": "专注模式正文的阅读列宽(480–1200px)",
1797
1892
  "empty": "暂无打开的会话",
1798
1893
  "loading": "正在读取会话…",
1799
- "running": "· 正在工作",
1894
+ "running": "正在工作",
1800
1895
  "reply.ready": "新回复已生成",
1801
1896
  "reply.view": "查看",
1802
1897
  "reply.waiting": "AI 正在等待你的回复",
@@ -1865,7 +1960,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1865
1960
  "settings.width.hint": "Reading column width (480–1200px)",
1866
1961
  "empty": "No open session",
1867
1962
  "loading": "Reading session…",
1868
- "running": "· Working",
1963
+ "running": "Working",
1869
1964
  "reply.ready": "New reply ready",
1870
1965
  "reply.view": "View",
1871
1966
  "reply.waiting": "AI is waiting for your reply",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-focus-overlay",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Focus Mode for the dsh web GUI: a full-screen reading overlay that hides chrome and folds the AI tool-call flow into summaries, reusing the official Markdown / image primitives.",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",