@iasiv5/dsh-quota-watch 0.1.9 → 0.1.11

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,11 +6,11 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - **悬浮胶囊**(0.1.1 起为唯一浮动形态,圆球已退役):常驻屏幕右下角(距边 16px 起步),可整体拖拽到任意位置,位置跨重启记忆,越界自动钳回视口。胶囊一行显示「GLM 13% · Copilot 24%」,已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时胶囊轻微脉动(尊重系统「减少动态效果」设置,开启时不脉动);颜色使用 DSH 主题语义变量,自动适配浅色 / 深色主题。悬停提示与 aria-label 实时给出摘要。
10
- - **面板**:点击球或胶囊展开浮动详情面板(半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随),头部带标题与 ✕,Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:GLM 为今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;Copilot 为可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。接口未下发有效 `remaining` / `percent_remaining` 时该行不渲染,`credits_used` 绝不用于推算余额。
9
+ - **悬浮胶囊**(0.1.1 起为唯一浮动形态,圆球已退役):拖拽松手后磁性贴靠左右屏幕边缘、完整可见(距边 8px),记住停靠边与纵向位置,跨重启恢复;数据文本变宽、窗口缩放或转屏后自动重新贴边。拖拽跟手(保持抓取点,transform 渲染);触摸设备可直接拖拽(触摸阈值更宽防误拖)、长按 500ms 呼出菜单,点击热区扩展到 44px,自动避让 iPhone 刘海 / 小黑条(safe-area)。胶囊一行显示「GLM 13% · Copilot 24%」,已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时胶囊轻微脉动(尊重系统「减少动态效果」设置,开启时不脉动、贴边直落无动画);颜色使用 DSH 主题语义变量,自动适配浅色 / 深色主题。悬停提示与 aria-label 实时给出摘要。
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
  - **右键菜单**:显示 / 隐藏侧边栏卡片 · 立即刷新。菜单项随上下文增减(无侧栏卡片挂载点时只剩刷新项)。
13
- - 凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠ 只出现在面板与卡片行内,不上球)。
13
+ - 凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠ 只出现在面板与卡片行内,不在胶囊上显示)。
14
14
  - 宿主默认每 60 秒探测(窗口 + 用量 + Copilot);profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停。
15
15
 
16
16
  ## 兼容与配置
@@ -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%, draggable anywhere with position remembered across restarts. 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. A sidebar card remains an opportunistic extra entry on web profiles (with a corner button to hide or restore the capsule) and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. A right-click menu offers card visibility, an immediate provider re-probe and a session-scoped capsule hide. 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 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).
package/lib/client.js CHANGED
@@ -82,35 +82,51 @@ function remainingToUsed(percentRemaining) {
82
82
  }
83
83
 
84
84
  // src/client/prefs.mjs
85
- var FLOAT_GEOMETRY_KEY = "dsh-quota-watch:float-geometry";
85
+ var FLOAT_DOCK_KEY = "dsh-quota-watch:float-dock";
86
86
  var SURFACE_FLAGS_KEY = "dsh-quota-watch:surface-flags";
87
87
  var MARGIN = 8;
88
- function clampPoint(point, viewport, size = 0) {
89
- const minX = MARGIN;
90
- const minY = MARGIN;
91
- const maxX = Math.max(MARGIN, viewport.width - MARGIN - size);
92
- const maxY = Math.max(MARGIN, viewport.height - MARGIN - size);
88
+ var ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 };
89
+ function normalizeSize(size) {
90
+ if (typeof size === "number") return { width: size, height: size };
91
+ const width = typeof size?.width === "number" ? size.width : 0;
92
+ const height = typeof size?.height === "number" ? size.height : 0;
93
+ return { width, height };
94
+ }
95
+ function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
96
+ const { width, height } = normalizeSize(size);
97
+ const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0;
98
+ const insetRight = Number.isFinite(insets?.right) ? insets.right : 0;
99
+ const insetTop = Number.isFinite(insets?.top) ? insets.top : 0;
100
+ const insetBottom = Number.isFinite(insets?.bottom) ? insets.bottom : 0;
101
+ const minX = MARGIN + insetLeft;
102
+ const minY = MARGIN + insetTop;
103
+ const maxX = Math.max(minX, viewport.width - insetRight - MARGIN - width);
104
+ const maxY = Math.max(minY, viewport.height - insetBottom - MARGIN - height);
93
105
  return {
94
106
  x: Math.min(Math.max(point.x, minX), maxX),
95
107
  y: Math.min(Math.max(point.y, minY), maxY)
96
108
  };
97
109
  }
