@iasiv5/dsh-quota-watch 0.1.11 → 0.1.13

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.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - **悬浮胶囊**(0.1.1 起为唯一浮动形态,圆球已退役):拖拽松手后磁性贴靠左右屏幕边缘、完整可见(距边 8px),记住停靠边与纵向位置,跨重启恢复;数据文本变宽、窗口缩放或转屏后自动重新贴边。拖拽跟手(保持抓取点,transform 渲染);触摸设备可直接拖拽(触摸阈值更宽防误拖)、长按 500ms 呼出菜单,点击热区扩展到 44px,自动避让 iPhone 刘海 / 小黑条(safe-area)。胶囊一行显示「GLM 13% · Copilot 24%」,已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时胶囊轻微脉动(尊重系统「减少动态效果」设置,开启时不脉动、贴边直落无动画);颜色使用 DSH 主题语义变量,自动适配浅色 / 深色主题。悬停提示与 aria-label 实时给出摘要。
9
+ - **悬浮胶囊**(0.1.1 起为唯一浮动形态,圆球已退役):常驻屏幕(默认右下角安全区),可自由拖拽到屏幕任意位置停放,位置跨重启记忆,越界自动钳回视口(距边 8px 安全边距)。拖拽跟手(保持抓取点,transform 渲染);触摸设备可直接拖拽(触摸阈值更宽防误拖)、长按 500ms 呼出菜单,点击热区扩展到 44px,自动避让 iPhone 刘海 / 小黑条(safe-area)。胶囊一行显示「GLM 13% · Copilot 24%」,已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时胶囊轻微脉动(尊重系统「减少动态效果」设置,开启时不脉动);颜色使用 DSH 主题语义变量,自动适配浅色 / 深色主题。悬停提示与 aria-label 实时给出摘要。
10
10
  - **面板**:点击胶囊展开浮动详情面板(半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area),头部带标题与 ✕,Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:GLM 为今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;Copilot 为可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。接口未下发有效 `remaining` / `percent_remaining` 时该行不渲染,`credits_used` 绝不用于推算余额。
11
11
  - **侧边栏卡片(可选,默认隐藏)**:胶囊是默认界面;需要侧栏卡片时在胶囊右键菜单开启「显示侧边栏卡片」(Web profile 侧栏底部区块存在时才可选)。卡片显示时提供摘要行 + 行点击开面板 + 行首 provider 名称点击立即探测。侧栏收起(Web 窄栏)或容器过窄(<160px,跨 profile 通用判据)时卡片自动隐藏,侧栏整体不存在(Desktop 收起态)同样自然缺席、无报错——入口始终由悬浮胶囊承载。
12
12
  - **右键菜单**:显示 / 隐藏侧边栏卡片 · 立即刷新。菜单项随上下文增减(无侧栏卡片挂载点时只剩刷新项)。
@@ -47,4 +47,4 @@ dsh plugin --profile web add @iasiv5/dsh-quota-watch
47
47
 
48
48
  ## English summary
49
49
 
50
- DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and the capsule magnetically docks to the nearest left/right screen edge after release — fully visible, with the docked edge and vertical offset remembered across restarts. Touch devices drag directly (wider slop against accidental drags), a 500ms long-press opens the context menu (iOS has no right-click), the hit area is padded to 44px and the dock respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. A sidebar card remains an opportunistic extra entry on web profiles, opt-in from the capsule's menu, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. The right-click menu (long-press on touch) offers card visibility and an immediate provider re-probe. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
50
+ DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and free to place anywhere on the screen, with position remembered across restarts and viewport boundaries automatically clamped. Touch devices drag directly (wider slop against accidental drags), a 500ms long-press opens the context menu (iOS has no right-click), the hit area is padded to 44px and placement respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. A sidebar card remains an opportunistic extra entry on web profiles, opt-in from the capsule's menu, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. The right-click menu (long-press on touch) offers card visibility and an immediate provider re-probe. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
package/lib/client.js CHANGED
@@ -82,7 +82,7 @@ function remainingToUsed(percentRemaining) {
82
82
  }
83
83
 
84
84
  // src/client/prefs.mjs
