dsh-remote-plugin 0.6.18 → 0.6.20

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/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
7
  */
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
- import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
10
10
  import { stat } from 'node:fs/promises'
11
11
  import net from 'node:net'
12
12
  import { homedir, hostname, networkInterfaces } from 'node:os'
@@ -14,7 +14,7 @@ import { dirname, extname, normalize, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
16
16
  export const name = 'dsh-remote'
17
- export const inject = ['webServer', 'commands', 'agents']
17
+ export const inject = ['webServer', 'commands', 'agents', 'connection']
18
18
 
19
19
  const MOUNT = '/remote'
20
20
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
@@ -68,6 +68,42 @@ try {
68
68
 
69
69
  // DSH 实际监听地址由 apply 时从 webServer 服务读取
70
70
  let dshListen = { host: '127.0.0.1', port: 3080 }
71
+ let dshConnection = null
72
+
73
+ function dshUpstreamCookieFile() {
74
+ return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
75
+ }
76
+
77
+ /**
78
+ * DSH 0.1.2-alpha.1 起,Host RPC 与 WebSocket 都要求浏览器会话 Cookie。
79
+ * 新版 connection 服务可把仅进程内可见的启动令牌兑换为 Cookie;旧版没有
80
+ * authenticatedUrl,直接跳过即可。文件只让同一用户的独立网关读取。
81
+ */
82
+ async function refreshDshUpstreamCookie() {
83
+ if (typeof dshConnection?.authenticatedUrl !== 'function') return false
84
+ const upstream = `http://${dshListen.host}:${dshListen.port}`
85
+ try {
86
+ const loginUrl = dshConnection.authenticatedUrl(upstream)
87
+ const response = await fetch(loginUrl, {
88
+ redirect: 'manual',
89
+ signal: AbortSignal.timeout(2500),
90
+ })
91
+ const setCookie = response.headers.get('set-cookie') || ''
92
+ const cookie = setCookie.split(';', 1)[0].trim()
93
+ if (response.status !== 303 || !cookie.includes('=') || cookie.length > 4096 || /[\0\r\n]/.test(cookie)) {
94
+ throw new Error(`认证交换返回 HTTP ${response.status}`)
95
+ }
96
+ const file = dshUpstreamCookieFile()
97
+ mkdirSync(dirname(file), { recursive: true })
98
+ writeFileSync(file, cookie + '\n', { mode: 0o600 })
99
+ chmodSync(file, 0o600)
100
+ return true
101
+ } catch (e) {
102
+ // 禁止记录带启动令牌的 URL 或 Cookie,只保留不含凭据的错误摘要。
103
+ logGateway('刷新 DSH 上游认证失败: ' + (e?.message || String(e)))
104
+ return false
105
+ }
106
+ }
71
107
 
72
108
  function lanIPs() {
73
109
  const out = [...configuredAdvertisedHosts()]
@@ -169,6 +205,7 @@ function runExit(cmd, args) {
169
205
 
170
206
  const GATEWAY_ENV_KEYS = [
171
207
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
208
+ 'DSH_REMOTE_DSH_COOKIE_FILE',
172
209
  'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
173
210
  'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
174
211
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
@@ -363,6 +400,7 @@ function ensureGateway() {
363
400
  if (ensurePromise) return ensurePromise
364
401
  ensurePromise = (async () => {
365
402
  try {
403
+ await refreshDshUpstreamCookie()
366
404
  const health = await gatewayRunning()
367
405
  if (!health.running) {
368
406
  const out = await startGateway()
@@ -740,7 +778,9 @@ async function serveStatic(req, res, ctx) {
740
778
  commandNames = ['list-error: ' + (e?.message || String(e))]
741
779
  }
742
780
  const signal = AbortSignal.timeout(30000)
743
- const result = await ctx.commands.execute(agent, line, signal)
781
+ const result = ctx.commands.execute.length === 3
782
+ ? await ctx.commands.execute(agent, line, signal)
783
+ : await ctx.commands.execute(agent, line, [], signal)
744
784
  sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
745
785
  } catch (e) {
746
786
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
@@ -783,6 +823,7 @@ async function serveStatic(req, res, ctx) {
783
823
 
784
824
  export function apply(ctx) {
785
825
  dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
826
+ dshConnection = ctx.connection
786
827
  ctx.effect(() => ctx.webServer.register({
787
828
  kind: 'prefix',
788
829
  path: MOUNT,
@@ -794,4 +835,10 @@ export function apply(ctx) {
794
835
  })
795
836
  // DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
796
837
  void ensureGateway()
838
+ // apply 可能早于 Web 监听完成;延迟再交换一次新版 DSH 的会话 Cookie。
839
+ ctx.effect(() => {
840
+ const timer = setTimeout(() => { void ensureGateway() }, 5000)
841
+ timer.unref?.()
842
+ return () => clearTimeout(timer)
843
+ }, 'dsh-remote: refresh upstream authentication')
797
844
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -3,9 +3,9 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <meta name="theme-color" content="#05348B">
6
+ <meta name="theme-color" content="#0D1117">
7
7
  <title>DSH Remote · 管理</title>
8
- <script>/* 首帧前应用皮肤 */ (function(){try{var t=localStorage.getItem('dshTheme');if(t!=='default'&&t!=='dark'&&t!=='light'&&t!=='neutral'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'default'}document.documentElement.setAttribute('data-theme',t)}catch(e){}})()</script>
8
+ <script>/* 首帧前应用皮肤 */ (function(){try{var t=localStorage.getItem('dshTheme');if(t!=='default'&&t!=='dark'&&t!=='light'&&t!=='neutral'&&t!=='mono'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'default'}document.documentElement.setAttribute('data-theme',t)}catch(e){}})()</script>
9
9
  <link rel="stylesheet" href="../styles.css">
10
10
  <style>
11
11
  .admin-wrap { max-width: 1180px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
@@ -87,8 +87,8 @@
87
87
  .device-key-toggle-actions { display:flex; align-items:center; gap:8px; flex:none; }
88
88
  .device-key-switch { position:relative; width:42px; height:24px; flex:none; }
89
89
  .device-key-switch input { position:absolute; opacity:0; pointer-events:none; }
90
- .device-key-switch span { position:absolute; inset:0; border:1px solid var(--dsr-line); border-radius:999px; background:var(--dsr-bg-2); cursor:pointer; transition:.18s ease; }
91
- .device-key-switch span::after { content:''; position:absolute; width:18px; height:18px; left:2px; top:2px; border-radius:50%; background:var(--dsr-muted); transition:.18s ease; }
90
+ .device-key-switch span { position:absolute; inset:0; border:1px solid var(--dsr-line); border-radius:999px; background:var(--dsr-bg-2); cursor:pointer; transition:background-color .18s ease,border-color .18s ease; }
91
+ .device-key-switch span::after { content:''; position:absolute; width:18px; height:18px; left:2px; top:2px; border-radius:50%; background:var(--dsr-muted); transition:transform .18s ease,background-color .18s ease; }
92
92
  .device-key-switch input:checked + span { border-color:var(--dsr-accent-line); background:var(--dsr-accent-soft); }
93
93
  .device-key-switch input:checked + span::after { transform:translateX(18px); background:var(--dsr-accent-strong); }
94
94
  .device-key-switch input:focus-visible + span { outline:2px solid var(--dsr-accent-strong); outline-offset:2px; }
@@ -126,8 +126,8 @@
126
126
  .host-ip-use { color: var(--dsr-muted); }
127
127
  .host-ip-switch { position: relative; display: inline-block; width: 38px; height: 22px; vertical-align: middle; }
128
128
  .host-ip-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
129
- .host-ip-switch span { position: absolute; inset: 0; border: 1px solid var(--dsr-line); border-radius: 999px; background: var(--dsr-bg-2); cursor: pointer; transition: .18s ease; }
130
- .host-ip-switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: var(--dsr-muted); transition: .18s ease; }
129
+ .host-ip-switch span { position: absolute; inset: 0; border: 1px solid var(--dsr-line); border-radius: 999px; background: var(--dsr-bg-2); cursor: pointer; transition: background-color .18s ease, border-color .18s ease; }
130
+ .host-ip-switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: var(--dsr-muted); transition: transform .18s ease, background-color .18s ease; }
131
131
  .host-ip-switch input:checked + span { border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
132
132
  .host-ip-switch input:checked + span::after { transform: translateX(16px); background: var(--dsr-accent-strong); }
133
133
  .host-ip-switch input:focus-visible + span { outline: 2px solid var(--dsr-accent-strong); outline-offset: 2px; }
@@ -186,7 +186,6 @@
186
186
  width: 248px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 14px;
187
187
  box-shadow: 0 12px 34px var(--dsr-shadow); padding: 6px;
188
188
  display: flex; flex-direction: column; gap: 2px;
189
- animation: fb-in .18s ease;
190
189
  }
191
190
  .fb-item {
192
191
  min-height: 44px; display: flex; align-items: center; gap: 10px;
@@ -204,7 +203,6 @@
204
203
  .fb-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
205
204
  .fb-name { font-weight: 600; }
206
205
  .fb-desc { font-size: 11px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
207
- @keyframes fb-in { from { opacity: 0; transform: translateY(-6px) } to { opacity: 1; transform: translateY(0) } }
208
206
  /* 窄屏压缩顶栏: 图标化文字按钮, 保证全部按钮完整可见 */
209
207
  @media (max-width: 900px) {
210
208
  .gh-label, .t-label { display: none; }
@@ -331,8 +329,7 @@
331
329
  @media (max-width: 900px) {
332
330
  .admin-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; row-gap: 10px; }
333
331
  .admin-title .left { min-width: 0; }
334
- .admin-title .right { grid-column: 1 / -1; justify-content: flex-start; min-width: 0; overflow-x: auto; scrollbar-width: none; }
335
- .admin-title .right::-webkit-scrollbar { display: none; }
332
+ .admin-title .right { grid-column: 1 / -1; justify-content: flex-start; min-width: 0; flex-wrap: wrap; overflow: visible; }
336
333
  }
337
334
  @media (max-width: 440px) {
338
335
  .admin-title h1 { display: block; min-width: 0; overflow: visible; font-size: 18px; }
@@ -381,12 +378,12 @@
381
378
  </div>
382
379
  </div>
383
380
 
384
- <div id="login-view" class="login-card hidden">
385
- <input id="token-input" type="password" placeholder="输入网关访问令牌" autocomplete="off" data-i18n-placeholder="tokenPlaceholder">
381
+ <div id="login-view" class="login-card hidden" data-motion-view>
382
+ <input id="token-input" name="gateway-token" type="password" placeholder="输入网关访问令牌" autocomplete="off" aria-label="网关访问令牌" data-i18n-placeholder="tokenPlaceholder" data-i18n-aria="tokenPlaceholder">
386
383
  <button id="btn-login" class="mini-btn" data-i18n="enter">进入</button>
387
384
  </div>
388
385
 
389
- <div id="main-view" class="hidden">
386
+ <div id="main-view" class="hidden" data-motion-view>
390
387
  <section id="admin-hero" class="admin-hero" aria-live="polite">
391
388
  <div class="admin-hero-copy">
392
389
  <div class="admin-eyebrow" data-i18n="hero.eyebrow">GATEWAY CONTROL</div>
@@ -455,8 +452,8 @@
455
452
  </div>
456
453
 
457
454
  <div id="gateway-port-row" class="gateway-port-row hidden">
458
- <label data-i18n="gatewayPort">网关端口</label>
459
- <input id="gateway-port-input" type="number" min="1" max="65535" inputmode="numeric" placeholder="8787">
455
+ <label for="gateway-port-input" data-i18n="gatewayPort">网关端口</label>
456
+ <input id="gateway-port-input" name="gateway-port" type="number" min="1" max="65535" inputmode="numeric" autocomplete="off" placeholder="8787">
460
457
  <span id="gateway-port-current" class="muted">—</span>
461
458
  <button id="btn-save-port" class="mini-btn" data-i18n="savePort">保存</button>
462
459
  </div>
@@ -560,7 +557,7 @@
560
557
  'doctor.realtime': '实时消息通道', 'doctor.realtimePass': 'mux 与 host 通道均已连接', 'doctor.realtimeFail': '实时通道未全部建立;可先使用轮询,检查 DSH 后会自动恢复',
561
558
  'doctor.start': '启动', 'doctor.showQr': '显示二维码', 'doctor.copyAddress': '复制地址', 'doctor.copyCommand': '复制命令',
562
559
  'doctor.reportCopied': '诊断报告已复制', 'doctor.commandCopied': '防火墙命令已复制,请确认网络范围后手动执行', 'doctor.addressCopied': '服务器地址已复制',
563
- 'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
560
+ 'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔', 'theme.mono': '黑曜白',
564
561
  'theme.panelTitle': '选择配色', 'theme.close': '关闭',
565
562
  'repo': '仓库',
566
563
  'donateTitle': '赞赏支持', 'donateThanks': '感谢你的支持 ☕',
@@ -670,7 +667,7 @@
670
667
  'doctor.realtime': 'Realtime channels', 'doctor.realtimePass': 'Both mux and host channels are connected', 'doctor.realtimeFail': 'Realtime channels are incomplete. Polling remains available and recovery is automatic.',
671
668
  'doctor.start': 'Start', 'doctor.showQr': 'Show QR', 'doctor.copyAddress': 'Copy address', 'doctor.copyCommand': 'Copy command',
672
669
  'doctor.reportCopied': 'Diagnostic report copied', 'doctor.commandCopied': 'Firewall command copied. Review the network scope before running it.', 'doctor.addressCopied': 'Server address copied',
673
- 'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
670
+ 'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower', 'theme.mono': 'Monochrome',
674
671
  'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
675
672
  'repo': 'Repo',
676
673
  'donateTitle': 'Support', 'donateThanks': 'Thanks for your support ☕',
@@ -767,6 +764,8 @@
767
764
  <script src="../qrcode.min.js"></script>
768
765
  <script src="../i18n.js"></script>
769
766
  <script src="../theme.js"></script>
767
+ <script src="../vendor/gsap/gsap.min.js"></script>
768
+ <script src="../motion.js"></script>
770
769
  <script src="../admin.js"></script>
771
770
  </body>
772
771
  </html>
package/public/admin.js CHANGED
@@ -935,10 +935,11 @@ function renderLangBtn() {
935
935
  }
936
936
 
937
937
  const THEME_META = [
938
- { id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
939
- { id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
940
- { id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
941
- { id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
938
+ { id: 'default', sw: ['#0D1117', '#21262D', '#58A6FF'] },
939
+ { id: 'dark', sw: ['#161316', '#302323', '#FFB86B'] },
940
+ { id: 'light', sw: ['#F5F7F8', '#FFFFFF', '#176B87'] },
941
+ { id: 'neutral', sw: ['#EEF1E8', '#FAFBF6', '#47643C'] },
942
+ { id: 'mono', sw: ['#050505', '#333333', '#F5F5F5'] }
942
943
  ]
943
944
 
944
945
  function renderThemeBtn() {
@@ -957,7 +958,7 @@ function renderThemeOptions() {
957
958
  if (!box) return
958
959
  const cur = window.DSHTheme.get()
959
960
  box.innerHTML = THEME_META.map(m => `
960
- <button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
961
+ <button type="button" class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" aria-pressed="${m.id === cur}" title="${t('theme.' + m.id)}">
961
962
  <span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
962
963
  <span class="theme-name">${t('theme.' + m.id)}</span>
963
964
  <span class="theme-check">${m.id === cur ? '✓' : ''}</span>
package/public/app.js CHANGED
@@ -1673,6 +1673,24 @@ function goalOf(s) {
1673
1673
  if (!p) return null
1674
1674
  return p.goal && typeof p.goal === 'object' ? p.goal : p
1675
1675
  }
1676
+ const COLLAPSED_GOALS_KEY = 'dshCollapsedGoalsV1'
1677
+ function collapsedGoals() {
1678
+ try {
1679
+ const value = JSON.parse(LS.get(COLLAPSED_GOALS_KEY, '[]'))
1680
+ return Array.isArray(value) ? value.filter(item => item && typeof item.sessionId === 'string' && typeof item.goalId === 'string') : []
1681
+ } catch { return [] }
1682
+ }
1683
+ function goalDisplayId(goal) { return String(goal?.id || '__current__') }
1684
+ function isGoalCollapsed(sessionId, goal) {
1685
+ const goalId = goalDisplayId(goal)
1686
+ return collapsedGoals().some(item => item.sessionId === sessionId && item.goalId === goalId)
1687
+ }
1688
+ function setGoalCollapsed(sessionId, goal, collapsed) {
1689
+ const goalId = goalDisplayId(goal)
1690
+ const next = collapsedGoals().filter(item => item.sessionId !== sessionId)
1691
+ if (collapsed) next.push({ sessionId, goalId })
1692
+ LS.set(COLLAPSED_GOALS_KEY, JSON.stringify(next.slice(-100)))
1693
+ }
1676
1694
 
1677
1695
  function updatePendingBadge() {
1678
1696
  const pending = state.approvals.length + state.questions.length
@@ -2090,6 +2108,7 @@ async function openSession(id) {
2090
2108
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
2091
2109
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2092
2110
  renderQueue()
2111
+ renderSessionPending()
2093
2112
  restoreCachedHistory()
2094
2113
  await loadHistory(true)
2095
2114
  renderSessionCards()
@@ -2122,6 +2141,7 @@ async function closeSession() {
2122
2141
  const discard = await shouldDiscardEmptySession(sessionId)
2123
2142
  if (state.current !== sessionId) return
2124
2143
  state.current = null
2144
+ renderSessionPending()
2125
2145
  if (discard) removeLocalSessionRecord(sessionId)
2126
2146
  setComposerFullscreen(false)
2127
2147
  clearComposerImages()
@@ -2684,15 +2704,23 @@ async function renderSessionCards() {
2684
2704
  let html = ''
2685
2705
 
2686
2706
  if (goal && !isGoalTerminal(goal)) {
2687
- html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
2688
- <div class="goal-obj">${esc(goal.objective || '')}</div>
2689
- <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
2690
- <div class="goal-actions">
2691
- ${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">' + t('goal.pause') + '</button>' : '<button class="mini-btn" data-goal="resume">' + t('goal.resume') + '</button>'}
2692
- <button class="mini-btn" data-goal="complete">${t('goal.complete')}</button>
2693
- <button class="mini-btn" data-goal="edit">${t('goal.edit')}</button>
2694
- <button class="mini-btn" data-goal="clear">${t('goal.clear')}</button>
2695
- </div></div>`
2707
+ const collapsed = isGoalCollapsed(sessionId, goal)
2708
+ html += `<div class="goal-disclosure${collapsed ? ' is-collapsed' : ''}">
2709
+ <div id="goal-panel-mobile" class="card goal-card${collapsed ? ' hidden' : ''}">
2710
+ <div class="goal-card-head"><div class="card-title">${t('goal.title')}</div>
2711
+ <button type="button" class="goal-collapse-btn" data-goal-collapse="1" aria-expanded="true" aria-controls="goal-panel-mobile" title="${esc(t('goal.collapse'))}"><span aria-hidden="true">›</span>${t('goal.collapseShort')}</button>
2712
+ </div>
2713
+ <div class="goal-obj">${esc(goal.objective || '')}</div>
2714
+ <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
2715
+ <div class="goal-actions">
2716
+ ${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">' + t('goal.pause') + '</button>' : '<button class="mini-btn" data-goal="resume">' + t('goal.resume') + '</button>'}
2717
+ <button class="mini-btn" data-goal="complete">${t('goal.complete')}</button>
2718
+ <button class="mini-btn" data-goal="edit">${t('goal.edit')}</button>
2719
+ <button class="mini-btn" data-goal="clear">${t('goal.clear')}</button>
2720
+ </div>
2721
+ </div>
2722
+ <button type="button" class="goal-side-tab${collapsed ? '' : ' hidden'}" data-goal-collapse="0" aria-expanded="false" aria-controls="goal-panel-mobile" aria-label="${esc(t('goal.expand'))}" title="${esc(t('goal.expand'))}"><span aria-hidden="true">‹</span><span>${t('goal.title')}</span><small>${t('goal.collapsedHint')}</small></button>
2723
+ </div>`
2696
2724
  }
2697
2725
  if (todos?.items?.length) {
2698
2726
  html += `<div class="card"><div class="card-title">${t('todos.title')}</div>${todos.items.map(t =>
@@ -2702,6 +2730,15 @@ async function renderSessionCards() {
2702
2730
  box.innerHTML = html
2703
2731
  box.querySelectorAll('[data-goal]').forEach(btn =>
2704
2732
  btn.addEventListener('click', () => goalAction(btn.dataset.goal)))
2733
+ box.querySelectorAll('[data-goal-collapse]').forEach(btn =>
2734
+ btn.addEventListener('click', () => {
2735
+ const collapse = btn.dataset.goalCollapse === '1'
2736
+ const currentGoal = goalOf(state.byId.get(state.current))
2737
+ if (!currentGoal) return
2738
+ setGoalCollapsed(state.current, currentGoal, collapse)
2739
+ void renderSessionCards()
2740
+ requestAnimationFrame(() => box.querySelector(`[data-goal-collapse="${collapse ? '0' : '1'}"]`)?.focus())
2741
+ }))
2705
2742
 
2706
2743
  // 子代理
2707
2744
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
@@ -3294,7 +3331,7 @@ function renderOverview() {
3294
3331
  $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
3295
3332
  const title = titleOf(state.byId.get(item.sessionId))
3296
3333
  if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
3297
- <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item.reason || t('pending.noReason'))} · ${esc(title)}</span></span>
3334
+ <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(approvalDetail(item))} · ${esc(title)}</span></span>
3298
3335
  <span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
3299
3336
  </div>`
3300
3337
  return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
@@ -3359,7 +3396,7 @@ function renderPending() {
3359
3396
  const title = titleOf(state.byId.get(a.sessionId))
3360
3397
  return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
3361
3398
  <div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
3362
- <div class="pc-desc">${esc(a.reason || t('pending.noReason'))}</div>
3399
+ <div class="pc-desc">${esc(approvalDetail(a))}</div>
3363
3400
  <div class="pc-session">${esc(title)}</div>
3364
3401
  <div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
3365
3402
  </div>`
@@ -3379,10 +3416,48 @@ function renderPending() {
3379
3416
  })
3380
3417
  list.querySelectorAll('[data-question]').forEach(btn =>
3381
3418
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
3419
+ renderSessionPending()
3382
3420
  updatePendingBadge()
3383
3421
  renderOverview()
3384
3422
  }
3385
3423
 
3424
+ function approvalDetail(a) {
3425
+ const lines = []
3426
+ if (a?.reason) lines.push(String(a.reason))
3427
+ if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
3428
+ if (a?.callId) lines.push(`callId: ${a.callId}`)
3429
+ return lines.join('\n') || t('pending.noReason')
3430
+ }
3431
+
3432
+ function renderSessionPending() {
3433
+ const box = $('session-pending')
3434
+ if (!box) return
3435
+ const sessionId = state.current
3436
+ const items = [
3437
+ ...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
3438
+ ...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
3439
+ ]
3440
+ box.classList.toggle('hidden', !sessionId || !items.length)
3441
+ if (!sessionId || !items.length) { box.innerHTML = ''; return }
3442
+ box.innerHTML = `<div class="session-pending-head"><span>${esc(t('overview.attention'))}</span><span>${esc(t('pending.count', { n: items.length }))}</span></div><div class="session-pending-list">${items.map(({ kind, item }) => {
3443
+ if (kind === 'approval') return `<article class="pending-card approval" data-session-approval="${esc(item.approvalId)}">
3444
+ <div class="pc-title">${esc(item.toolName || t('tool.default'))}</div>
3445
+ <div class="pc-desc">${esc(approvalDetail(item))}</div>
3446
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-session-approve="0">${t('pending.reject')}</button></div>
3447
+ </article>`
3448
+ return `<article class="pending-card question" data-session-question="${esc(item.rpcId)}">
3449
+ <div class="pc-title">❓ ${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / ') || t('notify.questionTitle'))}</div>
3450
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-answer>${t('pending.answer')}</button></div>
3451
+ </article>`
3452
+ }).join('')}</div>`
3453
+ box.querySelectorAll('[data-session-approve]').forEach(button => {
3454
+ button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1'))
3455
+ })
3456
+ box.querySelectorAll('[data-session-answer]').forEach(button => {
3457
+ button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion)))
3458
+ })
3459
+ }
3460
+
3386
3461
  async function approveApproval(id, allow) {
3387
3462
  const a = state.approvals.find(x => x.approvalId === id)
3388
3463
  if (!a) return
@@ -5728,6 +5803,8 @@ function showView(id) {
5728
5803
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
5729
5804
  document.body.classList.toggle('in-session', id === 'view-session')
5730
5805
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
5806
+ // 主页已有上下文明确的刷新按钮,避免顶栏出现第二个同义入口。
5807
+ $('btn-refresh')?.classList.toggle('hidden', id === 'view-activity')
5731
5808
  window.DshMotion?.view($(id))
5732
5809
  window.scrollTo(0, 0)
5733
5810
  if (id === 'view-files' && !state.fs.loaded) {
@@ -6411,10 +6488,11 @@ function renderLangBtn() {
6411
6488
  }
6412
6489
 
6413
6490
  const THEME_META = [
6414
- { id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
6415
- { id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
6416
- { id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
6417
- { id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
6491
+ { id: 'default', sw: ['#0D1117', '#21262D', '#58A6FF'] },
6492
+ { id: 'dark', sw: ['#161316', '#302323', '#FFB86B'] },
6493
+ { id: 'light', sw: ['#F5F7F8', '#FFFFFF', '#176B87'] },
6494
+ { id: 'neutral', sw: ['#EEF1E8', '#FAFBF6', '#47643C'] },
6495
+ { id: 'mono', sw: ['#050505', '#333333', '#F5F5F5'] }
6418
6496
  ]
6419
6497
 
6420
6498
  function renderThemeBtn() {
@@ -6427,7 +6505,7 @@ function renderThemeOptions() {
6427
6505
  if (!box) return
6428
6506
  const cur = window.DSHTheme.get()
6429
6507
  box.innerHTML = THEME_META.map(m => `
6430
- <button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
6508
+ <button type="button" class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" aria-pressed="${m.id === cur}" title="${t('theme.' + m.id)}">
6431
6509
  <span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
6432
6510
  <span class="theme-name">${t('theme.' + m.id)}</span>
6433
6511
  <span class="theme-check">${m.id === cur ? '✓' : ''}</span>
@@ -8,6 +8,7 @@ html, body {
8
8
  .hidden { display: none !important; }
9
9
  .ds-app { position: fixed; inset: 0; display: flex; overflow: hidden; }
10
10
  .ds-sidebar { width: 280px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--dsr-line); background: linear-gradient(180deg, var(--dsr-panel), var(--dsr-bg)); box-shadow: 12px 0 34px var(--dsr-shadow); overflow: hidden; }
11
+ .ds-sidebar-backdrop { display: none; }
11
12
  .ds-brand { display: flex; align-items: center; gap: 8px; padding: 17px 18px 12px; font-weight: 750; font-size: 15px; letter-spacing: .1px; }
12
13
  .ds-logo { color: var(--dsr-accent-strong); }
13
14
  .ds-brand-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -224,15 +225,15 @@ html.reorder-scroll-lock body { overscroll-behavior: none; }
224
225
  .ds-overview-attention-list, .ds-overview-session-list { display:flex; flex-direction:column; gap:8px; }
225
226
  .ds-overview-attention-item, .ds-overview-session-item { width:100%; min-width:0; display:flex; align-items:center; gap:10px; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:12px; background:var(--dsr-bg-2); color:var(--dsr-text); text-align:left; }
226
227
  button.ds-overview-attention-item, button.ds-overview-session-item { cursor:pointer; font:inherit; }
227
- .ds-overview-attention-item { border-left:3px solid var(--dsr-warning); }
228
+ .ds-overview-attention-item { align-items:flex-start; border-left:3px solid var(--dsr-warning); }
228
229
  .ds-overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
229
230
  .ds-overview-mark { flex:0 0 auto; width:26px; height:26px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
230
231
  .ds-overview-attention-item.question .ds-overview-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
231
232
  .ds-overview-copy { flex:1; min-width:0; }
232
- .ds-overview-item-title, .ds-overview-item-desc { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
233
+ .ds-overview-item-title, .ds-overview-item-desc { display:block; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
233
234
  .ds-overview-item-title { font-size:12px; font-weight:650; }
234
- .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; }
235
- .ds-overview-actions { flex:0 0 auto; display:flex; gap:4px; }
235
+ .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; white-space:normal; }
236
+ .ds-overview-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
236
237
  .ds-overview-actions .ds-btn { min-height:27px; padding:2px 8px; font-size:11px; }
237
238
  .ds-overview-actions .allow { background:var(--dsr-success-soft); border-color:var(--dsr-success-line); color:var(--dsr-success); }
238
239
  .ds-overview-actions .reject { background:var(--dsr-warning-soft); border-color:var(--dsr-warning-line); color:var(--dsr-warning); }
@@ -286,9 +287,37 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
286
287
  .ds-presets-guide { padding: 2px 8px 10px; font-size: 11px; line-height: 1.5; }
287
288
  .ds-session-cards { flex: none; max-height: 220px; overflow-y: auto; padding: 10px 22px 0; display: flex; flex-direction: column; gap: 8px; }
288
289
  .ds-session-cards:empty { display: none; }
290
+ .ds-session-pending { flex:none; margin:10px 22px 0; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
291
+ .ds-session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
292
+ .ds-session-pending-list { display:flex; flex-direction:column; gap:8px; }
293
+ .ds-session-pending .ds-notif-card { min-width:0; box-shadow:none; animation:none; }
294
+ .ds-session-pending .ds-notif-title, .ds-session-pending .ds-notif-body { overflow-wrap:anywhere; word-break:break-word; }
289
295
  .ds-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
290
296
  .ds-card-title { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; margin-bottom: 6px; text-transform: uppercase; }
291
- .ds-goal-obj { font-size: 13px; line-height: 1.55; word-break: break-word; }
297
+ .ds-goal-disclosure { width: 100%; min-width: 0; display: flex; justify-content: flex-end; }
298
+ .ds-goal-disclosure .ds-goal-card { width: 100%; min-width: 0; }
299
+ .ds-goal-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
300
+ .ds-goal-card-head .ds-card-title { min-width: 0; margin-bottom: 0; }
301
+ .ds-goal-collapse-btn,
302
+ .ds-goal-side-tab {
303
+ touch-action: manipulation; border: 1px solid var(--dsr-line); background: var(--dsr-bg-2); color: var(--dsr-muted);
304
+ font: inherit; cursor: pointer;
305
+ }
306
+ .ds-goal-collapse-btn { min-height: 32px; display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 8px; font-size: 11.5px; }
307
+ .ds-goal-collapse-btn > span { color: var(--dsr-accent-strong); font-size: 17px; line-height: 1; }
308
+ .ds-goal-side-tab {
309
+ min-height: 42px; max-width: min(100%, 210px); display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px 6px 10px;
310
+ border-right: 0; border-radius: 11px 0 0 11px; box-shadow: -5px 6px 18px var(--dsr-shadow); color: var(--dsr-text); font-size: 12px; font-weight: 700;
311
+ }
312
+ .ds-goal-side-tab > span:first-child { color: var(--dsr-accent-strong); font-size: 19px; line-height: 1; }
313
+ .ds-goal-side-tab small { min-width: 0; color: var(--dsr-muted); font-size: 10.5px; font-weight: 500; white-space: nowrap; }
314
+ .ds-goal-collapse-btn:hover,
315
+ .ds-goal-side-tab:hover { background: var(--dsr-panel-2); color: var(--dsr-text); }
316
+ .ds-goal-collapse-btn:active,
317
+ .ds-goal-side-tab:active { transform: translateY(1px); }
318
+ .ds-goal-collapse-btn:focus-visible,
319
+ .ds-goal-side-tab:focus-visible { outline: 2px solid var(--dsr-accent-strong); outline-offset: 2px; }
320
+ .ds-goal-obj { font-size: 13px; line-height: 1.55; word-break: break-word; overflow-wrap: anywhere; }
292
321
  .ds-goal-phase { font-size: 11px; color: var(--dsr-muted); margin-top: 3px; }
293
322
  .ds-goal-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
294
323
  .ds-mini-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 26px; padding: 2px 9px; border-radius: 7px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); color: var(--dsr-text); font: inherit; font-size: 11.5px; cursor: pointer; }
@@ -350,6 +379,10 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
350
379
  .ds-settings { max-width: 720px; display: flex; flex-direction: column; gap: 10px; }
351
380
  .ds-setting-row { display: flex; align-items: center; gap: 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 14px; }
352
381
  .ds-setting-row > div:first-child { flex: 1; min-width: 0; }
382
+ .ds-setting-link { width: 100%; color: var(--dsr-text); font: inherit; text-align: left; cursor: pointer; transition: background-color .18s ease, border-color .18s ease; }
383
+ .ds-setting-link:hover, .ds-setting-link:focus-visible { background: var(--dsr-bg-2); border-color: var(--dsr-accent-line); }
384
+ .ds-setting-link .ds-setting-arrow { transition: transform .18s ease, color .18s ease; }
385
+ .ds-setting-link:hover .ds-setting-arrow, .ds-setting-link:focus-visible .ds-setting-arrow { color: var(--dsr-accent-strong); transform: translateX(2px); }
353
386
  .ds-setting-arrow,
354
387
  #settings-home .ds-setting-row .ds-btn {
355
388
  display: inline-flex; align-items: center; justify-content: center;
@@ -397,8 +430,8 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
397
430
  .ds-mini:hover { color: var(--dsr-text); }
398
431
  .ds-switch { position: relative; display: inline-block; width: 36px; height: 21px; flex-shrink: 0; }
399
432
  .ds-switch input { opacity: 0; width: 0; height: 0; }
400
- .ds-switch .ds-slider { position: absolute; inset: 0; border-radius: 999px; background: var(--dsr-panel-2); border: 1px solid var(--dsr-line); transition: .2s; }
401
- .ds-switch .ds-slider::before { content: ''; position: absolute; width: 15px; height: 15px; border-radius: 50%; left: 2px; top: 2px; background: var(--dsr-muted); transition: .2s; }
433
+ .ds-switch .ds-slider { position: absolute; inset: 0; border-radius: 999px; background: var(--dsr-panel-2); border: 1px solid var(--dsr-line); transition: background-color .2s ease, border-color .2s ease; }
434
+ .ds-switch .ds-slider::before { content: ''; position: absolute; width: 15px; height: 15px; border-radius: 50%; left: 2px; top: 2px; background: var(--dsr-muted); transition: transform .2s ease, background-color .2s ease; }
402
435
  .ds-switch input:checked + .ds-slider { background: var(--dsr-accent-soft); border-color: var(--dsr-accent); }
403
436
  .ds-switch input:checked + .ds-slider::before { transform: translateX(15px); background: var(--dsr-accent); }
404
437
 
@@ -429,7 +462,7 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
429
462
  .ds-stats-note { font-size: 11px; color: var(--dsr-muted); line-height: 1.6; margin-top: 8px; }
430
463
 
431
464
  /* 统计抽屉(右侧滑出) */
432
- .ds-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 110; width: min(540px, 100vw); background: var(--dsr-panel); border-left: 1px solid var(--dsr-line); box-shadow: -12px 0 32px rgba(0,0,0,.35); display: flex; flex-direction: column; }
465
+ .ds-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 110; width: min(540px, 100vw); background: var(--dsr-panel); border-left: 1px solid var(--dsr-line); box-shadow: -12px 0 32px rgba(0,0,0,.35); display: flex; flex-direction: column; overscroll-behavior: contain; }
433
466
  .ds-drawer.hidden { display: none; }
434
467
  .ds-drawer-head { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border-bottom: 1px solid var(--dsr-line); font-weight: 700; font-size: 15px; }
435
468
  .ds-drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 15px; scrollbar-width: thin; scrollbar-color: var(--dsr-line) transparent; }
@@ -453,7 +486,7 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
453
486
  /* 模态 */
454
487
  .ds-modal { position: fixed; inset: 0; z-index: 120; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; padding: 16px; }
455
488
  .ds-modal.hidden { display: none; }
456
- .ds-modal-card { width: min(520px, 100%); background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 14px; padding: 16px; }
489
+ .ds-modal-card { width: min(520px, 100%); background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 18px; padding: 18px; box-shadow: 0 22px 60px var(--dsr-shadow); overscroll-behavior: contain; }
457
490
  .ds-modal-title { font-weight: 700; margin-bottom: 10px; }
458
491
  .ds-modal-body { display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow-y: auto; }
459
492
  .ds-workspace-create-desc { margin: 0; color: var(--dsr-muted); line-height: 1.55; }
@@ -529,9 +562,22 @@ input:focus-visible, select:focus-visible, textarea:focus-visible {
529
562
  .ds-btn:hover { filter: brightness(1.08); }
530
563
  }
531
564
 
532
- /* 窄屏回退: 隐藏侧栏, 顶部导航, 通知卡片全宽 */
565
+ /* 窄屏回退: 侧栏变为可关闭抽屉,保留完整导航与会话上下文。 */
533
566
  @media (max-width: 1023px) {
534
- .ds-sidebar { display: none; }
567
+ .ds-sidebar {
568
+ position: fixed; inset: 0 auto 0 0; z-index: 90; display: flex;
569
+ width: min(320px, 86vw); max-width: 100%;
570
+ opacity: 0; pointer-events: none; transform: translateX(-104%);
571
+ transition: transform .24s ease, opacity .18s ease;
572
+ box-shadow: 18px 0 48px rgba(0,0,0,.38);
573
+ }
574
+ .ds-sidebar.mobile-open { opacity: 1; pointer-events: auto; transform: translateX(0); }
575
+ .ds-sidebar-backdrop {
576
+ position: fixed; inset: 0; z-index: 80; display: block;
577
+ border: 0; background: var(--dsr-overlay); cursor: default;
578
+ }
579
+ .ds-sidebar-backdrop.hidden { display: none; }
580
+ .ds-app.mobile-nav-open { overflow: hidden; }
535
581
  #btn-mobile-nav { display: inline-flex; }
536
582
  .ds-content { flex-direction: column; }
537
583
  .ds-view { padding: 10px; overflow-y: auto; }