98
- function loadFloatGeometry(storage) {
110
+ var DOCK_EDGES = /* @__PURE__ */ new Set(["left", "right"]);
111
+ function loadFloatDock(storage) {
99
112
  try {
100
- const raw = storage.getItem(FLOAT_GEOMETRY_KEY);
113
+ const raw = storage.getItem(FLOAT_DOCK_KEY);
101
114
  if (typeof raw !== "string" || raw === "") return null;
102
115
  const parsed = JSON.parse(raw);
103
- if (typeof parsed !== "object" || parsed === null || !Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) {
116
+ if (typeof parsed !== "object" || parsed === null || !DOCK_EDGES.has(parsed.edge) || !Number.isFinite(parsed.offsetY)) {
104
117
  return null;
105
118
  }
106
- return { x: parsed.x, y: parsed.y };
119
+ return { edge: parsed.edge, offsetY: parsed.offsetY };
107
120
  } catch {
108
121
  return null;
109
122
  }
110
123
  }
111
- function saveFloatGeometry(storage, point) {
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)}`);
127
+ }
112
128
  try {
113
- storage.setItem(FLOAT_GEOMETRY_KEY, JSON.stringify({ x: point.x, y: point.y }));
129
+ storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }));
114
130
  } catch {
115
131
  }
116
132
  }
@@ -134,14 +150,46 @@ function saveSurfaceFlags(storage, flags) {
134
150
  }
135
151
  }
136
152
 
153
+ // src/client/drag.mjs
154
+ var DRAG_SLOP_MOUSE = 6;
155
+ var DRAG_SLOP_TOUCH = 10;
156
+ var LONG_PRESS_MS = 500;
157
+ function dragSlop(pointerType) {
158
+ return pointerType === "touch" || pointerType === "pen" ? DRAG_SLOP_TOUCH : DRAG_SLOP_MOUSE;
159
+ }
160
+ function grabOffset(point, rect) {
161
+ return { dx: point.x - rect.left, dy: point.y - rect.top };
162
+ }
163
+ function pillTopLeft(point, grab) {
164
+ return { x: point.x - grab.dx, y: point.y - grab.dy };
165
+ }
166
+ function clampFrame(point, grab, size, viewport, insets) {
167
+ return clampPoint(pillTopLeft(point, grab), viewport, size, insets);
168
+ }
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
+ function parseInset(computedValue) {
181
+ const value = Number.parseFloat(computedValue);
182
+ return Number.isFinite(value) ? value : 0;
183
+ }
184
+
137
185
  // src/client.mjs
138
186
  var name = "quota-watch-client";
139
187
  var inject = [];
140
188
  var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
141
189
  var FLOAT_SELECTOR = "[data-dsh-quota-watch-float]";
142
190
  var FETCH_TIMEOUT_MS = 15e3;
143
- var SURFACE_SIZE = 38;
144
- var SURFACE_MARGIN = 16;
191
+ var FALLBACK_SIZE = { width: 38, height: 26 };
192
+ var ZERO_INSETS2 = { left: 0, right: 0, top: 0, bottom: 0 };
145
193
  var CARD_MIN_WIDTH = 160;
146
194
  var COPY = {
147
195
  zh: {
@@ -238,16 +286,21 @@ var STYLE_TEXT = `
238
286
  `;
239
287
  var FLOAT_STYLE_TEXT = `
240
288
  :host { position: fixed; z-index: 2147483000; }
241
- .dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: pointer; user-select: none; white-space: nowrap; }
242
- .dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); }
289
+ .dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: grab; user-select: none; white-space: nowrap; position: relative; touch-action: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
290
+ .dqw-capsule::before { content: ''; position: absolute; inset: -9px; }
291
+ .dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); box-shadow: 0 6px 18px rgba(0,0,0,.28); }
292
+ .dqw-capsule:active { transform: scale(.98); }
243
293
  .dqw-capsule:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #5b8def); outline-offset: 2px; }
244
294
  .dqw-capsule-sep { opacity: .5; }
245
295
  .dqw-capsule-glm, .dqw-capsule-copilot { font-variant-numeric: tabular-nums; font-weight: 600; }
246
296
  .dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
247
297
  .dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
248
298
  .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; }
249
300
  @media (prefers-reduced-motion: no-preference) {
250
301
  .dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
302
+ .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); }
251
304
  }
252
305
  @media print { :host { display: none !important; } }
253
306
  @keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
@@ -257,6 +310,7 @@ var FLOAT_STYLE_TEXT = `
257
310
  .dqw-menu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
258
311
  .dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
259
312
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
313
+ .dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
260
314
  .dqw-panel[hidden] { display: none; }
261
315
  .dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
262
316
  .dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
@@ -276,7 +330,7 @@ var FLOAT_STYLE_TEXT = `
276
330
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
277
331
  .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
278
332
  .dqw-label[data-action="refresh"]:hover { opacity: .75; }
