dsh-zen-remote 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,265 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { GO_HOME_EVENT } from '../nav-store.ts'
3
+
4
+ /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
5
+ const PHONE_QUERY = '(max-width: 767px)'
6
+
7
+ /** This plugin's two dismissible sheets (session-info, home workspace/chips
8
+ * picker — MobileHome.tsx's home-sheet hosts both the workspace switcher and
9
+ * the S5 chip-customize list under the same marker). Official popupSelect
10
+ * menus (permission/model) are deliberately excluded — the design spec
11
+ * leaves those mask-tap-only, and they are the suite's own DOM, not ours to
12
+ * add gesture handling to. */
13
+ const SHEET_SELECTOR = '[data-mobile-nav="info-sheet"], [data-mobile-nav="home-sheet"]'
14
+
15
+ const CLOSE_DISTANCE = 80
16
+ /** px/ms — a fast flick closes the sheet even short of CLOSE_DISTANCE. */
17
+ const CLOSE_VELOCITY = 0.5
18
+ /** Below this the drag is a tap/jitter, not yet a pull — avoids hijacking
19
+ * the very first pixels of an upward scroll inside the sheet. */
20
+ const DRAG_COMMIT_PX = 4
21
+
22
+ function prefersReducedMotion(): boolean {
23
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
24
+ }
25
+
26
+ /** The sheet's own mask sibling — both this plugin's sheets name theirs
27
+ * with a "-mask" suffix (info-mask, home-sheet-mask) and already wire a
28
+ * click handler that calls the sheet's close(). Reusing that handler (a
29
+ * synthetic click) needs no access to the sheet's React state at all.
30
+ * Shared with the S6.1 edge-swipe-back priority chain below — both "drag
31
+ * down to dismiss" and "swipe back from the edge" close a sheet the exact
32
+ * same way. */
33
+ function closeSheet(sheet: HTMLElement): void {
34
+ sheet.parentElement?.querySelector<HTMLElement>('[data-mobile-nav$="-mask"]')?.click()
35
+ }
36
+
37
+ /**
38
+ * S6.2 — sheet drag-to-close: this plugin's two own bottom sheets (session
39
+ * info, home workspace picker) support "drag down to dismiss", mirroring
40
+ * the mask-tap close they already have. Official popupSelect menus
41
+ * (permission/model) are excluded on purpose — the design spec leaves them
42
+ * mask-tap-only, and they are the suite's own DOM, not ours to add gesture
43
+ * handling to.
44
+ *
45
+ * touchmove is registered non-passive (unlike the edge-swipe-back gesture
46
+ * below) because this gesture needs to preventDefault once it takes over, so
47
+ * the sheet's own translateY tracks the finger instead of fighting the
48
+ * scroller's native rubber-band. Before that handoff — while the sheet's
49
+ * inner content isn't scrolled to top — nothing is intercepted at all, so
50
+ * a pull down first scrolls the content as normal (spec requirement).
51
+ */
52
+ function installSheetDragClose(ctx: ClientContext): void {
53
+ ctx.effect(() => {
54
+ const narrow = window.matchMedia(PHONE_QUERY)
55
+ interface Drag { sheet: HTMLElement; startY: number; lastY: number; startTime: number; dragging: boolean }
56
+ let drag: Drag | null = null
57
+
58
+ const settle = (sheet: HTMLElement, close: boolean): void => {
59
+ if (close) {
60
+ sheet.style.transform = ''
61
+ sheet.style.transition = ''
62
+ closeSheet(sheet)
63
+ return
64
+ }
65
+ if (prefersReducedMotion()) {
66
+ sheet.style.transition = 'none'
67
+ sheet.style.transform = ''
68
+ return
69
+ }
70
+ sheet.style.transition = 'transform .22s var(--ds-ease-out, ease-in-out)'
71
+ sheet.style.transform = ''
72
+ const clear = (): void => {
73
+ sheet.style.transition = ''
74
+ sheet.removeEventListener('transitionend', clear)
75
+ }
76
+ sheet.addEventListener('transitionend', clear)
77
+ }
78
+
79
+ const onTouchStart = (event: TouchEvent): void => {
80
+ if (event.touches.length !== 1) {
81
+ drag = null
82
+ return
83
+ }
84
+ const touch = event.touches[0]
85
+ const target = event.target
86
+ if (touch === undefined || !(target instanceof Element)) {
87
+ drag = null
88
+ return
89
+ }
90
+ const sheet = target.closest<HTMLElement>(SHEET_SELECTOR)
91
+ if (sheet === null) {
92
+ drag = null
93
+ return
94
+ }
95
+ drag = { sheet, startY: touch.clientY, lastY: touch.clientY, startTime: Date.now(), dragging: false }
96
+ }
97
+
98
+ const onTouchMove = (event: TouchEvent): void => {
99
+ if (drag === null) return
100
+ const touch = event.touches[0]
101
+ if (touch === undefined) return
102
+ drag.lastY = touch.clientY
103
+ const dy = touch.clientY - drag.startY
104
+ if (!drag.dragging) {
105
+ if (drag.sheet.scrollTop > 0) return
106
+ if (dy <= DRAG_COMMIT_PX) return
107
+ drag.dragging = true
108
+ drag.sheet.style.transition = 'none'
109
+ }
110
+ event.preventDefault()
111
+ drag.sheet.style.transform = `translateY(${Math.max(0, dy)}px)`
112
+ }
113
+
114
+ const finish = (): void => {
115
+ if (drag === null) return
116
+ const { sheet, dragging, startY, lastY, startTime } = drag
117
+ drag = null
118
+ if (!dragging) return
119
+ const dy = Math.max(0, lastY - startY)
120
+ const elapsed = Math.max(1, Date.now() - startTime)
121
+ const velocity = dy / elapsed
122
+ settle(sheet, dy > CLOSE_DISTANCE || velocity > CLOSE_VELOCITY)
123
+ }
124
+
125
+ const attach = (): void => {
126
+ document.addEventListener('touchstart', onTouchStart, { passive: true })
127
+ document.addEventListener('touchmove', onTouchMove, { passive: false })
128
+ document.addEventListener('touchend', finish, { passive: true })
129
+ document.addEventListener('touchcancel', finish, { passive: true })
130
+ }
131
+ const detach = (): void => {
132
+ document.removeEventListener('touchstart', onTouchStart)
133
+ document.removeEventListener('touchmove', onTouchMove)
134
+ document.removeEventListener('touchend', finish)
135
+ document.removeEventListener('touchcancel', finish)
136
+ drag = null
137
+ }
138
+ if (narrow.matches) attach()
139
+ const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
140
+ narrow.addEventListener('change', onChange)
141
+ return () => {
142
+ narrow.removeEventListener('change', onChange)
143
+ detach()
144
+ }
145
+ }, 'dsh-mobile-nav: sheet drag-to-close')
146
+ }
147
+
148
+ /** Left-edge start zone for S6.1's swipe-back, in CSS px from the viewport's
149
+ * left edge. This used to be the gateway half's own edge-swipe-back hot zone
150
+ * (history.back, useless against an SPA) — that gesture is removed in the
151
+ * same revision that adds this one, so the 24px strip changes owner instead
152
+ * of staying carved out (design doc "手势" row, 2026-08-17 fourth revision). */
153
+ const EDGE_ZONE_PX = 24
154
+ const EDGE_SWIPE_MIN_DX = 90
155
+ const EDGE_SWIPE_RATIO = 1.6
156
+
157
+ /**
158
+ * dsh-better-sidebar's panel-open read (AGENTS.md pitfall "第三方浮层的开合
159
+ * 态可以纯 CSS `:has()` 读"): the panel's class ends in "_panel" only while
160
+ * open — "_panelHidden" is appended once closed, so the string no longer
161
+ * ends in "_panel" and this selector stops matching. Same anchor
162
+ * MobileSessionHeader.tsx's workbench button and its own close button use.
163
+ */
164
+ function betterSidebarPanelOpen(): boolean {
165
+ return document.querySelector('[data-dsh-better-sidebar] [class$="_panel"]') !== null
166
+ }
167
+
168
+ /**
169
+ * S6.1 — left-edge swipe-back priority chain (design doc "手势" row, fourth
170
+ * revision): a swipe starting in the left {@link EDGE_ZONE_PX} always closes
171
+ * the TOPMOST dismissible surface rather than always navigating, so a user
172
+ * mid-workflow (info card open, workbench panel open) gets "close that"
173
+ * instead of "leave the session" from the same gesture. Checked in the
174
+ * design doc's own order:
175
+ * 1. this plugin's session-info sheet, if open
176
+ * 2. this plugin's home sheet (workspace switcher or S5 chip-customize),
177
+ * if open
178
+ * 3. dsh-better-sidebar's workbench panel, if open (代点 its own toggle —
179
+ * same anchor MobileSessionHeader.tsx's workbench/close buttons use)
180
+ * 4. otherwise, GO_HOME_EVENT — but only from the session view; the design
181
+ * doc calls out "在 home 层→无动作" explicitly, and the phone page
182
+ * stack's current level is read straight off MobileHome's own
183
+ * `data-view` attribute (no store handle needed here — effects/*.ts is
184
+ * already all DOM reads, matching every sibling effect in this file).
185
+ */
186
+ function handleEdgeSwipeBack(): void {
187
+ const sheet = document.querySelector<HTMLElement>(SHEET_SELECTOR)
188
+ if (sheet !== null) {
189
+ closeSheet(sheet)
190
+ return
191
+ }
192
+ if (betterSidebarPanelOpen()) {
193
+ document.querySelector<HTMLButtonElement>('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click()
194
+ return
195
+ }
196
+ if (document.querySelector('[data-mobile-nav="home"]')?.getAttribute('data-view') === 'session') {
197
+ window.dispatchEvent(new CustomEvent(GO_HOME_EVENT))
198
+ }
199
+ }
200
+
201
+ /**
202
+ * S6.1 — left-edge swipe-back gesture install. Passive throughout (never
203
+ * calls preventDefault) and decided purely from the touchstart/touchend
204
+ * endpoints, exactly like the S6.1-predecessor content-area swipe this
205
+ * revision removes — so it never fights vertical (or, at the 24px strip,
206
+ * horizontal chip-row) scrolling, and needs no mid-drag tracking: the design
207
+ * doc explicitly allows "一步到位" over a followed-finger animation.
208
+ */
209
+ function installEdgeSwipeBack(ctx: ClientContext): void {
210
+ ctx.effect(() => {
211
+ const narrow = window.matchMedia(PHONE_QUERY)
212
+ let start: { x: number; y: number; eligible: boolean } | null = null
213
+
214
+ const onTouchStart = (event: TouchEvent): void => {
215
+ if (event.touches.length !== 1) {
216
+ start = null
217
+ return
218
+ }
219
+ const touch = event.touches[0]
220
+ if (touch === undefined) {
221
+ start = null
222
+ return
223
+ }
224
+ start = { x: touch.clientX, y: touch.clientY, eligible: touch.clientX <= EDGE_ZONE_PX }
225
+ }
226
+
227
+ const onTouchEnd = (event: TouchEvent): void => {
228
+ const state = start
229
+ start = null
230
+ if (state === null || !state.eligible) return
231
+ const touch = event.changedTouches[0]
232
+ if (touch === undefined) return
233
+ const dx = touch.clientX - state.x
234
+ const dy = touch.clientY - state.y
235
+ if (dx <= EDGE_SWIPE_MIN_DX || Math.abs(dx) <= EDGE_SWIPE_RATIO * Math.abs(dy)) return
236
+ handleEdgeSwipeBack()
237
+ }
238
+
239
+ const attach = (): void => {
240
+ document.addEventListener('touchstart', onTouchStart, { passive: true })
241
+ document.addEventListener('touchend', onTouchEnd, { passive: true })
242
+ }
243
+ const detach = (): void => {
244
+ document.removeEventListener('touchstart', onTouchStart)
245
+ document.removeEventListener('touchend', onTouchEnd)
246
+ start = null
247
+ }
248
+ if (narrow.matches) attach()
249
+ const onChange = (event: MediaQueryListEvent): void => (event.matches ? attach() : detach())
250
+ narrow.addEventListener('change', onChange)
251
+ return () => {
252
+ narrow.removeEventListener('change', onChange)
253
+ detach()
254
+ }
255
+ }, 'dsh-mobile-nav: left-edge swipe-back')
256
+ }
257
+
258
+ /** S6: the two-gesture set — left-edge swipe-back and sheet drag-to-close.
259
+ * Both install/uninstall their own document listeners on the phone
260
+ * breakpoint's matchMedia change, so at >= 768px this is a true no-op (no
261
+ * listeners attached at all), matching every other effect in this file. */
262
+ export function installGestures(ctx: ClientContext): void {
263
+ installEdgeSwipeBack(ctx)
264
+ installSheetDragClose(ctx)
265
+ }
@@ -0,0 +1,30 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { dotState } from '../session-dot.ts'
3
+
4
+ /**
5
+ * Session header running-status dot (S2). No official element exists to
6
+ * reposition (ConversationSessionHeader renders only the crumb title, the
7
+ * actions/utilities slots, and the tablist — dsh-client-ui-conversation
8
+ * lib/client.js:6949-7009), so this reads `ctx.sessions.list` directly
9
+ * (the same feed `useSessions` wraps) and stamps a data attribute the
10
+ * mobile stylesheet turns into a `::after` dot on the title crumb —
11
+ * "read data, self-draw" is the plan's documented fallback for this piece.
12
+ * Kept outside React: the dot must track the CURRENT session regardless of
13
+ * which component the header happens to mount, and a plain attribute +
14
+ * CSS avoids reaching into the official crumb's own DOM subtree.
15
+ */
16
+ export function installHeaderStatusDot(ctx: ClientContext): void {
17
+ ctx.effect(() => {
18
+ const apply = (): void => {
19
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
20
+ if (frame === null) return
21
+ const { current, byId } = ctx.sessions.list.getSnapshot()
22
+ const row = current === undefined ? undefined : byId[current]
23
+ const state = row === undefined ? undefined : dotState(row)
24
+ if (state === undefined) frame.removeAttribute('data-mobile-nav-dot')
25
+ else frame.setAttribute('data-mobile-nav-dot', state)
26
+ }
27
+ apply()
28
+ return ctx.sessions.list.subscribe(apply)
29
+ }, 'dsh-mobile-nav: header status dot')
30
+ }
@@ -0,0 +1,265 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+
3
+ /**
4
+ * Phone chrome: KEEP the system status bar (no fullscreen) and make it
5
+ * blend into the page. On narrow screens:
6
+ * - The viewport meta gains viewport-fit=cover, so env(safe-area-inset-top)
7
+ * is the real status-bar / notch height and the stylesheet can push every
8
+ * surface below it (off notched phones, or in a browser tab where the
9
+ * layout viewport already sits below the status bar, the inset is 0 and
10
+ * nothing shifts).
11
+ * - A theme-color meta tracks the shell background (the official theme is
12
+ * toggled by body[data-ds-dark-theme], which flips --dsw-alias-bg-base):
13
+ * Android then paints the status bar / URL bar with the page's own base
14
+ * color, so the status bar reads as part of the UI instead of a foreign
15
+ * strip. The drawer paints the same strip on iOS / notch displays.
16
+ * - gesturestart is suppressed as the legacy-iOS fallback for double-tap
17
+ * zoom; modern browsers are covered by the stylesheet's
18
+ * touch-action: manipulation (which keeps pan and pinch zoom).
19
+ */
20
+ export function installPhoneChrome(ctx: ClientContext): void {
21
+ ctx.effect(() => {
22
+ const narrow = window.matchMedia('(max-width: 1023px)')
23
+ const viewport = document.querySelector<HTMLMetaElement>('meta[name="viewport"]')
24
+ const originalViewport = viewport?.content ?? ''
25
+ const themeMeta = document.createElement('meta')
26
+ themeMeta.name = 'theme-color'
27
+ const bodyBg = (): string => getComputedStyle(document.body).backgroundColor
28
+
29
+ const sync = (): void => {
30
+ if (viewport !== null) viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover'
31
+ themeMeta.content = bodyBg()
32
+ if (themeMeta.parentElement === null) document.head.appendChild(themeMeta)
33
+ }
34
+ const restore = (): void => {
35
+ if (viewport !== null) viewport.content = originalViewport
36
+ themeMeta.remove()
37
+ }
38
+ const onGestureStart = (event: Event) => event.preventDefault()
39
+ if (narrow.matches) sync()
40
+ const onChange = (event: MediaQueryListEvent) => (event.matches ? sync() : restore())
41
+ narrow.addEventListener('change', onChange)
42
+ const observer = new MutationObserver(() => {
43
+ if (narrow.matches) themeMeta.content = bodyBg()
44
+ })
45
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
46
+ document.addEventListener('gesturestart', onGestureStart)
47
+ return () => {
48
+ narrow.removeEventListener('change', onChange)
49
+ observer.disconnect()
50
+ document.removeEventListener('gesturestart', onGestureStart)
51
+ restore()
52
+ }
53
+ }, 'dsh-mobile-nav: status bar theme + viewport + zoom guard')
54
+ }
55
+
56
+ /**
57
+ * Does the layout viewport sit shorter than the screen by at least the top
58
+ * inset env() still claims? (S1.2 predicate, wired in S1.3.)
59
+ *
60
+ * Device numbers (iPhone, iOS 26.5, standalone PWA, 393x852): innerHeight
61
+ * 793, env top 59 (= 852 - 793), env bottom 34, frame bottom 793 flush.
62
+ * The same device in a Safari TAB reads innerHeight 695 with every surface
63
+ * flush and every design value correct, which is what rules our own CSS out.
64
+ *
65
+ * What the true-branch means, settled by S1.2 + S1.3 device evidence: the
66
+ * viewport is anchored at screen y=0 and merely cut 59px short at the BOTTOM
67
+ * — not pushed down below the status bar. So the top inset is paid exactly
68
+ * once and must be left alone, while the bottom inset is a lie: the home
69
+ * indicator lives in the dead strip below the page. installSunkInset() acts
70
+ * on that, and only on --mnav-sab.
71
+ *
72
+ * The `envTop > 0` guard is what keeps every other standalone install out:
73
+ * landscape iPhone (top inset 0, the notch moves to left/right), iPad, and
74
+ * Android — where standalone also loses status-bar height off innerHeight but
75
+ * reports env top 0 — all fall through to false. Kept pure so the boundaries
76
+ * are checkable off-device: scripts/check-sunk-viewport.mjs.
77
+ */
78
+ export function isViewportSunkBelowStatusBar(input: {
79
+ standalone: boolean
80
+ screenHeight: number
81
+ innerHeight: number
82
+ envTop: number
83
+ }): boolean {
84
+ return input.standalone
85
+ && input.envTop > 0
86
+ && input.screenHeight - input.innerHeight >= input.envTop
87
+ }
88
+
89
+ /**
90
+ * iOS standalone-PWA keyboard shrink (S1.2, 2026-08-17) — the ~60px white
91
+ * band under the composer AND under the session list.
92
+ *
93
+ * Documented WebKit defect: the first time the software keyboard opens inside
94
+ * a home-screen (standalone) PWA, the layout viewport permanently loses the
95
+ * status-bar height for the rest of the app session. innerHeight,
96
+ * visualViewport.height and 100dvh all report the shrunken value together, so
97
+ * nothing inside the page can see anything wrong — the page simply ends early
98
+ * and the strip below it is system background that no CSS can reach. Reported
99
+ * as 932 -> 873 on an iPhone Pro Max; the user's device reads 852 -> 793.
100
+ * Both are exactly one status bar. See
101
+ * https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d
102
+ *
103
+ * This is why the reported symptom is asymmetric and why the earlier
104
+ * "double-counted top inset" reading was wrong: the top edge is genuinely
105
+ * correct (env top 59 is paid once, the viewport starts at screen y=0), the
106
+ * bottom is simply 59px short. It also explains a chat client being hit
107
+ * hardest — the trigger is typing, which happens on the first message.
108
+ *
109
+ * The only known cure is to make WebKit re-measure: drop a full-viewport
110
+ * element out of the box tree and force a synchronous reflow. Two departures
111
+ * from the published recipe:
112
+ * - scrollTop is saved and restored around the toggle. Un-boxing an ancestor
113
+ * resets every descendant scroller to 0, which in a chat client means the
114
+ * conversation jumps to its first message. Both happen inside one task, so
115
+ * no frame is ever painted in between and nothing flickers.
116
+ * - the baseline is the tallest innerHeight this session has actually seen,
117
+ * not the screen height. On any device where the viewport is legitimately
118
+ * short, the baseline equals the current height and this never fires.
119
+ *
120
+ * Standalone-gated, so a browser tab (and every desktop, CDP included) is a
121
+ * strict no-op — which is also why the fix cannot be regression-tested here
122
+ * and has to be confirmed on the device.
123
+ */
124
+ export function installViewportHeal(ctx: ClientContext): void {
125
+ ctx.effect(() => {
126
+ const standalone = window.matchMedia('(display-mode: standalone)').matches
127
+ || (navigator as Navigator & { standalone?: boolean }).standalone === true
128
+ /* iOS-only on purpose. The ceiling below leans on screen.height being the
129
+ height the viewport SHOULD have, which holds for an iPhone standalone
130
+ app under viewport-fit=cover and does not hold on Android, where the
131
+ navigation bar makes screen.height permanently larger than any viewport
132
+ and every keyboard dismiss would trigger a pointless reflow. */
133
+ if (!standalone || !/iPad|iPhone|iPod/.test(navigator.userAgent)) return () => {}
134
+
135
+ /* Ceiling = the tallest this session has seen, floored at the screen —
136
+ the screen half matters because the shrink outlives a page reload, so a
137
+ PWA reopened into the broken state would otherwise measure 793 as
138
+ "normal" and never heal itself. */
139
+ let tallest = window.innerHeight
140
+ const ceiling = (): number => Math.max(tallest, window.screen.height)
141
+ const onResize = (): void => {
142
+ if (window.innerHeight > tallest) tallest = window.innerHeight
143
+ }
144
+
145
+ /* Give up after three ineffective attempts per trigger: on a device whose
146
+ viewport is short for a reason a reflow cannot fix (the iOS 26.x
147
+ standalone regression family) retrying forever buys nothing. The two
148
+ budgets are separate on purpose — the cold-start attempts would
149
+ otherwise spend the whole allowance before the user ever types, and the
150
+ keyboard shrink IS reflow-curable even on a device whose cold start is
151
+ not. */
152
+ const strikes = { boot: 0, blur: 0 }
153
+ const heal = (kind: 'boot' | 'blur'): void => {
154
+ if (strikes[kind] >= 3 || ceiling() - window.innerHeight <= 4) return
155
+ const frame = document.querySelector<HTMLElement>('[data-mobile-nav="frame"]')
156
+ if (frame === null) return
157
+ const scroller = document.querySelector<HTMLElement>('[class$="_scrollBody"]')
158
+ const scrollTop = scroller?.scrollTop ?? 0
159
+ const previous = frame.style.display
160
+ frame.style.display = 'none'
161
+ void frame.offsetHeight /* forces the synchronous reflow that re-measures */
162
+ frame.style.display = previous
163
+ if (scroller !== null) scroller.scrollTop = scrollTop
164
+ strikes[kind] = ceiling() - window.innerHeight > 4 ? strikes[kind] + 1 : 0
165
+ }
166
+
167
+ /* Blur is when the keyboard starts closing; the viewport settles a beat
168
+ later. Two attempts because the dismiss animation length is not
169
+ specified anywhere, and a heal that runs too early is a silent no-op. */
170
+ const timers: number[] = []
171
+ const onFocusOut = (event: FocusEvent): void => {
172
+ if (!(event.target instanceof HTMLElement)) return
173
+ if (event.target.matches('textarea, input') !== true) return
174
+ timers.push(window.setTimeout(() => heal('blur'), 300), window.setTimeout(() => heal('blur'), 900))
175
+ }
176
+
177
+ /* Cold start (S1.3): the user's iOS 26.5 device is already 59px short on
178
+ a freshly killed-and-reopened app that never saw the keyboard, so the
179
+ keyboard trigger alone is not the whole story. Same reflow, tried once
180
+ the shell has mounted and again after it has settled, plus every return
181
+ to the foreground — iOS re-lays a backgrounded PWA out and that is
182
+ another moment the height can come back wrong. Whether a reflow can win
183
+ against a system-level regression is unknowable off-device; the strike
184
+ budget is what keeps a losing attempt cheap. */
185
+ timers.push(window.setTimeout(() => heal('boot'), 1000), window.setTimeout(() => heal('boot'), 3000))
186
+ const onVisible = (): void => {
187
+ if (document.visibilityState !== 'visible') return
188
+ timers.push(window.setTimeout(() => heal('boot'), 300))
189
+ }
190
+
191
+ window.addEventListener('resize', onResize)
192
+ document.addEventListener('focusout', onFocusOut, true)
193
+ document.addEventListener('visibilitychange', onVisible)
194
+ return () => {
195
+ window.removeEventListener('resize', onResize)
196
+ document.removeEventListener('focusout', onFocusOut, true)
197
+ document.removeEventListener('visibilitychange', onVisible)
198
+ for (const t of timers) clearTimeout(t)
199
+ }
200
+ }, 'dsh-mobile-nav: iOS viewport-shrink heal (boot / foreground / keyboard)')
201
+ }
202
+
203
+ /**
204
+ * Sunk-viewport compensation (S1.3, 2026-08-17) — zeroes --mnav-sab ONLY.
205
+ *
206
+ * When isViewportSunkBelowStatusBar() holds, the layout viewport is 59px
207
+ * shorter than the screen while env() still reports the full pair of insets.
208
+ * The bottom one is then a lie with a cost: the home indicator sits in the
209
+ * dead strip BELOW the page, so the 34px we reserve for it is 34px of blank
210
+ * page stacked on top of an already-blank system band. Zeroing it does not
211
+ * fix the band (nothing in the page can) but stops us widening it.
212
+ *
213
+ * --mnav-sat is deliberately left alone: the top edge is measured correct on
214
+ * the device (header sits right under the notch), and the S1.2 round already
215
+ * burned a cycle on the theory that it was double-counted.
216
+ *
217
+ * Reversible by design. The predicate is re-evaluated on every resize, so a
218
+ * viewport that comes back to full height — the heal above winning, or Apple
219
+ * shipping the fix — drops the override and the normal env() compensation
220
+ * returns without a reload. `data-mnav-sunk` on <html> is what the debug
221
+ * badge reads, so the badge reports the state that is actually in force
222
+ * rather than re-deriving it.
223
+ */
224
+ export function installSunkInset(ctx: ClientContext): void {
225
+ ctx.effect(() => {
226
+ /* ?mobile-nav-inset outranks this: both write the same inline property on
227
+ <html>, and the debug param exists precisely to fake insets that this
228
+ would then erase. Param present = stay out entirely, attribute included,
229
+ so the badge shows "n/a" instead of a state nobody is enforcing. */
230
+ if (new URLSearchParams(location.search).has('mobile-nav-inset')) return () => {}
231
+ const root = document.documentElement
232
+ /* env() is only readable through a real element's computed style. */
233
+ const probe = document.createElement('div')
234
+ probe.style.cssText =
235
+ 'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;' +
236
+ 'padding-top:env(safe-area-inset-top,0px)'
237
+ document.body.appendChild(probe)
238
+
239
+ const sync = (): void => {
240
+ const sunk = isViewportSunkBelowStatusBar({
241
+ standalone: window.matchMedia('(display-mode: standalone)').matches
242
+ || (navigator as Navigator & { standalone?: boolean }).standalone === true,
243
+ screenHeight: window.screen.height,
244
+ innerHeight: window.innerHeight,
245
+ envTop: Number.parseFloat(getComputedStyle(probe).paddingTop) || 0,
246
+ })
247
+ root.dataset.mnavSunk = sunk ? '1' : '0'
248
+ if (sunk) root.style.setProperty('--mnav-sab', '0px')
249
+ else root.style.removeProperty('--mnav-sab')
250
+ }
251
+ sync()
252
+ /* resize covers rotation and any height the heal above wins back.
253
+ Deliberately not visualViewport: it fires on every keyboard show/hide
254
+ while innerHeight, the value read here, does not move on iOS. */
255
+ window.addEventListener('resize', sync)
256
+ window.addEventListener('orientationchange', sync)
257
+ return () => {
258
+ window.removeEventListener('resize', sync)
259
+ window.removeEventListener('orientationchange', sync)
260
+ probe.remove()
261
+ root.style.removeProperty('--mnav-sab')
262
+ delete root.dataset.mnavSunk
263
+ }
264
+ }, 'dsh-mobile-nav: sunk-viewport bottom-inset override')
265
+ }