85
- var FLOAT_DOCK_KEY = "dsh-quota-watch:float-dock";
85
+ var FLOAT_POSITION_KEY = "dsh-quota-watch:float-position";
86
86
  var SURFACE_FLAGS_KEY = "dsh-quota-watch:surface-flags";
87
87
  var MARGIN = 8;
88
88
  var ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 };
@@ -107,26 +107,25 @@ function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
107
107
  y: Math.min(Math.max(point.y, minY), maxY)
108
108
  };
109
109
  }
110
- var DOCK_EDGES = /* @__PURE__ */ new Set(["left", "right"]);
111
- function loadFloatDock(storage) {
110
+ function loadFloatPosition(storage) {
112
111
  try {
113
- const raw = storage.getItem(FLOAT_DOCK_KEY);
112
+ const raw = storage.getItem(FLOAT_POSITION_KEY);
114
113
  if (typeof raw !== "string" || raw === "") return null;
115
114
  const parsed = JSON.parse(raw);
116
- if (typeof parsed !== "object" || parsed === null || !DOCK_EDGES.has(parsed.edge) || !Number.isFinite(parsed.offsetY)) {
115
+ if (typeof parsed !== "object" || parsed === null || !Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) {
117
116
  return null;
118
117
  }
119
- return { edge: parsed.edge, offsetY: parsed.offsetY };
118
+ return { x: parsed.x, y: parsed.y };
120
119
  } catch {
121
120
  return null;
122
121
  }
123
122
  }
124
- function saveFloatDock(storage, dock) {
125
- if (typeof dock !== "object" || dock === null || !DOCK_EDGES.has(dock.edge) || !Number.isFinite(dock.offsetY)) {
126
- throw new TypeError(`invalid float dock: ${JSON.stringify(dock)}`);
123
+ function saveFloatPosition(storage, point) {
124
+ if (typeof point !== "object" || point === null || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
125
+ throw new TypeError(`invalid float position: ${JSON.stringify(point)}`);
127
126
  }
128
127
  try {
129
- storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }));
128
+ storage.setItem(FLOAT_POSITION_KEY, JSON.stringify({ x: point.x, y: point.y }));
130
129
  } catch {
131
130
  }
132
131
  }
@@ -166,17 +165,6 @@ function pillTopLeft(point, grab) {
166
165
  function clampFrame(point, grab, size, viewport, insets) {
167
166
  return clampPoint(pillTopLeft(point, grab), viewport, size, insets);
168
167
  }
169
- function releaseDock(point, grab, size, viewport, insets) {
170
- const topLeft = pillTopLeft(point, grab);
171
- const centerX = topLeft.x + size.width / 2;
172
- const edge = centerX < viewport.width / 2 ? "left" : "right";
173
- return { edge, offsetY: clampPoint(topLeft, viewport, size, insets).y };
174
- }
175
- function dockX(edge, width, viewport, insets) {
176
- const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0;
177
- const insetRight = Number.isFinite(insets?.right) ? insets.right : 0;
178
- return edge === "left" ? MARGIN + insetLeft : viewport.width - insetRight - MARGIN - width;
179
- }
180
168
  function parseInset(computedValue) {
181
169
  const value = Number.parseFloat(computedValue);
182
170
  return Number.isFinite(value) ? value : 0;
@@ -296,11 +284,10 @@ var FLOAT_STYLE_TEXT = `
296
284
  .dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
297
285
  .dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
298
286
  .dqw-capsule[hidden] { display: none; }
299
- .dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); will-change: transform; transition: none; }
287
+ .dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); box-shadow: 0 8px 24px rgba(0,0,0,.35); transform: scale(1.04); transition: none; }
300
288
  @media (prefers-reduced-motion: no-preference) {
301
289
  .dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
302
290
  .dqw-capsule { transition: border-color .12s ease-out, box-shadow .12s ease-out; }
303
- .dqw-capsule--snapping { transition: transform 150ms cubic-bezier(.2,.8,.2,1); }
304
291
  }
305
292
  @media print { :host { display: none !important; } }
306
293
  @keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
@@ -544,19 +531,19 @@ function mountQuotaCard({
544
531
  const rect = surface.getBoundingClientRect();
545
532
  return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE;
546
533
  };
547
- const applyDock = () => {
534
+ const applyPosition = () => {
548
535
  const viewWidth = win.innerWidth ?? 1024;
549
536
  const viewHeight = win.innerHeight ?? 768;
550
537
  const viewport = { width: viewWidth, height: viewHeight };
551
538
  const size = measureSurface();
552
- const dock = loadFloatDock(win.localStorage) ?? {
553
- edge: "right",
554
- offsetY: viewHeight - MARGIN - size.height
555
- };
556
539
  const insets = readInsets();
557
- const top = clampPoint({ x: 0, y: dock.offsetY }, viewport, size, insets).y;
558
- floatHost.style.left = `${Math.round(dockX(dock.edge, size.width, viewport, insets))}px`;
559
- floatHost.style.top = `${Math.round(top)}px`;
540
+ const pos = loadFloatPosition(win.localStorage) ?? {
541
+ x: viewWidth - MARGIN - insets.right - size.width,
542
+ y: viewHeight - MARGIN - insets.bottom - size.height
543
+ };
544
+ const point = clampPoint(pos, viewport, size, insets);
545
+ floatHost.style.left = `${Math.round(point.x)}px`;
546
+ floatHost.style.top = `${Math.round(point.y)}px`;
560
547
  };
561
548
  const safeAreaProbe = text(doc, "div", "", "");
562
549
  safeAreaProbe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);";
@@ -574,7 +561,7 @@ function mountQuotaCard({
574
561
  return ZERO_INSETS2;
575
562
  }
576
563
  };
577
- applyDock();
564
+ applyPosition();
578
565
  floatRoot.append(panel);
579
566
  const menu = doc.createElement("div");
580
567
  menu.className = "dqw-menu";
@@ -654,17 +641,20 @@ function mountQuotaCard({
654
641
  if (!dragging || !lastPoint || disposed) return;
655
642
  const viewWidth = win.innerWidth ?? 1024;
656
643
  const viewHeight = win.innerHeight ?? 768;
657
- const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS2);
644
+ const insets = readInsets();
645
+ const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, insets);
658
646
  const dx = topLeft.x - restX;
659
647
  const dy = topLeft.y - restY;
660
- surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`;
648
+ floatHost.style.willChange = "transform";
649
+ floatHost.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`;
661
650
  });
662
651
  };
663
- const finishDragRelease = (dock) => {
664
- surfaceEl.classList.remove("dqw-capsule--dragging", "dqw-capsule--snapping");
665
- surfaceEl.style.transform = "";
666
- saveFloatDock(win.localStorage, dock);
667
- applyDock();
652
+ const finishDragRelease = (point) => {
653
+ surfaceEl.classList.remove("dqw-capsule--dragging");
654
+ floatHost.style.willChange = "";
655
+ floatHost.style.transform = "";
656
+ saveFloatPosition(win.localStorage, point);
657
+ applyPosition();
668
658
  suppressNextClick = true;
669
659
  };
670
660
  const onPointerUp = (event) => {
@@ -678,7 +668,7 @@ function mountQuotaCard({
678
668
  const viewHeight = win.innerHeight ?? 768;
679
669
  const insets = readInsets();
680
670
  const viewport = { width: viewWidth, height: viewHeight };
681
- const dock = releaseDock(
671
+ const point = clampFrame(
682
672
  { x: event.clientX, y: event.clientY },
683
673
  grab,
684
674
  dragSize,
@@ -686,26 +676,7 @@ function mountQuotaCard({
686
676
  insets
687
677
  );
688
678
  suppressNextClick = true;
689
- const motionAllowed = typeof win.matchMedia === "function" && win.matchMedia("(prefers-reduced-motion: no-preference)").matches;
690
- if (motionAllowed) {
691
- surfaceEl.classList.remove("dqw-capsule--dragging");
692
- surfaceEl.classList.add("dqw-capsule--snapping");
693
- const targetLeft = dockX(dock.edge, dragSize.width, viewport, insets);
694
- surfaceEl.style.transform = `translate3d(${Math.round(targetLeft - restX)}px, ${Math.round(dock.offsetY - restY)}px, 0) scale(1)`;
695
- let settled = false;
696
- let snapTimer;
697
- const settle = () => {
698
- if (settled || disposed) return;
699
- settled = true;
700
- surfaceEl.removeEventListener("transitionend", settle);
701
- win.clearTimeout(snapTimer);
702
- finishDragRelease(dock);
703
- };
704
- surfaceEl.addEventListener("transitionend", settle);
705
- snapTimer = win.setTimeout(settle, 200);
706
- } else {
707
- finishDragRelease(dock);
708
- }
679
+ finishDragRelease(point);
709
680
  };
710
681
  const onPointerCancel = () => {
711
682
  if (!dragging) return;
@@ -713,8 +684,9 @@ function mountQuotaCard({
713
684
  clearLongPress();
714
685
  unbindGesture();
715
686
  doc.body.style.userSelect = "";
716
- surfaceEl.classList.remove("dqw-capsule--dragging", "dqw-capsule--snapping");
717
- surfaceEl.style.transform = "";
687
+ surfaceEl.classList.remove("dqw-capsule--dragging");
688
+ floatHost.style.willChange = "";
689
+ floatHost.style.transform = "";
718
690
  };
719
691
  surfaceEl.addEventListener("pointerdown", onPointerDown);
720
692
  const disposeDrag2 = () => {
@@ -1016,7 +988,7 @@ function mountQuotaCard({
1016
988
  const label = summary === "" ? copy.title : locale === "zh" ? `${copy.title}\uFF1A${summary}` : `${copy.title}: ${summary}`;
1017
989
  surface.setAttribute("aria-label", label);
1018
990
  surface.title = label;
1019
- applyDock();
991
+ applyPosition();
1020
992
  };
1021
993
  const onSurfaceClick = () => {
1022
994
  if (suppressNextClick) {
@@ -1236,7 +1208,7 @@ function mountQuotaCard({
1236
1208
  const onWinResize = () => {
1237
1209
  placePanel();
1238
1210
  syncCardVisibility();
1239
- applyDock();
1211
+ applyPosition();
1240
1212
  };
1241
1213
  const onDocKeydown = (event) => {
1242
1214
  if (event.key !== "Escape") return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
@@ -1,8 +1,8 @@
1
- // DOM-free drag state machine for @iasiv5/dsh-quota-watch (ADR 0002).
1
+ // DOM-free drag state machine for @iasiv5/dsh-quota-watch.
2
2
  // Point/rect values come from the caller; nothing here touches the DOM, so the
3
3
  // math stays unit-testable under jsdom.
4
4
 
5
- import { MARGIN, clampPoint } from './prefs.mjs'
5
+ import { clampPoint } from './prefs.mjs'
6
6
 
7
7
  export const DRAG_SLOP_MOUSE = 6
8
8
  export const DRAG_SLOP_TOUCH = 10
@@ -22,26 +22,11 @@ function pillTopLeft(point, grab) {
22
22
  return { x: point.x - grab.dx, y: point.y - grab.dy }
23
23
  }
24
24
 
25
- /** Live clamp for the pill top-left while the gesture is active. */
25
+ /** Live clamp for the pill top-left while the gesture is active or upon release. */
26
26
  export function clampFrame(point, grab, size, viewport, insets) {
27
27
  return clampPoint(pillTopLeft(point, grab), viewport, size, insets)
28
28
  }
29
29
 
30
- /** Rest dock for a release: nearest horizontal edge by pill center, vertical offset clamped. */
31
- export function releaseDock(point, grab, size, viewport, insets) {
32
- const topLeft = pillTopLeft(point, grab)
33
- const centerX = topLeft.x + size.width / 2
34
- const edge = centerX < viewport.width / 2 ? 'left' : 'right'
35
- return { edge, offsetY: clampPoint(topLeft, viewport, size, insets).y }
36
- }
37
-
38
- /** Resting x for a docked pill: always derived, never stored. */
39
- export function dockX(edge, width, viewport, insets) {
40
- const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0
41
- const insetRight = Number.isFinite(insets?.right) ? insets.right : 0
42
- return edge === 'left' ? MARGIN + insetLeft : viewport.width - insetRight - MARGIN - width
43
- }
44
-
45
30
  /** Computed-style padding value ("20px" | "" | "auto") → numeric inset or 0. */
46
31
  export function parseInset(computedValue) {
47
32
  const value = Number.parseFloat(computedValue)
@@ -1,7 +1,7 @@
1
1
  // Float-shell preferences for @iasiv5/dsh-quota-watch.
2
2
  // Pure logic: no DOM. `storage` is a localStorage-like object (getItem/setItem).
3
3
 
4
- export const FLOAT_DOCK_KEY = 'dsh-quota-watch:float-dock'
4
+ export const FLOAT_POSITION_KEY = 'dsh-quota-watch:float-position'
5
5
  export const SURFACE_FLAGS_KEY = 'dsh-quota-watch:surface-flags'
6
6
 
7
7
  export const MARGIN = 8
@@ -31,40 +31,38 @@ export function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
31
31
  }
32
32
  }
33
33
 
34
- const DOCK_EDGES = new Set(['left', 'right'])
35
-
36
- export function loadFloatDock(storage) {
34
+ export function loadFloatPosition(storage) {
37
35
  try {
38
- const raw = storage.getItem(FLOAT_DOCK_KEY)
36
+ const raw = storage.getItem(FLOAT_POSITION_KEY)
39
37
  if (typeof raw !== 'string' || raw === '') return null
40
38
  const parsed = JSON.parse(raw)
41
39
  if (
42
40
  typeof parsed !== 'object' ||
43
41
  parsed === null ||
44
- !DOCK_EDGES.has(parsed.edge) ||
45
- !Number.isFinite(parsed.offsetY)
42
+ !Number.isFinite(parsed.x) ||
43
+ !Number.isFinite(parsed.y)
46
44
  ) {
47
45
  return null
48
46
  }
49
- return { edge: parsed.edge, offsetY: parsed.offsetY }
47
+ return { x: parsed.x, y: parsed.y }
50
48
  } catch {
51
49
  return null
52
50
  }
53
51
  }
54
52
 
55
- export function saveFloatDock(storage, dock) {
53
+ export function saveFloatPosition(storage, point) {
56
54
  if (
57
- typeof dock !== 'object' ||
58
- dock === null ||
59
- !DOCK_EDGES.has(dock.edge) ||
60
- !Number.isFinite(dock.offsetY)
55
+ typeof point !== 'object' ||
56
+ point === null ||
57
+ !Number.isFinite(point.x) ||
58
+ !Number.isFinite(point.y)
61
59
  ) {
62
- throw new TypeError(`invalid float dock: ${JSON.stringify(dock)}`)
60
+ throw new TypeError(`invalid float position: ${JSON.stringify(point)}`)
63
61
  }
64
62
  try {
65
- storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }))
63
+ storage.setItem(FLOAT_POSITION_KEY, JSON.stringify({ x: point.x, y: point.y }))
66
64
  } catch {
67
- /* storage unavailable; dock stays session-only */
65
+ /* storage unavailable; position stays session-only */
68
66
  }
69
67
  }
70
68
 
package/src/client.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
2
2
  import { remainingToUsed } from './core/adapters.mjs'
3
- import { MARGIN, clampPoint, loadFloatDock, loadSurfaceFlags, saveFloatDock, saveSurfaceFlags } from './client/prefs.mjs'
4
- import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, LONG_PRESS_MS, parseInset, clampFrame, dockX, dragSlop, grabOffset, releaseDock } from './client/drag.mjs'
3
+ import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosition, saveSurfaceFlags } from './client/prefs.mjs'
4
+ import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, LONG_PRESS_MS, parseInset, clampFrame, dragSlop, grabOffset } from './client/drag.mjs'
5
5
 
6
6
  export const name = 'quota-watch-client'
7
7
  export const inject = []
@@ -130,11 +130,10 @@ const FLOAT_STYLE_TEXT = `
130
130
  .dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
131
131
  .dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
132
132
  .dqw-capsule[hidden] { display: none; }
133
- .dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); will-change: transform; transition: none; }
133
+ .dqw-capsule--dragging { cursor: grabbing; -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); box-shadow: 0 8px 24px rgba(0,0,0,.35); transform: scale(1.04); transition: none; }
134
134
  @media (prefers-reduced-motion: no-preference) {
135
135
  .dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
136
136
  .dqw-capsule { transition: border-color .12s ease-out, box-shadow .12s ease-out; }
137
- .dqw-capsule--snapping { transition: transform 150ms cubic-bezier(.2,.8,.2,1); }
138
137
  }