279
- .dqw-bar { display: block; flex: 0 0 auto; width: 120px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
333
+ .dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
280
334
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
281
335
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
282
336
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
@@ -486,19 +540,41 @@ function mountQuotaCard({
486
540
  floatHost.hidden = dataHidden;
487
541
  };
488
542
  doc.body.append(floatHost);
489
- const applyFloatGeometry = () => {
543
+ const measureSurface = () => {
544
+ const rect = surface.getBoundingClientRect();
545
+ return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE;
546
+ };
547
+ const applyDock = () => {
490
548
  const viewWidth = win.innerWidth ?? 1024;
491
549
  const viewHeight = win.innerHeight ?? 768;
492
- const saved = loadFloatGeometry(win.localStorage);
493
- const point = clampPoint(
494
- saved ?? { x: viewWidth - SURFACE_MARGIN - SURFACE_SIZE, y: viewHeight - SURFACE_MARGIN - SURFACE_SIZE },
495
- { width: viewWidth, height: viewHeight },
496
- SURFACE_SIZE
497
- );
498
- floatHost.style.left = `${Math.round(point.x)}px`;
499
- floatHost.style.top = `${Math.round(point.y)}px`;
550
+ const viewport = { width: viewWidth, height: viewHeight };
551
+ const size = measureSurface();
552
+ const dock = loadFloatDock(win.localStorage) ?? {
553
+ edge: "right",
554
+ offsetY: viewHeight - MARGIN - size.height
555
+ };
556
+ 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`;
560
+ };
561
+ const safeAreaProbe = text(doc, "div", "", "");
562
+ 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);";
563
+ floatRoot.append(safeAreaProbe);
564
+ const readInsets = () => {
565
+ try {
566
+ const style2 = win.getComputedStyle(safeAreaProbe);
567
+ return {
568
+ top: parseInset(style2?.paddingTop),
569
+ right: parseInset(style2?.paddingRight),
570
+ bottom: parseInset(style2?.paddingBottom),
571
+ left: parseInset(style2?.paddingLeft)
572
+ };
573
+ } catch {
574
+ return ZERO_INSETS2;
575
+ }
500
576
  };
501
- applyFloatGeometry();
577
+ applyDock();
502
578
  floatRoot.append(panel);
503
579
  const menu = doc.createElement("div");
504
580
  menu.className = "dqw-menu";
@@ -507,52 +583,145 @@ function mountQuotaCard({
507
583
  menu.hidden = true;
508
584
  floatRoot.append(menu);
509
585
  let suppressNextClick = false;
586
+ const scheduleFrame = (fn) => typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame.call(win, fn) : fn();
510
587
  const attachDrag = (surfaceEl) => {
511
588
  let dragging = false;
512
589
  let moved = false;
513
590
  let originX = 0;
514
591
  let originY = 0;
592
+ let slop = DRAG_SLOP_MOUSE;
593
+ let grab = { dx: 0, dy: 0 };
594
+ let dragSize = FALLBACK_SIZE;
595
+ let restX = 0;
596
+ let restY = 0;
597
+ let lastPoint = null;
598
+ let longPressTimer;
599
+ const clearLongPress = () => {
600
+ if (longPressTimer !== void 0) {
601
+ win.clearTimeout(longPressTimer);
602
+ longPressTimer = void 0;
603
+ }
604
+ };
605
+ const bindGesture = () => {
606
+ doc.addEventListener("pointermove", onPointerMove);
607
+ doc.addEventListener("pointerup", onPointerUp);
608
+ doc.addEventListener("pointercancel", onPointerCancel);
609
+ };
610
+ const unbindGesture = () => {
611
+ doc.removeEventListener("pointermove", onPointerMove);
612
+ doc.removeEventListener("pointerup", onPointerUp);
613
+ doc.removeEventListener("pointercancel", onPointerCancel);
614
+ };
515
615
  const onPointerDown = (event) => {
516
616
  if (event.button !== 0) return;
517
617
  dragging = true;
518
618
  moved = false;
519
619
  originX = event.clientX;
520
620
  originY = event.clientY;
621
+ slop = dragSlop(event.pointerType);
622
+ clearLongPress();
623
+ if (slop === DRAG_SLOP_TOUCH) {
624
+ longPressTimer = win.setTimeout(() => {
625
+ longPressTimer = void 0;
626
+ if (!dragging || moved) return;
627
+ try {
628
+ win.navigator.vibrate?.(10);
629
+ } catch {
630
+ }
631
+ openMenu();
632
+ suppressNextClick = true;
633
+ }, LONG_PRESS_MS);
634
+ }
635
+ const rect = surfaceEl.getBoundingClientRect();
636
+ grab = grabOffset({ x: event.clientX, y: event.clientY }, rect);
637
+ dragSize = rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE;
638
+ restX = Number.parseFloat(floatHost.style.left) || 0;
639
+ restY = Number.parseFloat(floatHost.style.top) || 0;
521
640
  surfaceEl.setPointerCapture?.(event.pointerId);
641
+ bindGesture();
522
642
  };
523
643
  const onPointerMove = (event) => {
524
644
  if (!dragging) return;
525
- if (!moved && Math.hypot(event.clientX - originX, event.clientY - originY) < 6) return;
526
- moved = true;
527
- doc.body.style.userSelect = "none";
528
- floatHost.style.left = `${Math.round(event.clientX)}px`;
529
- floatHost.style.top = `${Math.round(event.clientY)}px`;
645
+ if (!moved) {
646
+ if (Math.hypot(event.clientX - originX, event.clientY - originY) < slop) return;
647
+ moved = true;
648
+ clearLongPress();
649
+ doc.body.style.userSelect = "none";
650
+ surfaceEl.classList.add("dqw-capsule--dragging");
651
+ }
652
+ lastPoint = { x: event.clientX, y: event.clientY };
653
+ scheduleFrame(() => {
654
+ if (!dragging || !lastPoint || disposed) return;
655
+ const viewWidth = win.innerWidth ?? 1024;
656
+ const viewHeight = win.innerHeight ?? 768;
657
+ const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS2);
658
+ const dx = topLeft.x - restX;
659
+ const dy = topLeft.y - restY;
660
+ surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`;
661
+ });
662
+ };
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();
668
+ suppressNextClick = true;
530
669
  };
531
670
  const onPointerUp = (event) => {
532
671
  if (!dragging) return;
533
672
  dragging = false;
673
+ clearLongPress();
674
+ unbindGesture();
534
675
  doc.body.style.userSelect = "";
535
676
  if (!moved) return;
536
677
  const viewWidth = win.innerWidth ?? 1024;
537
678
  const viewHeight = win.innerHeight ?? 768;
538
- const point = clampPoint(
679
+ const insets = readInsets();
680
+ const viewport = { width: viewWidth, height: viewHeight };
681
+ const dock = releaseDock(
539
682
  { x: event.clientX, y: event.clientY },
540
- { width: viewWidth, height: viewHeight },
541
- SURFACE_SIZE
683
+ grab,
684
+ dragSize,
685
+ viewport,
686
+ insets
542
687
  );
543
- floatHost.style.left = `${Math.round(point.x)}px`;
544
- floatHost.style.top = `${Math.round(point.y)}px`;
545
- saveFloatGeometry(win.localStorage, point);
546
688
  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
+ }
709
+ };
710
+ const onPointerCancel = () => {
711
+ if (!dragging) return;
712
+ dragging = false;
713
+ clearLongPress();
714
+ unbindGesture();
715
+ doc.body.style.userSelect = "";
716
+ surfaceEl.classList.remove("dqw-capsule--dragging", "dqw-capsule--snapping");
717
+ surfaceEl.style.transform = "";
547
718
  };
