dsh-codinput 0.1.1 → 0.2.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.
@@ -21,7 +21,7 @@ export interface SidebarCarryState {
21
21
  }
22
22
 
23
23
  export interface SidebarCarry {
24
- /** 本次承载的令牌(同会话多 body 时,最早的持令牌者渲染编辑面)。 */
24
+ /** 本次承载的令牌(同会话多 body 时,可见者优先、同可见最早者渲染编辑面)。 */
25
25
  readonly token: number;
26
26
  /** 可见性/展开态变化时更新(同一 body 生命周期内)。 */
27
27
  set(state: SidebarCarryState): void;
@@ -53,11 +53,12 @@ function recompute(): void {
53
53
  for (const [token, claim] of claims) {
54
54
  const prev = next.get(claim.sessionId);
55
55
  // 同一会话多 body(分栏里复制出第二个 Codinput 标签)时取「最可见」的一份,
56
- // 编辑面归属最早声明者(owner),其余 body 只出提示,杜绝双编辑面。
56
+ // 编辑面归属可见者(owner 随 visible 走——否则编辑面会留在看不见的标签里,
57
+ // 正在看的标签只剩提示),同可见时归最早声明者,杜绝双编辑面。
57
58
  if (!prev) {
58
59
  next.set(claim.sessionId, { visible: claim.visible, expanded: claim.expanded, owner: token });
59
60
  } else if (!prev.visible && claim.visible) {
60
- next.set(claim.sessionId, { ...prev, visible: true, expanded: claim.expanded });
61
+ next.set(claim.sessionId, { visible: true, expanded: claim.expanded, owner: token });
61
62
  }
62
63
  }
63
64
  if (sameSnapshot(next)) return;
@@ -93,6 +94,11 @@ export function isSidebarInputCarried(sessionId: string | undefined): boolean {
93
94
  return sessionId !== undefined && snapshot.has(sessionId);
94
95
  }
95
96
 
97
+ /** 当前被侧栏承载的会话 id 列表(/codinput 退出时广播关闭标签用)。 */
98
+ export function carriedSessionIds(): string[] {
99
+ return [...snapshot.keys()];
100
+ }
101
+
96
102
  /** 任一会话被侧栏承载(主遮蔽条目需保持挂载以执行隐藏)。 */
97
103
  export function isAnySidebarInputCarried(): boolean {
98
104
  return snapshot.size > 0;
@@ -105,14 +111,30 @@ export function sidebarCarryState(sessionId: string | undefined): SidebarCarrySt
105
111
 
106
112
  /**
107
113
  * 编辑面归属:同会话被多个 body 承载(分栏里复制出第二个 Codinput 标签)时,
108
- * 只有最早声明者渲染编辑面。`token === 0`(尚未声明)一律返回 true——避免
109
- * 首帧把还没拿到令牌的自己判成非归属者。
114
+ * 可见者优先,同可见归最早声明者。`token === 0`(尚未声明)一律返回 true——
115
+ * 避免首帧把还没拿到令牌的自己判成非归属者。
110
116
  */
111
117
  export function isSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
112
118
  if (token === 0 || sessionId === undefined) return true;
113
119
  return snapshot.get(sessionId)?.owner === token;
114
120
  }
115
121
 
122
+
123
+ /** 标签关闭请求总线:/codinput 从侧栏退出时,命令侧请求 body 关掉自己的标签。 */
124
+ const tabCloseListeners = new Set<(sessionId: string) => void>();
125
+
126
+ /** 请求关闭某会话的侧栏 Codinput 标签(无 body 在听时无害)。 */
127
+ export function requestSidebarTabClose(sessionId: string): void {
128
+ for (const fn of tabCloseListeners) fn(sessionId);
129
+ }
130
+
131
+ export function subscribeSidebarTabClose(fn: (sessionId: string) => void): () => void {
132
+ tabCloseListeners.add(fn);
133
+ return () => {
134
+ tabCloseListeners.delete(fn);
135
+ };
136
+ }
137
+
116
138
  /** 归属订阅(body 用):令牌变化、其他 body 挂载/卸载都会重渲染。 */
117
139
  export function useSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
118
140
  const [owner, setOwner] = useState(() => isSidebarCarryOwner(sessionId, token));
@@ -150,12 +172,16 @@ export function useSidebarInputCarried(sessionId: string | undefined): boolean {
150
172
 
151
173
  /**
152
174
  * 侧栏承载态订阅(小球用):`carried` = 本会话输入在侧栏里,
153
- * `collapsed` = 侧栏已收起(承载但不可见 → 输入入口需要小球唤出)。
175
+ * `collapsed` = 输入面不可见(承载但侧栏已收起 → 输入入口需要小球唤出)。
176
+ * 浮动面板 visible=true——输入就在眼前,即使侧栏恰好收起也不出球。
154
177
  */
155
178
  export function useSidebarCarry(sessionId: string | undefined): { carried: boolean; collapsed: boolean } {
156
179
  const read = (): { carried: boolean; collapsed: boolean } => {
157
180
  const state = sidebarCarryState(sessionId);
158
- return { carried: state !== undefined, collapsed: state !== undefined && state.expanded === false };
181
+ return {
182
+ carried: state !== undefined,
183
+ collapsed: state !== undefined && state.expanded === false && state.visible === false,
184
+ };
159
185
  };
160
186
  const [value, setValue] = useState(read);
161
187
  useEffect(() => {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 数据行数据源(卡片下方):会话统计投影探测 + 归一化。
3
- * 官方投影键(实测确认):tokenUsage(cacheRead/cacheWrite/output/
4
- * uncachedInput)、contextPressure(contextWindow/pressureTokens/
5
- * projectedTokens);轮/步按别名键探测。tok/s 为输出令牌的实时采样速率。
2
+ * 数据行数据源(卡片下方):逐值复刻官方 StatsPills 的取数与计算——
3
+ * `sessionStats` 投影优先(turns/steps/llm/tool/ttft/decode 全量),缺席时
4
+ * 从 chat legacy 节点按官方 deriveStats 派生;tok/s = decodeTokens ÷
5
+ * decodeMs(会话级持久值,非流式采样),缓存命中/用量分桶同官方公式。
6
6
  * 注意:useProjection 是真实 hook,键探测必须是固定次数的顶层调用。
7
7
  */
8
8
 
@@ -19,18 +19,24 @@ const PROJECTION_KEYS = [
19
19
  export interface SessionStats {
20
20
  turns?: number;
21
21
  steps?: number;
22
- /** 实时输出速率(tok/s),运行期间采样更新,空闲时保留最后一次。 */
22
+ /** 输出速度(tok/s)= decodeTokens ÷ (decodeMs/1000);decodeMs>0 才有。 */
23
23
  tokensPerSecond?: number;
24
24
  totalTokens?: number;
25
- cacheHitPct?: number;
25
+ /** 官方 formatCacheHitPercent 文本("88"/"99.9"/"100")。 */
26
+ cacheHitText?: string;
26
27
  /** 上下文占用百分比(projectedTokens ?? pressureTokens,除以 contextWindow)。 */
27
28
  contextPct?: number;
28
- /** 会话统计面板:模型用时(流式活跃累计毫秒)与首 token 延迟。 */
29
- modelMs?: number;
29
+ /** 会话统计面板:模型用时 / 工具调用用时 / TTFT 均值(官方格式化)。 */
30
+ llmMs?: number;
31
+ toolMs?: number;
30
32
  ttftMs?: number;
31
- /** Token 用量面板:原始分段。 */
33
+ ttftSteps?: number;
34
+ decodeMs?: number;
35
+ decodeTokens?: number;
36
+ /** Token 用量面板:原始分桶。 */
32
37
  uncachedInputTokens?: number;
33
38
  cacheReadTokens?: number;
39
+ cacheWriteTokens?: number;
34
40
  outputTokens?: number;
35
41
  }
36
42
 
@@ -47,6 +53,18 @@ interface ContextPressure {
47
53
  projectedTokens?: number;
48
54
  }
49
55
 
56
+ /** 官方 sessionStats 投影/deriveStats 的输出形状。 */
57
+ interface ChatStats {
58
+ turns?: number;
59
+ steps?: number;
60
+ llmMs?: number;
61
+ toolMs?: number;
62
+ ttftMs?: number;
63
+ ttftSteps?: number;
64
+ decodeMs?: number;
65
+ decodeTokens?: number;
66
+ }
67
+
50
68
  function num(value: unknown): number | undefined {
51
69
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
52
70
  }
@@ -74,47 +92,105 @@ function readContextPressure(raw: unknown): ContextPressure | undefined {
74
92
  return cp.contextWindow !== undefined && cp.pressureTokens !== undefined ? cp : undefined;
75
93
  }
76
94
 
77
- function readTurnsSteps(raw: unknown): { turns?: number; steps?: number } {
78
- if (!raw || typeof raw !== 'object') return {};
95
+ function readSessionStats(raw: unknown): ChatStats | undefined {
96
+ if (!raw || typeof raw !== 'object') return undefined;
79
97
  const r = raw as Record<string, unknown>;
80
- const turns = num(r['turns']) ?? num(r['turnCount']);
81
- const steps = num(r['steps']) ?? num(r['stepCount']);
82
- return { turns, steps };
98
+ const stats: ChatStats = {
99
+ turns: num(r['turns']),
100
+ steps: num(r['steps']),
101
+ llmMs: num(r['llmMs']),
102
+ toolMs: num(r['toolMs']),
103
+ ttftMs: num(r['ttftMs']),
104
+ ttftSteps: num(r['ttftSteps']),
105
+ decodeMs: num(r['decodeMs']),
106
+ decodeTokens: num(r['decodeTokens']),
107
+ };
108
+ return Object.values(stats).some((v) => v !== undefined) ? stats : undefined;
83
109
  }
84
110
 
85
- /** 实时速率与用时采样:模块级(按 sessionId 记录上次输出令牌数与时刻)。 */
86
- const rateSamples = new Map<string, { tokens: number; time: number; rate?: number; modelMs: number }>();
87
-
88
- export function sampleTokensPerSecond(
89
- sessionId: string | undefined,
90
- outputTokens: number | undefined,
91
- ): { rate?: number; modelMs?: number; ttftMs?: number } {
92
- if (!sessionId || outputTokens === undefined) return {};
93
- const now = Date.now();
94
- const last = rateSamples.get(sessionId);
95
- let rate: number | undefined = last?.rate;
96
- let modelMs = last?.modelMs ?? 0;
97
- let ttftMs: number | undefined;
98
- if (!last) {
99
- // 首个样本:无从计算速率;当前值即初始输出。
100
- rateSamples.set(sessionId, { tokens: outputTokens, time: now, modelMs: 0 });
101
- return {};
111
+ /**
112
+ * 官方 deriveStats 逐行复刻:assistant 节点计轮/步并带 timing(stepStartTime
113
+ * →firstTokenTime→completedTime)与 usage.outputTokens;tool-result 以
114
+ * callTime 起算工具用时。
115
+ */
116
+ export function deriveChatStats(nodes: unknown): ChatStats | undefined {
117
+ if (!Array.isArray(nodes)) return undefined;
118
+ const turns = new Set<unknown>();
119
+ let steps = 0;
120
+ let llmMs = 0;
121
+ let toolMs = 0;
122
+ let ttftMs = 0;
123
+ let ttftSteps = 0;
124
+ let decodeMs = 0;
125
+ let decodeTokens = 0;
126
+ for (const raw of nodes) {
127
+ if (!raw || typeof raw !== 'object') continue;
128
+ const node = raw as Record<string, unknown>;
129
+ if (node['kind'] === 'tool-result') {
130
+ const callTime = num(node['callTime']);
131
+ const time = num(node['time']);
132
+ if (callTime !== undefined && time !== undefined) toolMs += Math.max(0, time - callTime);
133
+ continue;
134
+ }
135
+ if (node['kind'] !== 'assistant') continue;
136
+ turns.add(node['turn']);
137
+ steps += 1;
138
+ const timing = node['timing'] as Record<string, unknown> | undefined;
139
+ const stepStartTime = num(timing?.['stepStartTime']);
140
+ const firstTokenTime = num(timing?.['firstTokenTime']);
141
+ const completedTime = num(timing?.['completedTime']);
142
+ if (stepStartTime !== undefined && completedTime !== undefined) {
143
+ llmMs += Math.max(0, completedTime - stepStartTime);
144
+ }
145
+ if (stepStartTime !== undefined && firstTokenTime !== undefined) {
146
+ ttftMs += Math.max(0, firstTokenTime - stepStartTime);
147
+ ttftSteps += 1;
148
+ }
149
+ if (firstTokenTime !== undefined && completedTime !== undefined) {
150
+ decodeMs += Math.max(0, completedTime - firstTokenTime);
151
+ }
152
+ const usage = node['usage'] as Record<string, unknown> | undefined;
153
+ const out = num(usage?.['outputTokens']);
154
+ if (out !== undefined && out >= 0) decodeTokens += out;
102
155
  }
103
- const dt = now - last.time;
104
- if (outputTokens > last.tokens && dt > 0) {
105
- rate = ((outputTokens - last.tokens) / dt) * 1000;
106
- modelMs += dt;
107
- // 空闲较久后恢复增长:这段等待近似 TTFT。
108
- if (dt > 600) ttftMs = dt;
156
+ if (nodes.length === 0) return undefined;
157
+ return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
158
+ }
159
+
160
+ /** 官方 formatTokensPerSecond:≥10 取整,<10 保留 1 位小数。 */
161
+ export function formatTokensPerSecond(tps: number): string {
162
+ const clamped = Math.max(0, tps);
163
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
164
+ }
165
+
166
+ /**
167
+ * 官方 formatCacheHitPercent 语义:整除 100 → "100";整数舍入 <100 → 整数;
168
+ * 否则递增小数位直到舍入仍 <100(99.9 / 99.99 …)。
169
+ */
170
+ export function formatCacheHitPercent(cacheReadTokens: number, promptTokens: number): string | null {
171
+ if (promptTokens <= 0) return null;
172
+ if (cacheReadTokens >= promptTokens) return '100';
173
+ for (let places = 0; places < 20; places++) {
174
+ const factor = 10 ** places;
175
+ const rounded = Math.round((cacheReadTokens / promptTokens) * 100 * factor) / factor;
176
+ if (rounded < 100) return places === 0 ? String(rounded) : rounded.toFixed(places);
109
177
  }
110
- rateSamples.set(sessionId, { tokens: outputTokens, time: now, rate, modelMs });
111
- return { rate, modelMs, ttftMs };
178
+ return '99.99';
179
+ }
180
+
181
+ /** 官方 formatDuration:<60s 一位小数秒,否则 分+秒。 */
182
+ export function formatDuration(ms: number): string {
183
+ const s = ms / 1000;
184
+ if (s < 60) return `${Math.round(s * 10) / 10}s`;
185
+ const whole = Math.round(s);
186
+ return `${Math.floor(whole / 60)}分${whole % 60}秒`;
112
187
  }
113
188
 
114
189
  /** 每个键一个固定位置的 hook 调用——跨渲染的调用次数与顺序恒定。 */
115
190
  export function useSessionStats(
116
191
  useProjection: UseProjection | undefined,
117
192
  sessionId: string | undefined,
193
+ chatNodes: unknown,
118
194
  ): SessionStats | null {
119
195
  const k0 = useProjection ? useProjection(PROJECTION_KEYS[0]) : undefined;
120
196
  const k1 = useProjection ? useProjection(PROJECTION_KEYS[1]) : undefined;
@@ -124,14 +200,25 @@ export function useSessionStats(
124
200
 
125
201
  const usage = readTokenUsage(k0);
126
202
  const pressure = readContextPressure(k1);
127
- const { turns, steps } = readTurnsSteps(k2) ?? {};
128
- const alt = readTurnsSteps(k3) ?? {};
129
- const alt2 = readTurnsSteps(k4) ?? {};
203
+ // 官方:sessionStats 投影优先,缺席从 chat legacy 节点派生。
204
+ const chat = readSessionStats(k4) ?? deriveChatStats(chatNodes) ?? readTurnsSteps(k2, k3);
130
205
 
131
206
  const stats: SessionStats = {};
132
- if (turns !== undefined || steps !== undefined) Object.assign(stats, { turns, steps });
133
- else if (alt.turns !== undefined || alt.steps !== undefined) Object.assign(stats, alt);
134
- else if (alt2.turns !== undefined || alt2.steps !== undefined) Object.assign(stats, alt2);
207
+ if (chat) {
208
+ if (chat.turns !== undefined || chat.steps !== undefined) {
209
+ stats.turns = chat.turns;
210
+ stats.steps = chat.steps;
211
+ }
212
+ if (chat.llmMs !== undefined) stats.llmMs = chat.llmMs;
213
+ if (chat.toolMs !== undefined) stats.toolMs = chat.toolMs;
214
+ if (chat.ttftMs !== undefined) stats.ttftMs = chat.ttftMs;
215
+ if (chat.ttftSteps !== undefined) stats.ttftSteps = chat.ttftSteps;
216
+ if (chat.decodeMs !== undefined) stats.decodeMs = chat.decodeMs;
217
+ if (chat.decodeTokens !== undefined) stats.decodeTokens = chat.decodeTokens;
218
+ if ((chat.decodeMs ?? 0) > 0) {
219
+ stats.tokensPerSecond = (chat.decodeTokens ?? 0) / (chat.decodeMs! / 1000);
220
+ }
221
+ }
135
222
  if (usage) {
136
223
  const total =
137
224
  (usage.cacheReadTokens ?? 0) +
@@ -142,14 +229,15 @@ export function useSessionStats(
142
229
  stats.totalTokens = total;
143
230
  stats.uncachedInputTokens = usage.uncachedInputTokens;
144
231
  stats.cacheReadTokens = usage.cacheReadTokens;
232
+ stats.cacheWriteTokens = usage.cacheWriteTokens;
145
233
  stats.outputTokens = usage.outputTokens;
146
234
  }
235
+ // 官方:分母 = 三段输入桶(缓存读+写+未缓存输入)。
147
236
  const inputTotal = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + (usage.uncachedInputTokens ?? 0);
148
- if (inputTotal > 0) stats.cacheHitPct = ((usage.cacheReadTokens ?? 0) / inputTotal) * 100;
149
- const sample = sampleTokensPerSecond(sessionId, usage.outputTokens);
150
- if (sample.rate !== undefined && sample.rate > 0) stats.tokensPerSecond = sample.rate;
151
- if (sample.modelMs !== undefined && sample.modelMs > 0) stats.modelMs = sample.modelMs;
152
- if (sample.ttftMs !== undefined && sample.ttftMs > 0) stats.ttftMs = sample.ttftMs;
237
+ if (inputTotal > 0) {
238
+ const text = formatCacheHitPercent(usage.cacheReadTokens ?? 0, inputTotal);
239
+ if (text !== null) stats.cacheHitText = text;
240
+ }
153
241
  }
154
242
  const window_ = pressure?.contextWindow;
155
243
  // 官方 contextOccupancy:优先 projectedTokens。
@@ -157,11 +245,26 @@ export function useSessionStats(
157
245
  if (window_ !== undefined && window_ > 0 && used !== undefined) {
158
246
  stats.contextPct = (used / window_) * 100;
159
247
  }
248
+ void sessionId;
160
249
  return Object.keys(stats).length > 0 ? stats : null;
161
250
  }
162
251
 
252
+ function readTurnsSteps(k2: unknown, k3: unknown): ChatStats | undefined {
253
+ const pick = (raw: unknown): ChatStats | undefined => {
254
+ if (!raw || typeof raw !== 'object') return undefined;
255
+ const r = raw as Record<string, unknown>;
256
+ const turns = num(r['turns']) ?? num(r['turnCount']);
257
+ const steps = num(r['steps']) ?? num(r['stepCount']);
258
+ return turns !== undefined || steps !== undefined ? { turns, steps } : undefined;
259
+ };
260
+ return pick(k2) ?? pick(k3);
261
+ }
262
+
263
+ /** 官方 formatTokens:<1e3 原值,否则 K/M(≥100 取整、<100 一位小数)。 */
163
264
  export function formatTokens(tokens: number): string {
164
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M tok`;
165
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K tok`;
166
- return `${tokens} tok`;
265
+ const scaled = (candidate: number): string =>
266
+ candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10);
267
+ if (tokens < 1e3) return `${tokens} tok`;
268
+ if (tokens < 1e6) return `${scaled(tokens / 1e3)}K tok`;
269
+ return `${scaled(tokens / 1e6)}M tok`;
167
270
  }
@@ -21,8 +21,13 @@ const CSS = `
21
21
  会话态(无包裹约束)不再被拉满,左右居中;拖边调节后由内联 max-width 接管。 */
22
22
  .dci-card { box-sizing: border-box; width: 100%; max-width: var(--dsh-composer-card-max-width, 760px); margin-inline: auto;
23
23
  background: var(--dsw-specific-input-major, color-mix(in srgb, currentColor 4%, transparent));
24
- box-shadow: var(--dsw-elevation-soft, none); font-size: 14px; line-height: 24px;
25
- border: 0; border-radius: 22px; flex-direction: column; gap: 0; padding-top: 4px; display: flex; position: relative; min-width: 0; }
24
+ box-shadow: var(--dsw-elevation-soft, none); font-size: var(--dsh-content-font-size, 14px);
25
+ line-height: calc(24px + var(--dsh-content-font-delta, 0px));
26
+ border: 0; border-radius: var(--dsw-radius-panel, 22px); flex-direction: column; gap: 0; padding-top: 4px; display: flex; position: relative; min-width: 0; }
27
+ /* 主模式 surface 复刻官方 composer root:侧向 clearance 内缩 + 底部 4px,
28
+ 卡片与数据行由数据行自身 padding-top 定距(官方 root 无 gap,dock 上边距 4px)。
29
+ side/float 形态自带铺满布局,不参与。 */
30
+ .dci-surface:not([data-side]):not([data-float]) { padding: 0 var(--dsh-composer-side-clearance, 0px) 4px; align-items: center; gap: 0; }
26
31
  /* 卡片边缘调节把手:左右调宽、上下调高(对称灵敏度),悬停显形。 */
27
32
  .dci-rip { position: absolute; z-index: 60; touch-action: none; }
28
33
  .dci-rip[data-axis="y"] { left: 12px; right: 12px; height: 9px; cursor: ns-resize; }
@@ -388,10 +393,25 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
388
393
  background: var(--dsw-alias-label-primary-foreground, #fff); transition: transform 120ms ease; }
389
394
  .dci-switch[aria-checked="true"]::after { transform: translateX(16px); }
390
395
 
391
- /* ===== 数据行(官方 bOPqQW 复刻:居中双胶囊)===== */
392
- .dci-stat-root { max-width: var(--dsh-chat-content-width, 100%); box-sizing: border-box; width: 100%;
393
- padding: 4px calc(var(--dsh-composer-side-clearance, 0px) + 16px) 0; font-size: 13px; line-height: 20px;
394
- justify-content: center; gap: 12px; margin: 0 auto; display: flex; }
396
+
397
+ /* ===== 原图预览(官方 primitives ImageLightbox 逐值复刻)===== */
398
+ .dci-lightbox { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 40px; }
399
+ .dci-lightbox-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.55));
400
+ backdrop-filter: var(--dsw-mask-blur, blur(4px)); }
401
+ .dci-lightbox-image { position: relative; max-width: min(100%, 1600px); max-height: calc(100vh - 80px);
402
+ object-fit: contain; border-radius: var(--dsw-radius-lg, 16px); background: var(--dsw-specific-input-major);
403
+ border: 0; box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35)); }
404
+ .dci-lightbox-close { position: fixed; top: 20px; right: 20px; z-index: 1; display: grid; place-items: center;
405
+ width: 36px; height: 36px; border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-border-l2, transparent));
406
+ border-radius: 999px; corner-shape: round; background: var(--dsw-specific-input-major);
407
+ color: var(--dsw-alias-label-primary, currentColor); cursor: pointer; }
408
+ .dci-lightbox-close:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, currentColor)); outline-offset: 3px; }
409
+
410
+ /* ===== 数据行(官方 uV2eYG_dock 复刻:root 内居中、无横向 padding)===== */
411
+ .dci-stat-root { max-width: 100%; box-sizing: border-box; width: 100%;
412
+ padding: 4px 0 0; font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
413
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
414
+ justify-content: center; gap: 12px; display: flex; }
395
415
  .dci-stat-anchor { min-width: 0; display: inline-flex; position: relative; }
396
416
 
397
417
  /* 数据行详情面板(官方 bRhRbq 复刻:锚点上方弹出) */
@@ -411,7 +431,7 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
411
431
  gap: 6px 16px; margin: 0; display: grid; }
412
432
  .dci-stat-details dt, .dci-stat-details dd { min-width: 0; margin: 0; }
413
433
  .dci-stat-details dd { color: var(--dsw-alias-label-secondary, inherit); font-variant-numeric: tabular-nums; text-align: right; }
414
- .dci-stat-pill { box-sizing: border-box; max-width: 100%; color: var(--dsw-alias-label-tertiary, currentColor);
434
+ .dci-stat-pill { font: inherit; box-sizing: border-box; max-width: 100%; color: var(--dsw-alias-label-tertiary, currentColor);
415
435
  white-space: nowrap; background: transparent; border: none; border-radius: 24px; align-items: center; gap: 6px;
416
436
  padding: 1px 8px; display: inline-flex; cursor: pointer; font-variant-numeric: tabular-nums; }
417
437
  .dci-stat-pill svg { flex: none; width: 14px; height: 14px; }