139
138
  @media print { :host { display: none !important; } }
140
139
  @keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
@@ -442,23 +441,21 @@ export function mountQuotaCard({
442
441
  const rect = surface.getBoundingClientRect()
443
442
  return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
444
443
  }
445
- /** Rest position = docked edge + persisted offsetY; x is always derived (ADR 0002). */
446
- const applyDock = () => {
444
+ /** Position = user preferred {x, y} clamped to viewport & safe-area insets. */
445
+ const applyPosition = () => {
447
446
  const viewWidth = win.innerWidth ?? 1024
448
447
  const viewHeight = win.innerHeight ?? 768
449
448
  const viewport = { width: viewWidth, height: viewHeight }
450
449
  const size = measureSurface()
451
- const dock = loadFloatDock(win.localStorage) ?? {
452
- edge: 'right',
453
- offsetY: viewHeight - MARGIN - size.height,
454
- }
455
- // Deliberately NOT persisted here: a window that starts small (Electron
456
- // restore, split-screen) must not clobber the user's stored dock — storage
457
- // updates on drag release only; this is the visual clamp.
458
450
  const insets = readInsets()
459
- const top = clampPoint({ x: 0, y: dock.offsetY }, viewport, size, insets).y
460
- floatHost.style.left = `${Math.round(dockX(dock.edge, size.width, viewport, insets))}px`
461
- floatHost.style.top = `${Math.round(top)}px`
451
+ const pos = loadFloatPosition(win.localStorage) ?? {
452
+ x: viewWidth - MARGIN - insets.right - size.width,
453
+ y: viewHeight - MARGIN - insets.bottom - size.height,
454
+ }
455
+ // Clamped visually so a restored or shrunk window never lets the capsule fall outside.
456
+ const point = clampPoint(pos, viewport, size, insets)
457
+ floatHost.style.left = `${Math.round(point.x)}px`
458
+ floatHost.style.top = `${Math.round(point.y)}px`
462
459
  }
463
460
  // Safe-area probe: env() insets ride computed padding; jsdom reports '' which
464
461
  // parseInset normalizes to 0, so tests without a stub see zero insets.
@@ -480,7 +477,7 @@ export function mountQuotaCard({
480
477
  return ZERO_INSETS
481
478
  }
482
479
  }
483
- applyDock()
480
+ applyPosition()
484
481
  // Panel lives inside the float shadow root from now on (契约总表「归属时序」).
485
482
  floatRoot.append(panel)
486
483
  const menu = doc.createElement('div')
@@ -575,17 +572,20 @@ export function mountQuotaCard({
575
572
  if (!dragging || !lastPoint || disposed) return
576
573
  const viewWidth = win.innerWidth ?? 1024
577
574
  const viewHeight = win.innerHeight ?? 768
578
- const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS)
575
+ const insets = readInsets()
576
+ const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, insets)
579
577
  const dx = topLeft.x - restX
580
578
  const dy = topLeft.y - restY
581
- surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`
579
+ floatHost.style.willChange = 'transform'
580
+ floatHost.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`
582
581
  })