548
719
  surfaceEl.addEventListener("pointerdown", onPointerDown);
549
- doc.addEventListener("pointermove", onPointerMove);
550
- doc.addEventListener("pointerup", onPointerUp);
551
- return () => {
720
+ const disposeDrag2 = () => {
552
721
  surfaceEl.removeEventListener("pointerdown", onPointerDown);
553
- doc.removeEventListener("pointermove", onPointerMove);
554
- doc.removeEventListener("pointerup", onPointerUp);
722
+ unbindGesture();
555
723
  };
724
+ return disposeDrag2;
556
725
  };
557
726
  let snapshot;
558
727
  let requestSequence = 0;
@@ -763,6 +932,13 @@ function mountQuotaCard({
763
932
  if (panel.hidden) return;
764
933
  const anchor = lastAnchor?.();
765
934
  if (!anchor || !anchor.isConnected) return;
935
+ if ((win.innerWidth ?? 1024) <= 480) {
936
+ panel.classList.add("dqw-panel--sheet");
937
+ panel.style.left = "";
938
+ panel.style.top = "";
939
+ return;
940
+ }
941
+ panel.classList.remove("dqw-panel--sheet");
766
942
  const rect = anchor.getBoundingClientRect();
767
943
  const viewWidth = win.innerWidth ?? 1024;
768
944
  const viewHeight = win.innerHeight ?? 768;
@@ -840,6 +1016,7 @@ function mountQuotaCard({
840
1016
  const label = summary === "" ? copy.title : locale === "zh" ? `${copy.title}\uFF1A${summary}` : `${copy.title}: ${summary}`;
841
1017
  surface.setAttribute("aria-label", label);
842
1018
  surface.title = label;
1019
+ applyDock();
843
1020
  };
844
1021
  const onSurfaceClick = () => {
845
1022
  if (suppressNextClick) {
@@ -880,6 +1057,7 @@ function mountQuotaCard({
880
1057
  };
881
1058
  const onSurfaceContext = (event) => {
882
1059
  event.preventDefault();
1060
+ if (!menu.hidden) return;
883
1061
  openMenu();
884
1062
  };
885
1063
  const onMenuClick = (event) => {
@@ -1046,21 +1224,19 @@ function mountQuotaCard({
1046
1224
  openKey = void 0;
1047
1225
  renderAll();
1048
1226
  };
1227
+ let scrollFrameQueued = false;
1049
1228
  const onDocScroll = () => {
1050
- placePanel();
1229
+ if (scrollFrameQueued || disposed) return;
1230
+ scrollFrameQueued = true;
1231
+ scheduleFrame(() => {
1232
+ scrollFrameQueued = false;
1233
+ if (!disposed) placePanel();
1234
+ });
1051
1235
  };
1052
1236
  const onWinResize = () => {
1053
1237
  placePanel();
1054
1238
  syncCardVisibility();
1055
- const viewWidth = win.innerWidth ?? 1024;
1056
- const viewHeight = win.innerHeight ?? 768;
1057
- const x = parseFloat(floatHost.style.left);
1058
- const y = parseFloat(floatHost.style.top);
1059
- if (Number.isFinite(x) && Number.isFinite(y)) {
1060
- const point = clampPoint({ x, y }, { width: viewWidth, height: viewHeight }, SURFACE_SIZE);
1061
- floatHost.style.left = `${Math.round(point.x)}px`;
1062
- floatHost.style.top = `${Math.round(point.y)}px`;
1063
- }
1239
+ applyDock();
1064
1240
  };
1065
1241
  const onDocKeydown = (event) => {
1066
1242
  if (event.key !== "Escape") return;
@@ -1112,6 +1288,7 @@ function mountQuotaCard({
1112
1288
  surface.removeEventListener("contextmenu", onSurfaceContext);
1113
1289
  menu.removeEventListener("click", onMenuClick);
1114
1290
  disposeDrag();
1291
+ doc.body.style.userSelect = "";
1115
1292
  container.remove();
1116
1293
  panel.remove();
1117
1294
  floatHost.remove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
@@ -0,0 +1,49 @@
1
+ // DOM-free drag state machine for @iasiv5/dsh-quota-watch (ADR 0002).
2
+ // Point/rect values come from the caller; nothing here touches the DOM, so the
3
+ // math stays unit-testable under jsdom.
4
+
5
+ import { MARGIN, clampPoint } from './prefs.mjs'
6
+
7
+ export const DRAG_SLOP_MOUSE = 6
8
+ export const DRAG_SLOP_TOUCH = 10
9
+ export const LONG_PRESS_MS = 500
10
+
11
+ /** Touch/pen gestures get the larger slop so taps win over drags. */
12
+ export function dragSlop(pointerType) {
13
+ return pointerType === 'touch' || pointerType === 'pen' ? DRAG_SLOP_TOUCH : DRAG_SLOP_MOUSE
14
+ }
15
+
16
+ /** Offset between the pointer and the pill's top-left at grab time. */
17
+ export function grabOffset(point, rect) {
18
+ return { dx: point.x - rect.left, dy: point.y - rect.top }
19
+ }
20
+
21
+ function pillTopLeft(point, grab) {
22
+ return { x: point.x - grab.dx, y: point.y - grab.dy }
23
+ }
24
+
25
+ /** Live clamp for the pill top-left while the gesture is active. */
26
+ export function clampFrame(point, grab, size, viewport, insets) {
27
+ return clampPoint(pillTopLeft(point, grab), viewport, size, insets)
28
+ }
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
+ /** Computed-style padding value ("20px" | "" | "auto") → numeric inset or 0. */
46
+ export function parseInset(computedValue) {
47
+ const value = Number.parseFloat(computedValue)
48
+ return Number.isFinite(value) ? value : 0
49
+ }
@@ -1,46 +1,70 @@
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_GEOMETRY_KEY = 'dsh-quota-watch:float-geometry'
4
+ export const FLOAT_DOCK_KEY = 'dsh-quota-watch:float-dock'
5
5
  export const SURFACE_FLAGS_KEY = 'dsh-quota-watch:surface-flags'
6
6
 
7
- const MARGIN = 8
7
+ export const MARGIN = 8
8
8
 
9
- export function clampPoint(point, viewport, size = 0) {
10
- const minX = MARGIN
11
- const minY = MARGIN
12
- const maxX = Math.max(MARGIN, viewport.width - MARGIN - size)
13
- const maxY = Math.max(MARGIN, viewport.height - MARGIN - size)
9
+ const ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 }
10
+
11
+ function normalizeSize(size) {
12
+ if (typeof size === 'number') return { width: size, height: size }
13
+ const width = typeof size?.width === 'number' ? size.width : 0
14
+ const height = typeof size?.height === 'number' ? size.height : 0
15
+ return { width, height }
16
+ }
17
+
18
+ export function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
19
+ const { width, height } = normalizeSize(size)
20
+ const insetLeft = Number.isFinite(insets?.left) ? insets.left : 0
21
+ const insetRight = Number.isFinite(insets?.right) ? insets.right : 0
22
+ const insetTop = Number.isFinite(insets?.top) ? insets.top : 0
23
+ const insetBottom = Number.isFinite(insets?.bottom) ? insets.bottom : 0
24
+ const minX = MARGIN + insetLeft
25
+ const minY = MARGIN + insetTop
26
+ const maxX = Math.max(minX, viewport.width - insetRight - MARGIN - width)
27
+ const maxY = Math.max(minY, viewport.height - insetBottom - MARGIN - height)
14
28
  return {
15
29
  x: Math.min(Math.max(point.x, minX), maxX),
16
30
  y: Math.min(Math.max(point.y, minY), maxY),
17
31
  }
18
32
  }
19
33
 
20
- export function loadFloatGeometry(storage) {
34
+ const DOCK_EDGES = new Set(['left', 'right'])
35
+
36
+ export function loadFloatDock(storage) {
21
37
  try {
22
- const raw = storage.getItem(FLOAT_GEOMETRY_KEY)
38
+ const raw = storage.getItem(FLOAT_DOCK_KEY)
23
39
  if (typeof raw !== 'string' || raw === '') return null
24
40
  const parsed = JSON.parse(raw)
25
41
  if (
26
42
  typeof parsed !== 'object' ||
27
43
  parsed === null ||
28
- !Number.isFinite(parsed.x) ||
29
- !Number.isFinite(parsed.y)
44
+ !DOCK_EDGES.has(parsed.edge) ||
45
+ !Number.isFinite(parsed.offsetY)
30
46
  ) {
31
47
  return null
32
48
  }
33
- return { x: parsed.x, y: parsed.y }
49
+ return { edge: parsed.edge, offsetY: parsed.offsetY }
34
50
  } catch {
35
51
  return null
36
52
  }
37
53
  }
38
54
 
39
- export function saveFloatGeometry(storage, point) {
55
+ export function saveFloatDock(storage, dock) {
56
+ if (
57
+ typeof dock !== 'object' ||
58
+ dock === null ||
59
+ !DOCK_EDGES.has(dock.edge) ||
60
+ !Number.isFinite(dock.offsetY)
61
+ ) {
62
+ throw new TypeError(`invalid float dock: ${JSON.stringify(dock)}`)
63
+ }
40
64
  try {
41
- storage.setItem(FLOAT_GEOMETRY_KEY, JSON.stringify({ x: point.x, y: point.y }))
65
+ storage.setItem(FLOAT_DOCK_KEY, JSON.stringify({ edge: dock.edge, offsetY: dock.offsetY }))
42
66
  } catch {
43
- /* storage unavailable; geometry stays session-only */
67
+ /* storage unavailable; dock stays session-only */
44
68
  }
45
69
  }
46
70
 
package/src/client.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
2
2
  import { remainingToUsed } from './core/adapters.mjs'
3
- import { clampPoint, loadFloatGeometry, loadSurfaceFlags, saveFloatGeometry, saveSurfaceFlags } from './client/prefs.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'
4
5
 
5
6
  export const name = 'quota-watch-client'
6
7
  export const inject = []
@@ -8,8 +9,10 @@ export const inject = []
8
9
  const CARD_SELECTOR = '[data-dsh-quota-watch-card]'
9
10
  const FLOAT_SELECTOR = '[data-dsh-quota-watch-float]'
10
11
  const FETCH_TIMEOUT_MS = 15_000
11
- const SURFACE_SIZE = 38
12
- const SURFACE_MARGIN = 16
12
+ // Measured-size fallback for environments where getBoundingClientRect reports
13
+ // zeros (jsdom) or the capsule has not painted yet.
14
+ const FALLBACK_SIZE = { width: 38, height: 26 }
15
+ const ZERO_INSETS = { left: 0, right: 0, top: 0, bottom: 0 }
13
16
  // Below this a mounted card would clip into unreadable text (collapsed web
14
17
  // rails, squeezed desktop layouts) — hide it and let the capsule carry the
15
18
  // entry. clientWidth 0 (no layout yet, jsdom) counts as "unknown", not narrow.
@@ -117,16 +120,21 @@ const STYLE_TEXT = `
117
120
  // card shadow keeps its own copies for the sidebar rows.
118
121
  const FLOAT_STYLE_TEXT = `
119
122
  :host { position: fixed; z-index: 2147483000; }
120
- .dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: pointer; user-select: none; white-space: nowrap; }
121
- .dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); }
123
+ .dqw-capsule { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 999px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); box-shadow: 0 4px 14px rgba(0,0,0,.22); color: var(--dsw-alias-label-primary, inherit); font: inherit; font-size: 11px; line-height: 24px; cursor: grab; user-select: none; white-space: nowrap; position: relative; touch-action: none; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; }
124
+ .dqw-capsule::before { content: ''; position: absolute; inset: -9px; }
125
+ .dqw-capsule:hover { border-color: var(--dsw-alias-border-primary, var(--dsw-alias-border-secondary, rgba(128,128,128,.35))); box-shadow: 0 6px 18px rgba(0,0,0,.28); }
126
+ .dqw-capsule:active { transform: scale(.98); }
122
127
  .dqw-capsule:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #5b8def); outline-offset: 2px; }
123
128
  .dqw-capsule-sep { opacity: .5; }
124
129
  .dqw-capsule-glm, .dqw-capsule-copilot { font-variant-numeric: tabular-nums; font-weight: 600; }
125
130
  .dqw-capsule-glm.warn, .dqw-capsule-copilot.warn { color: var(--dsw-alias-label-warning, #d29922); }
126
131
  .dqw-capsule-glm.danger, .dqw-capsule-copilot.danger { color: var(--dsw-alias-label-danger, #c93c3c); }
127
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; }
128
134
  @media (prefers-reduced-motion: no-preference) {
129
135
  .dqw-capsule[data-alert] { animation: dqw-pulse 1.6s ease-in-out infinite; }
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); }
130
138
  }
131
139
  @media print { :host { display: none !important; } }
132
140
  @keyframes dqw-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
@@ -136,6 +144,7 @@ const FLOAT_STYLE_TEXT = `
136
144
  .dqw-menu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
137
145
  .dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
138
146
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
147
+ .dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
139
148
  .dqw-panel[hidden] { display: none; }
140
149
  .dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
141
150
  .dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
@@ -155,7 +164,7 @@ const FLOAT_STYLE_TEXT = `
155
164
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
156
165
  .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
157
166
  .dqw-label[data-action="refresh"]:hover { opacity: .75; }
158
- .dqw-bar { display: block; flex: 0 0 auto; width: 120px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
167
+ .dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
159
168
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
160
169
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
161
170
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
@@ -428,22 +437,50 @@ export function mountQuotaCard({
428
437
  // fallbacks (broke the light theme). <body> is also transform-free, so
429
438
  // position:fixed stays viewport-anchored.
430
439
  doc.body.append(floatHost)
431
- const applyFloatGeometry = () => {
440
+ /** Measured capsule size; zeros (jsdom / unpainted) fall back to the box. */
441
+ const measureSurface = () => {
442
+ const rect = surface.getBoundingClientRect()
443
+ return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
444
+ }
445
+ /** Rest position = docked edge + persisted offsetY; x is always derived (ADR 0002). */
446
+ const applyDock = () => {
432
447
  const viewWidth = win.innerWidth ?? 1024
433
448
  const viewHeight = win.innerHeight ?? 768
434
- const saved = loadFloatGeometry(win.localStorage)
435
- const point = clampPoint(
436
- saved ?? { x: viewWidth - SURFACE_MARGIN - SURFACE_SIZE, y: viewHeight - SURFACE_MARGIN - SURFACE_SIZE },
437
- { width: viewWidth, height: viewHeight },
438
- SURFACE_SIZE,
439
- )
440
- floatHost.style.left = `${Math.round(point.x)}px`
441
- floatHost.style.top = `${Math.round(point.y)}px`
449
+ const viewport = { width: viewWidth, height: viewHeight }
450
+ const size = measureSurface()
451
+ const dock = loadFloatDock(win.localStorage) ?? {
452
+ edge: 'right',
453
+ offsetY: viewHeight - MARGIN - size.height,
454
+ }
442
455
  // Deliberately NOT persisted here: a window that starts small (Electron
443
- // restore, split-screen) must not clobber the user's preferred coordinates.
444
- // The visual clamp above applies every mount; storage updates on drag only.
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
+ 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`
462
+ }
463
+ // Safe-area probe: env() insets ride computed padding; jsdom reports '' which
464
+ // parseInset normalizes to 0, so tests without a stub see zero insets.
465
+ const safeAreaProbe = text(doc, 'div', '', '')
466
+ 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);'
467
+ floatRoot.append(safeAreaProbe)
468
+ /** Live env(safe-area-inset-*) reading — re-probed on every use, so window
469
+ * chrome changes (rotation, URL-bar collapse) need no explicit invalidation. */
470
+ const readInsets = () => {
471
+ try {
472
+ const style = win.getComputedStyle(safeAreaProbe)
473
+ return {
474
+ top: parseInset(style?.paddingTop),
475
+ right: parseInset(style?.paddingRight),
476
+ bottom: parseInset(style?.paddingBottom),
477
+ left: parseInset(style?.paddingLeft),
478
+ }
479
+ } catch {
480
+ return ZERO_INSETS
481
+ }
445
482
  }
446
- applyFloatGeometry()
483
+ applyDock()
447
484
  // Panel lives inside the float shadow root from now on (契约总表「归属时序」).
448
485
  floatRoot.append(panel)
449
486
  const menu = doc.createElement('div')
@@ -454,55 +491,168 @@ export function mountQuotaCard({
454
491
  floatRoot.append(menu)
455
492
 
456
493
  let suppressNextClick = false
457
- /** Whole-surface drag: 6px threshold, top-left follows the pointer, clamp + persist on release.
458
- * Move/up listen on `document` — pointer events always bubble there, in browsers and jsdom alike.
459
- * Returns a dispose function. */
494
+ /** One rAF (or a synchronous fallback when the host has none) — drag frames
495
+ * and scroll re-placement share this gate so a burst of events costs one
496
+ * layout read/write per frame. */
497
+ const scheduleFrame = (fn) => (
498
+ typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.call(win, fn) : fn()
499
+ )
500
+ /** Grab-offset + transform drag (ADR 0002): the pill keeps its grab point
501
+ * under the cursor, moves via transform during the gesture (rAF-batched,
502
+ * live-clamped to the measured size), and the dock is persisted on release —
503
+ * optionally through a short snap transition when motion is allowed.
504
+ * Move/up listen on `document` — pointer events always bubble there, in
505
+ * browsers and jsdom alike. Returns a dispose function. */
460
506
  const attachDrag = (surfaceEl) => {
461
507
  let dragging = false
462
508
  let moved = false
463
509
  let originX = 0
464
510
  let originY = 0
511
+ let slop = DRAG_SLOP_MOUSE
512
+ let grab = { dx: 0, dy: 0 }
513
+ let dragSize = FALLBACK_SIZE
514
+ let restX = 0
515
+ let restY = 0
516
+ let lastPoint = null
517
+ let longPressTimer
518
+ const clearLongPress = () => {
519
+ if (longPressTimer !== undefined) {
520
+ win.clearTimeout(longPressTimer)
521
+ longPressTimer = undefined
522
+ }
523
+ }
524
+ /** Doc-level move/up/cancel are bound only while a gesture is active —
525
+ * the pill's resting state costs zero document-level listeners. */
526
+ const bindGesture = () => {
527
+ doc.addEventListener('pointermove', onPointerMove)
528
+ doc.addEventListener('pointerup', onPointerUp)
529
+ doc.addEventListener('pointercancel', onPointerCancel)
530
+ }
531
+ const unbindGesture = () => {
532
+ doc.removeEventListener('pointermove', onPointerMove)
533
+ doc.removeEventListener('pointerup', onPointerUp)
534
+ doc.removeEventListener('pointercancel', onPointerCancel)
535
+ }
465
536
  const onPointerDown = (event) => {
466
537
  if (event.button !== 0) return
467
538
  dragging = true
468
539
  moved = false
469
540
  originX = event.clientX
470
541
  originY = event.clientY
542
+ // Graded slop: touch/pen get 10px so taps win over drags (Task 6).
543
+ slop = dragSlop(event.pointerType)
544
+ // Long press opens the context menu on touch (iOS has no contextmenu);
545
+ // movement past the slop cancels it in favor of the drag.
546
+ clearLongPress()
547
+ if (slop === DRAG_SLOP_TOUCH) {
548
+ longPressTimer = win.setTimeout(() => {
549
+ longPressTimer = undefined
550
+ if (!dragging || moved) return
551
+ try { win.navigator.vibrate?.(10) } catch { /* vibration unavailable */ }
552
+ openMenu()
553
+ suppressNextClick = true
554
+ }, LONG_PRESS_MS)
555
+ }
556
+ const rect = surfaceEl.getBoundingClientRect()
557
+ grab = grabOffset({ x: event.clientX, y: event.clientY }, rect)
558
+ dragSize = rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
559
+ restX = Number.parseFloat(floatHost.style.left) || 0
560
+ restY = Number.parseFloat(floatHost.style.top) || 0
471
561
  surfaceEl.setPointerCapture?.(event.pointerId)
562
+ bindGesture()
472
563
  }
473
564
  const onPointerMove = (event) => {
474
565
  if (!dragging) return
475
- if (!moved && Math.hypot(event.clientX - originX, event.clientY - originY) < 6) return
476
- moved = true
477
- doc.body.style.userSelect = 'none'
478
- floatHost.style.left = `${Math.round(event.clientX)}px`
479
- floatHost.style.top = `${Math.round(event.clientY)}px`
566
+ if (!moved) {
567
+ if (Math.hypot(event.clientX - originX, event.clientY - originY) < slop) return
568
+ moved = true
569
+ clearLongPress()
570
+ doc.body.style.userSelect = 'none'
571
+ surfaceEl.classList.add('dqw-capsule--dragging')
572
+ }
573
+ lastPoint = { x: event.clientX, y: event.clientY }
574
+ scheduleFrame(() => {
575
+ if (!dragging || !lastPoint || disposed) return
576
+ const viewWidth = win.innerWidth ?? 1024
577
+ const viewHeight = win.innerHeight ?? 768
578
+ const topLeft = clampFrame(lastPoint, grab, dragSize, { width: viewWidth, height: viewHeight }, ZERO_INSETS)
579
+ const dx = topLeft.x - restX
580
+ const dy = topLeft.y - restY
581
+ surfaceEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0) scale(1.03)`
582
+ })
583
+ }
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()
589
+ suppressNextClick = true
480
590
  }
481
591
  const onPointerUp = (event) => {
482
592
  if (!dragging) return
483
593
  dragging = false
594
+ clearLongPress()
595
+ unbindGesture()
484
596
  doc.body.style.userSelect = ''
485
597
  if (!moved) return
486
598
  const viewWidth = win.innerWidth ?? 1024
487
599
  const viewHeight = win.innerHeight ?? 768
488
- const point = clampPoint(
600
+ const insets = readInsets()
601
+ const viewport = { width: viewWidth, height: viewHeight }
602
+ const dock = releaseDock(
489
603
  { x: event.clientX, y: event.clientY },
490
- { width: viewWidth, height: viewHeight },
491
- SURFACE_SIZE,
604
+ grab,
605
+ dragSize,
606
+ viewport,
607
+ insets,
492
608
  )
493
- floatHost.style.left = `${Math.round(point.x)}px`
494
- floatHost.style.top = `${Math.round(point.y)}px`
495
- saveFloatGeometry(win.localStorage, point)
609
+ // The real click lands within ms of pointerup — suppression is armed
610
+ // synchronously here, never deferred to the animation's settle (E1-2).
496
611
  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
+ }
638
+ }
639
+ /** A canceled pointer (scroll takeover, incoming call, palm) resets the
640
+ * gesture: no dock is persisted and the trailing click stays allowed. */
641
+ const onPointerCancel = () => {
642
+ if (!dragging) return
643
+ dragging = false
644
+ clearLongPress()
645
+ unbindGesture()
646
+ doc.body.style.userSelect = ''
647
+ surfaceEl.classList.remove('dqw-capsule--dragging', 'dqw-capsule--snapping')
648
+ surfaceEl.style.transform = ''
497
649
  }
498
650
  surfaceEl.addEventListener('pointerdown', onPointerDown)
499
- doc.addEventListener('pointermove', onPointerMove)
500
- doc.addEventListener('pointerup', onPointerUp)
501
- return () => {
651
+ const disposeDrag = () => {
502
652
  surfaceEl.removeEventListener('pointerdown', onPointerDown)
503
- doc.removeEventListener('pointermove', onPointerMove)
504
- doc.removeEventListener('pointerup', onPointerUp)
653
+ unbindGesture()
505
654
  }
655
+ return disposeDrag
506
656
  }
507
657
  let snapshot
508
658
  let requestSequence = 0
@@ -735,6 +885,16 @@ export function mountQuotaCard({
735
885
  if (panel.hidden) return
736
886
  const anchor = lastAnchor?.()
737
887
  if (!anchor || !anchor.isConnected) return
888
+ if ((win.innerWidth ?? 1024) <= 480) {
889
+ // Narrow viewport (phones): dock the panel to the bottom instead of
890
+ // squeezing it beside the capsule — the sheet is CSS-positioned, so the
891
+ // inline anchor coordinates are dropped.
892
+ panel.classList.add('dqw-panel--sheet')
893
+ panel.style.left = ''
894
+ panel.style.top = ''
895
+ return
896
+ }
897
+ panel.classList.remove('dqw-panel--sheet')
738
898
  const rect = anchor.getBoundingClientRect()
739
899
  const viewWidth = win.innerWidth ?? 1024
740
900
  const viewHeight = win.innerHeight ?? 768
@@ -817,6 +977,9 @@ export function mountQuotaCard({
817
977
  const label = summary === '' ? copy.title : locale === 'zh' ? `${copy.title}:${summary}` : `${copy.title}: ${summary}`
818
978
  surface.setAttribute('aria-label', label)
819
979
  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()
820
983
  }
821
984
 
822
985
  const onSurfaceClick = () => {
@@ -859,6 +1022,9 @@ export function mountQuotaCard({
859
1022
  }
860
1023
  const onSurfaceContext = (event) => {
861
1024
  event.preventDefault()
1025
+ // Android fires the native contextmenu right after the long-press timer:
1026
+ // an already-open menu is kept as-is instead of being rebuilt.
1027
+ if (!menu.hidden) return
862
1028
  openMenu()
863
1029
  }
864
1030
  const onMenuClick = (event) => {
@@ -1045,21 +1211,20 @@ export function mountQuotaCard({
1045
1211
  openKey = undefined
1046
1212
  renderAll()
1047
1213
  }
1048
- const onDocScroll = () => { placePanel() }
1214
+ let scrollFrameQueued = false
1215
+ const onDocScroll = () => {
1216
+ // Coalesce scroll bursts into one placement per frame (mobile inertia).
1217
+ if (scrollFrameQueued || disposed) return
1218
+ scrollFrameQueued = true
1219
+ scheduleFrame(() => {
1220
+ scrollFrameQueued = false
1221
+ if (!disposed) placePanel()
1222
+ })
1223
+ }
1049
1224
  const onWinResize = () => {
1050
1225
  placePanel()
1051
1226
  syncCardVisibility()
1052
- // Keep the capsule inside a shrunk viewport; storage keeps the user's
1053
- // preferred coordinates — only the visual position is clamped here.
1054
- const viewWidth = win.innerWidth ?? 1024
1055
- const viewHeight = win.innerHeight ?? 768
1056
- const x = parseFloat(floatHost.style.left)
1057
- const y = parseFloat(floatHost.style.top)
1058
- if (Number.isFinite(x) && Number.isFinite(y)) {
1059
- const point = clampPoint({ x, y }, { width: viewWidth, height: viewHeight }, SURFACE_SIZE)
1060
- floatHost.style.left = `${Math.round(point.x)}px`
1061
- floatHost.style.top = `${Math.round(point.y)}px`
1062
- }
1227
+ applyDock()
1063
1228
  }
1064
1229
  const onDocKeydown = (event) => {
1065
1230
  if (event.key !== 'Escape') return
@@ -1112,6 +1277,8 @@ export function mountQuotaCard({
1112
1277
  surface.removeEventListener('contextmenu', onSurfaceContext)
1113
1278
  menu.removeEventListener('click', onMenuClick)
1114
1279
  disposeDrag()
1280
+ // A dispose landing mid-gesture must not leave the body unselectable.
1281
+ doc.body.style.userSelect = ''
1115
1282
  container.remove()
1116
1283
  panel.remove()
1117
1284
  floatHost.remove()