583
582
  }
584
- const finishDragRelease = (dock) => {
585
- surfaceEl.classList.remove('dqw-capsule--dragging', 'dqw-capsule--snapping')
586
- surfaceEl.style.transform = ''
587
- saveFloatDock(win.localStorage, dock)
588
- applyDock()
583
+ const finishDragRelease = (point) => {
584
+ surfaceEl.classList.remove('dqw-capsule--dragging')
585
+ floatHost.style.willChange = ''
586
+ floatHost.style.transform = ''
587
+ saveFloatPosition(win.localStorage, point)
588
+ applyPosition()
589
589
  suppressNextClick = true
590
590
  }
591
591
  const onPointerUp = (event) => {
@@ -599,53 +599,27 @@ export function mountQuotaCard({
599
599
  const viewHeight = win.innerHeight ?? 768
600
600
  const insets = readInsets()
601
601
  const viewport = { width: viewWidth, height: viewHeight }
602
- const dock = releaseDock(
602
+ const point = clampFrame(
603
603
  { x: event.clientX, y: event.clientY },
604
604
  grab,
605
605
  dragSize,
606
606
  viewport,
607
607
  insets,
608
608
  )
609
- // The real click lands within ms of pointerup — suppression is armed
610
- // synchronously here, never deferred to the animation's settle (E1-2).
611
609
  suppressNextClick = true
612
- // jsdom has no matchMedia: the typeof guard keeps the release working
613
- // (direct commit) where the motion query cannot be asked at all.
614
- const motionAllowed = typeof win.matchMedia === 'function'
615
- && win.matchMedia('(prefers-reduced-motion: no-preference)').matches
616
- if (motionAllowed) {
617
- surfaceEl.classList.remove('dqw-capsule--dragging')
618
- surfaceEl.classList.add('dqw-capsule--snapping')
619
- // Animate toward the NEW dock (delta from rest) — the host still sits
620
- // at the old dock until settle commits it, so (0,0) would slide the
621
- // pill backwards first and then teleport (E1-1).
622
- const targetLeft = dockX(dock.edge, dragSize.width, viewport, insets)
623
- surfaceEl.style.transform = `translate3d(${Math.round(targetLeft - restX)}px, ${Math.round(dock.offsetY - restY)}px, 0) scale(1)`
624
- let settled = false
625
- let snapTimer
626
- const settle = () => {
627
- if (settled || disposed) return
628
- settled = true
629
- surfaceEl.removeEventListener('transitionend', settle)
630
- win.clearTimeout(snapTimer)
631
- finishDragRelease(dock)
632
- }
633
- surfaceEl.addEventListener('transitionend', settle)
634
- snapTimer = win.setTimeout(settle, 200)
635
- } else {
636
- finishDragRelease(dock)
637
- }
610
+ finishDragRelease(point)
638
611
  }
639
612
  /** A canceled pointer (scroll takeover, incoming call, palm) resets the
640
- * gesture: no dock is persisted and the trailing click stays allowed. */
613
+ * gesture: no position is persisted and the trailing click stays allowed. */
641
614
  const onPointerCancel = () => {
642
615
  if (!dragging) return
643
616
  dragging = false
644
617
  clearLongPress()
645
618
  unbindGesture()
646
619
  doc.body.style.userSelect = ''
647
- surfaceEl.classList.remove('dqw-capsule--dragging', 'dqw-capsule--snapping')
648
- surfaceEl.style.transform = ''
620
+ surfaceEl.classList.remove('dqw-capsule--dragging')
621
+ floatHost.style.willChange = ''
622
+ floatHost.style.transform = ''
649
623
  }
650
624
  surfaceEl.addEventListener('pointerdown', onPointerDown)
651
625
  const disposeDrag = () => {
@@ -977,9 +951,7 @@ export function mountQuotaCard({
977
951
  const label = summary === '' ? copy.title : locale === 'zh' ? `${copy.title}:${summary}` : `${copy.title}: ${summary}`
978
952
  surface.setAttribute('aria-label', label)
979
953
  surface.title = label
980
- // Re-derive the docked x every render: the pill's width follows its text,
981
- // and the stored dock only knows the edge, not the pixel (ADR 0002).
982
- applyDock()
954
+ applyPosition()
983
955
  }
984
956
 
985
957
  const onSurfaceClick = () => {
@@ -1224,7 +1196,7 @@ export function mountQuotaCard({
1224
1196
  const onWinResize = () => {
1225
1197
  placePanel()
1226
1198
  syncCardVisibility()
1227
- applyDock()
1199
+ applyPosition()
1228
1200
  }
1229
1201
  const onDocKeydown = (event) => {
1230
1202
  if (event.key !== 'Escape') return