dsh-zen-remote 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  <p align="center">
5
5
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0B7285?style=flat-square" alt="MIT"></a>
6
- <img src="https://img.shields.io/badge/release-v1.1.1-5B4CF0?style=flat-square" alt="v1.1.1">
6
+ <img src="https://img.shields.io/badge/release-v1.1.2-5B4CF0?style=flat-square" alt="v1.1.2">
7
7
  <img src="https://img.shields.io/badge/DSH-Web%20Profile-5B4CF0?style=flat-square" alt="DSH Web Profile">
8
8
  </p>
9
9
 
@@ -39,7 +39,7 @@ dsh plugin add dsh-zen-remote
39
39
  ```jsonc
40
40
  {
41
41
  "dependencies": {
42
- "dsh-zen-remote": "^1.1.1" // 本地开发换成 "link:/path/to/dsh-zen-remote"
42
+ "dsh-zen-remote": "^1.1.2" // 本地开发换成 "link:/path/to/dsh-zen-remote"
43
43
  },
44
44
  "dsh": { "profile": { "bundles": [
45
45
  "@deepseek-ai/dsh-base",
@@ -282,6 +282,8 @@ open http://127.0.0.1:3088/lan-gate/admin
282
282
 
283
283
  **iOS 26.x 独立 PWA 视口缩水**:加到主屏后视口底部会少掉一条状态栏高度,普通 Safari 标签页正常。这是 iOS 系统缺陷,缺掉的区域在文档之外,CSS 够不着;本插件做了三层缓解(浅色 manifest 背景 + 安全区补偿 + 强制重排),能减轻但不保证复原。彻底恢复只能整个 App 退出重开。
284
284
 
285
+ **个别环境软键盘对浏览器完全不可见,输入框抬升靠估算兜底**:部分组合(实测过:某些第三方输入法 + Chrome;小米浏览器安装的 PWA 壳)里,键盘弹出/收起时系统不把键盘高度告知页面——视口不变、无任何事件(visualViewport、VirtualKeyboard API 一并失效,均已实测排除)。插件的兜底是:聚焦后探测约 1.2 秒,判定「键盘不可见」就按估计高度抬升输入框(判定按浏览器记忆,之后聚焦即时抬升)。代价有两条:抬升高度是估算的,可能与实际键盘有几十像素出入;键盘收起同样无信号,输入框要等你点击或滑动输入框以外的区域才回落。正常环境完全不走这条路径,不受影响。
286
+
285
287
  **经反代访问时设置页打不开(插件配置列表空白、模型卡片报「settings are unavailable in this browser」)**:直连 `127.0.0.1:3080/3088` 正常。
286
288
 
287
289
  根因是 DSH 官方的设计,不在网关:设置类 RPC **只对回环连接开放**。客户端按 `location.hostname` 判定(`dsh-client-connection` 的 `isLoopback`),非回环时 `dsh-client-ui-settings` 把持久化降级为 `memory`,设置镜像初始状态就是 `unavailable`——官方源码注释原话是「remote browsers remain process-local because settings RPCs are loopback-only」。所有依赖这个镜像的卡片(模型、插件配置)因此一起空白,与本插件、与 service worker 缓存都无关(2026-08-20 真机 USB 调试 + 本机对照实测)。
package/lib/client.js CHANGED
@@ -4875,8 +4875,12 @@ exports.COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
4875
4875
  a resting transform, even translateY(0), would silently become the
4876
4876
  containing block for any fixed-position descendant of the bar. In every
4877
4877
  environment where the browser's own focus-reveal works the attribute is
4878
- simply never set. */
4879
- html[data-mnav-kb] [data-slot="conversation.composer.bar"] {
4878
+ simply never set.
4879
+ The target is the slot's _root CHILD, not the slot wrapper: the wrapper
4880
+ is display:contents — it generates no box, so a transform on it is a
4881
+ silent no-op (found live on-device via CDP, 2026-08-21; the variable and
4882
+ attribute were set and nothing moved). */
4883
+ html[data-mnav-kb] [data-slot="conversation.composer.bar"] > [class$="_root"] {
4880
4884
  transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));
4881
4885
  }
4882
4886
  }
@@ -7424,7 +7428,10 @@ function installKeyboardGuard(ctx) {
7424
7428
  __modules["effects/keyboard-avoid.js"] = function (require, module, exports) {
7425
7429
  "use strict";
7426
7430
  Object.defineProperty(exports, "__esModule", { value: true });
7431
+ exports.safetyPad = safetyPad;
7432
+ exports.estimatedLift = estimatedLift;
7427
7433
  exports.keyboardLift = keyboardLift;
7434
+ exports.composerLift = composerLift;
7428
7435
  exports.installKeyboardAvoid = installKeyboardAvoid;
7429
7436
  /** Phone breakpoint — same query every phone-only effect in this plugin uses. */
7430
7437
  const PHONE_QUERY = '(max-width: 767px)';
@@ -7438,6 +7445,55 @@ const LIFT_ATTR = 'data-mnav-kb';
7438
7445
  const MIN_LIFT_PX = 2;
7439
7446
  /** Above this visualViewport scale the geometry is pinch-zoom, not keyboard. */
7440
7447
  const MAX_SCALE = 1.01;
7448
+ /** Extra clearance whenever the keyboard is up AND the browser reacted to it.
7449
+ * Third-party IMEs routinely under-report their height — the toolbar strip
7450
+ * above the keys is left out of what they hand the system, so the viewport
7451
+ * shrinks by less than the keyboard actually covers and the composer lands
7452
+ * slightly under it (搜狗 ~30px is a documented case; one reporter saw the
7453
+ * same with 微信输入法 after the viewport DID shrink). Deliberately small:
7454
+ * it only has to clear a toolbar strip, not a keyboard.
7455
+ *
7456
+ * ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
7457
+ * behaviour; iOS has one system keyboard whose height WebKit reports exactly,
7458
+ * so padding there would be a visible gap bought for nothing. */
7459
+ const SAFETY_PAD_PX = 15;
7460
+ /** The safety pad this platform gets: the clearance above, or nothing off
7461
+ * Android. UA sniffing is the right tool here — the target is a platform
7462
+ * defect, not a feature that could be detected. */
7463
+ function safetyPad(userAgent) {
7464
+ return /Android/iu.test(userAgent) ? SAFETY_PAD_PX : 0;
7465
+ }
7466
+ /** A viewport that lost at least this much against its no-keyboard baseline
7467
+ * has a keyboard up. Comfortably above the ~56px a collapsing URL bar moves,
7468
+ * comfortably below any real keyboard. */
7469
+ const KEYBOARD_MIN_SHRINK_PX = 100;
7470
+ /** The official composer slot wrapper (same marker keyboard-guard reads). */
7471
+ const COMPOSER = '[data-slot="conversation.composer.bar"]';
7472
+ /** Dumb-keyboard probe: after a touch-granted focus, the viewport gets this
7473
+ * long to move before the keyboard is declared invisible to the browser. */
7474
+ const PROBE_TOTAL_MS = 1200;
7475
+ const PROBE_INTERVAL_MS = 120;
7476
+ /** Per-browser cache of the probe's verdict. Once a keyboard has proven
7477
+ * invisible, later focuses lift IMMEDIATELY instead of sitting through the
7478
+ * probe again — the probe still runs in the background on every focus, and
7479
+ * any viewport movement it sees revokes the cache (IME switched, engine
7480
+ * fixed), returning the browser to the pure geometric path. */
7481
+ const DUMB_KEY = 'dsh-mobile-nav.kb-dumb';
7482
+ /** Any viewport movement beyond this during the probe means the browser can
7483
+ * see the keyboard (or is panning) — the geometric path owns the job then. */
7484
+ const PROBE_EPSILON_PX = 8;
7485
+ /**
7486
+ * Fallback lift for a keyboard the browser cannot see (issue #1 确诊根因:
7487
+ * 微信输入法在该设备上不向系统 insets 上报键盘高度, Chrome 键盘高度恒 0).
7488
+ * There is no signal to measure, so this is an estimate: the reporter's
7489
+ * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
7490
+ * 400px covers taller IME toolbars without stranding the composer mid-screen.
7491
+ * ponytail: single heuristic constant; per-IME calibration only if reports
7492
+ * show it misses.
7493
+ */
7494
+ function estimatedLift(innerHeight) {
7495
+ return Math.min(Math.round(innerHeight * 0.42), 400);
7496
+ }
7441
7497
  /**
7442
7498
  * How far the composer must rise so its bottom edge sits on the visual
7443
7499
  * viewport's bottom edge (issue #1, 方案 2 of
@@ -7459,6 +7515,29 @@ function keyboardLift(reading) {
7459
7515
  const occluded = reading.innerHeight - reading.vvHeight - reading.offsetTop;
7460
7516
  return occluded >= MIN_LIFT_PX ? Math.round(occluded) : 0;
7461
7517
  }
7518
+ /**
7519
+ * The lift the composer actually gets, from the three sources in priority
7520
+ * order.
7521
+ * @param geometric - {@link keyboardLift} of the current reading.
7522
+ * @param estimate - the dumb-keyboard estimate, 0 when not in that mode.
7523
+ * @param keyboardShrunk - the viewport lost {@link KEYBOARD_MIN_SHRINK_PX}
7524
+ * or more against its no-keyboard baseline, i.e. the browser reacted.
7525
+ * @param pad - this platform's safety clearance, from {@link safetyPad}.
7526
+ * @returns pixels to translate the composer up by.
7527
+ */
7528
+ function composerLift(geometric, estimate, keyboardShrunk, pad) {
7529
+ // Measured occlusion, plus clearance for the strip the IME did not declare.
7530
+ if (geometric > 0)
7531
+ return geometric + pad;
7532
+ // The estimate is a generous fraction already; padding it would strand the
7533
+ // composer mid-screen.
7534
+ if (estimate > 0)
7535
+ return estimate;
7536
+ // Nothing occluded on paper, but a keyboard IS up and the browser handled
7537
+ // it: the only thing that can still cover the composer is an under-reported
7538
+ // toolbar, so clear exactly that.
7539
+ return keyboardShrunk ? pad : 0;
7540
+ }
7462
7541
  /**
7463
7542
  * S10 — keep the composer above the software keyboard (< 768px).
7464
7543
  *
@@ -7469,13 +7548,32 @@ function keyboardLift(reading) {
7469
7548
  * is the increment that covers it: mirror the occluded band into a root CSS
7470
7549
  * variable, and let the stylesheet translate the composer up by it. In every
7471
7550
  * environment where the browser already handles the keyboard the band
7472
- * computes to zero and nothing changes; if the IME reports no height at all
7473
- * (the research's候选 1/2) no event fires and this is inert — that class
7474
- * needs the polling fallback, deliberately not built until confirmed.
7551
+ * computes to zero and nothing changes.
7475
7552
  *
7476
7553
  * `scroll` is listened to as well as `resize`: panning the visual viewport
7477
7554
  * changes offsetTop without a resize (CSSOM View §13.2), and both sides of
7478
7555
  * the subtraction must stay fresh.
7556
+ *
7557
+ * Second layer (2026-08-21, after on-device confirmation): the reporter's
7558
+ * 小米 + 微信输入法 keyboard is INVISIBLE to Chrome — vv.height stayed 859/858
7559
+ * with the keyboard open, so no event ever fires and the geometry above is
7560
+ * honestly zero. For that class only, a touch-granted composer focus starts a
7561
+ * short probe; if the viewport has not moved at all by the end, the composer
7562
+ * gets an ESTIMATED lift until blur. Guards against false positives:
7563
+ * - probe only after a recent touch pointerdown (a hardware-keyboard focus
7564
+ * never lifts anything);
7565
+ * - any viewport movement ≥ {@link PROBE_EPSILON_PX} cancels the probe — a
7566
+ * browser that shows any reaction owns the reveal itself (iOS pans,
7567
+ * working Android resizes);
7568
+ * - a non-zero geometric lift always wins over the estimate.
7569
+ *
7570
+ * Third layer: an IME that under-reports its height (toolbar strip left out
7571
+ * of what it declares) makes the browser shrink the viewport by less than the
7572
+ * keyboard covers — every number the page can read is self-consistent, so no
7573
+ * occlusion is computable. Whenever a keyboard is up and the browser DID
7574
+ * react, the composer therefore also gets {@link safetyPad} of clearance —
7575
+ * Android only, where the under-reporting happens.
7576
+ * See {@link composerLift} for how the three sources compose.
7479
7577
  */
7480
7578
  function installKeyboardAvoid(ctx) {
7481
7579
  ctx.effect(() => {
@@ -7488,13 +7586,28 @@ function installKeyboardAvoid(ctx) {
7488
7586
  let frame = 0;
7489
7587
  let applied = 0;
7490
7588
  let attached = false;
7589
+ /** Estimated lift while a browser-invisible keyboard is up; 0 otherwise. */
7590
+ let estimate = 0;
7591
+ let probeTimer = 0;
7592
+ let lastTouch = 0;
7593
+ /** Viewport height with no keyboard up, the shrink is measured against
7594
+ * it. Re-read on every sync that finds the composer unfocused, so a
7595
+ * rotation or a URL-bar change rebases it without extra listeners. */
7596
+ let baseline = 0;
7597
+ const pad = safetyPad(navigator.userAgent);
7598
+ const composerTextarea = (node) => node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null;
7491
7599
  const sync = () => {
7492
- const lift = keyboardLift({
7600
+ const focused = composerTextarea(document.activeElement);
7601
+ if (!focused)
7602
+ baseline = viewport.height;
7603
+ const geometric = keyboardLift({
7493
7604
  innerHeight: window.innerHeight,
7494
7605
  vvHeight: viewport.height,
7495
7606
  offsetTop: viewport.offsetTop,
7496
7607
  scale: viewport.scale,
7497
7608
  });
7609
+ const shrunk = focused && baseline - viewport.height >= KEYBOARD_MIN_SHRINK_PX;
7610
+ const lift = composerLift(geometric, estimate, shrunk, pad);
7498
7611
  if (lift === applied)
7499
7612
  return;
7500
7613
  applied = lift;
@@ -7515,12 +7628,111 @@ function installKeyboardAvoid(ctx) {
7515
7628
  sync();
7516
7629
  });
7517
7630
  };
7631
+ const stopProbe = () => {
7632
+ if (probeTimer !== 0)
7633
+ window.clearTimeout(probeTimer);
7634
+ probeTimer = 0;
7635
+ };
7636
+ /** End the estimated lift AND close the keyboard (blur), keeping the two
7637
+ * in one coherent state. Only ever called while an estimate is active, so
7638
+ * healthy environments never feel it. */
7639
+ const retract = () => {
7640
+ stopProbe();
7641
+ if (estimate !== 0) {
7642
+ estimate = 0;
7643
+ sync();
7644
+ }
7645
+ const el = document.activeElement;
7646
+ if (composerTextarea(el) && el instanceof HTMLElement)
7647
+ el.blur();
7648
+ };
7649
+ /** True when a touch outside the composer means "the reader moved on". */
7650
+ const outside = (target) => estimate !== 0 && target instanceof Element && target.closest(COMPOSER) === null;
7651
+ const onPointerDown = (event) => {
7652
+ if (event.pointerType === 'touch')
7653
+ lastTouch = Date.now();
7654
+ // An invisible keyboard also closes invisibly (system back / the IME's
7655
+ // own collapse chevron keep the textarea focused), so blur alone cannot
7656
+ // end the estimate. A tap OR a scroll outside the composer is the
7657
+ // reader moving on — retract and dismiss the keyboard together, the
7658
+ // same gesture mainstream chat apps use.
7659
+ // ponytail: collapse-then-just-read keeps the lift until the next
7660
+ // touch; a real close signal does not exist in this class.
7661
+ if (outside(event.target))
7662
+ retract();
7663
+ };
7664
+ const onTouchMove = (event) => {
7665
+ if (outside(event.target))
7666
+ retract();
7667
+ };
7668
+ const onFocusIn = (event) => {
7669
+ if (!composerTextarea(event.target))
7670
+ return;
7671
+ // Focus not born from a touch (hardware keyboard, programmatic) raises
7672
+ // no on-screen keyboard — never estimate for it.
7673
+ if (Date.now() - lastTouch > 1000)
7674
+ return;
7675
+ stopProbe();
7676
+ // A browser already convicted by an earlier probe lifts right away —
7677
+ // the probe below still runs and revokes the verdict if the viewport
7678
+ // turns out to react after all.
7679
+ if (localStorage.getItem(DUMB_KEY) === '1') {
7680
+ estimate = estimatedLift(window.innerHeight);
7681
+ sync();
7682
+ }
7683
+ const h0 = viewport.height;
7684
+ const t0 = viewport.offsetTop;
7685
+ const i0 = window.innerHeight;
7686
+ const deadline = Date.now() + PROBE_TOTAL_MS;
7687
+ const moved = () => Math.abs(viewport.height - h0) >= PROBE_EPSILON_PX
7688
+ || Math.abs(viewport.offsetTop - t0) >= PROBE_EPSILON_PX
7689
+ || Math.abs(window.innerHeight - i0) >= PROBE_EPSILON_PX;
7690
+ const step = () => {
7691
+ probeTimer = 0;
7692
+ if (moved()) {
7693
+ // The browser can see this keyboard: the geometric path owns the
7694
+ // reveal. Drop any stale verdict and estimated lift.
7695
+ localStorage.removeItem(DUMB_KEY);
7696
+ if (estimate !== 0) {
7697
+ estimate = 0;
7698
+ sync();
7699
+ }
7700
+ return;
7701
+ }
7702
+ if (Date.now() < deadline) {
7703
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
7704
+ return;
7705
+ }
7706
+ // Keyboard is up (touch focus on a phone) yet the viewport never
7707
+ // reacted: the browser cannot see it. Estimate until blur, and
7708
+ // remember the verdict so the next focus skips the wait.
7709
+ if (!composerTextarea(document.activeElement))
7710
+ return;
7711
+ localStorage.setItem(DUMB_KEY, '1');
7712
+ estimate = estimatedLift(window.innerHeight);
7713
+ sync();
7714
+ };
7715
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
7716
+ };
7717
+ const onFocusOut = (event) => {
7718
+ if (!composerTextarea(event.target))
7719
+ return;
7720
+ stopProbe();
7721
+ if (estimate === 0)
7722
+ return;
7723
+ estimate = 0;
7724
+ sync();
7725
+ };
7518
7726
  const attach = () => {
7519
7727
  if (attached)
7520
7728
  return;
7521
7729
  attached = true;
7522
7730
  viewport.addEventListener('resize', schedule);
7523
7731
  viewport.addEventListener('scroll', schedule);
7732
+ document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true });
7733
+ document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true });
7734
+ document.addEventListener('focusin', onFocusIn, true);
7735
+ document.addEventListener('focusout', onFocusOut, true);
7524
7736
  sync();
7525
7737
  };
7526
7738
  const detach = () => {
@@ -7529,10 +7741,17 @@ function installKeyboardAvoid(ctx) {
7529
7741
  attached = false;
7530
7742
  viewport.removeEventListener('resize', schedule);
7531
7743
  viewport.removeEventListener('scroll', schedule);
7744
+ document.removeEventListener('pointerdown', onPointerDown, true);
7745
+ document.removeEventListener('touchmove', onTouchMove, true);
7746
+ document.removeEventListener('focusin', onFocusIn, true);
7747
+ document.removeEventListener('focusout', onFocusOut, true);
7748
+ stopProbe();
7532
7749
  if (frame !== 0)
7533
7750
  cancelAnimationFrame(frame);
7534
7751
  frame = 0;
7535
7752
  applied = 0;
7753
+ estimate = 0;
7754
+ baseline = 0;
7536
7755
  root.style.removeProperty(LIFT_VAR);
7537
7756
  root.removeAttribute(LIFT_ATTR);
7538
7757
  };
@@ -1,4 +1,18 @@
1
1
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
2
+ /** The safety pad this platform gets: the clearance above, or nothing off
3
+ * Android. UA sniffing is the right tool here — the target is a platform
4
+ * defect, not a feature that could be detected. */
5
+ export declare function safetyPad(userAgent: string): number;
6
+ /**
7
+ * Fallback lift for a keyboard the browser cannot see (issue #1 确诊根因:
8
+ * 微信输入法在该设备上不向系统 insets 上报键盘高度, Chrome 键盘高度恒 0).
9
+ * There is no signal to measure, so this is an estimate: the reporter's
10
+ * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
11
+ * 400px covers taller IME toolbars without stranding the composer mid-screen.
12
+ * ponytail: single heuristic constant; per-IME calibration only if reports
13
+ * show it misses.
14
+ */
15
+ export declare function estimatedLift(innerHeight: number): number;
2
16
  /** One visualViewport reading, in CSS pixels. */
3
17
  export interface ViewportReading {
4
18
  /** Layout viewport height (window.innerHeight). */
@@ -26,6 +40,17 @@ export interface ViewportReading {
26
40
  * flinging the composer around.
27
41
  */
28
42
  export declare function keyboardLift(reading: ViewportReading): number;
43
+ /**
44
+ * The lift the composer actually gets, from the three sources in priority
45
+ * order.
46
+ * @param geometric - {@link keyboardLift} of the current reading.
47
+ * @param estimate - the dumb-keyboard estimate, 0 when not in that mode.
48
+ * @param keyboardShrunk - the viewport lost {@link KEYBOARD_MIN_SHRINK_PX}
49
+ * or more against its no-keyboard baseline, i.e. the browser reacted.
50
+ * @param pad - this platform's safety clearance, from {@link safetyPad}.
51
+ * @returns pixels to translate the composer up by.
52
+ */
53
+ export declare function composerLift(geometric: number, estimate: number, keyboardShrunk: boolean, pad: number): number;
29
54
  /**
30
55
  * S10 — keep the composer above the software keyboard (< 768px).
31
56
  *
@@ -36,13 +61,32 @@ export declare function keyboardLift(reading: ViewportReading): number;
36
61
  * is the increment that covers it: mirror the occluded band into a root CSS
37
62
  * variable, and let the stylesheet translate the composer up by it. In every
38
63
  * environment where the browser already handles the keyboard the band
39
- * computes to zero and nothing changes; if the IME reports no height at all
40
- * (the research's候选 1/2) no event fires and this is inert — that class
41
- * needs the polling fallback, deliberately not built until confirmed.
64
+ * computes to zero and nothing changes.
42
65
  *
43
66
  * `scroll` is listened to as well as `resize`: panning the visual viewport
44
67
  * changes offsetTop without a resize (CSSOM View §13.2), and both sides of
45
68
  * the subtraction must stay fresh.
69
+ *
70
+ * Second layer (2026-08-21, after on-device confirmation): the reporter's
71
+ * 小米 + 微信输入法 keyboard is INVISIBLE to Chrome — vv.height stayed 859/858
72
+ * with the keyboard open, so no event ever fires and the geometry above is
73
+ * honestly zero. For that class only, a touch-granted composer focus starts a
74
+ * short probe; if the viewport has not moved at all by the end, the composer
75
+ * gets an ESTIMATED lift until blur. Guards against false positives:
76
+ * - probe only after a recent touch pointerdown (a hardware-keyboard focus
77
+ * never lifts anything);
78
+ * - any viewport movement ≥ {@link PROBE_EPSILON_PX} cancels the probe — a
79
+ * browser that shows any reaction owns the reveal itself (iOS pans,
80
+ * working Android resizes);
81
+ * - a non-zero geometric lift always wins over the estimate.
82
+ *
83
+ * Third layer: an IME that under-reports its height (toolbar strip left out
84
+ * of what it declares) makes the browser shrink the viewport by less than the
85
+ * keyboard covers — every number the page can read is self-consistent, so no
86
+ * occlusion is computable. Whenever a keyboard is up and the browser DID
87
+ * react, the composer therefore also gets {@link safetyPad} of clearance —
88
+ * Android only, where the under-reporting happens.
89
+ * See {@link composerLift} for how the three sources compose.
46
90
  */
47
91
  export declare function installKeyboardAvoid(ctx: ClientContext): void;
48
92
  //# sourceMappingURL=keyboard-avoid.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"keyboard-avoid.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/keyboard-avoid.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAmB3E,iDAAiD;AACjD,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,gCAAgC;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAI7D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA+D7D"}
1
+ {"version":3,"file":"keyboard-avoid.d.ts","sourceRoot":"","sources":["../../../../src/client/effects/keyboard-avoid.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA;AAgC3E;;mDAEmD;AACnD,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEnD;AAyBD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,iDAAiD;AACjD,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;IACnB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,gCAAgC;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAI7D;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,OAAO,EACvB,GAAG,EAAE,MAAM,GACV,MAAM,CAUR;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAkL7D"}
@@ -1,2 +1,2 @@
1
- export declare const COMPOSER_CSS = "/* ---------- phone composer (< 768px) ---------- */\n\n/* The file picker the attachment button drives (S7) \u2014 hidden at EVERY width,\n deliberately OUTSIDE the phone media block below. A bare <input type=\"file\">\n renders as a native \"Choose Files\" control, and the slot wrapper around it is\n `display: contents`, so an un-hidden one becomes a flex item of the official\n tool row. Scoping this to < 768px once put that control in the DESKTOP\n composer (caught on a live deploy, 2026-08-17): the button that drives it is\n phone-only, but the input it drives is in the DOM at all widths. */\n[data-mobile-nav=\"attach-picker\"] {\n display: none !important;\n}\n\n@media (max-width: 767px) {\n /* --- 1. flatten the two official groups ---\n `_tools` and `_trailing` only exist to cluster controls left/right.\n display:contents dissolves them into `_row`, so the six leaf controls\n become siblings in one flex line and `order` alone drives the layout:\n [attach \u00B7 + \u00B7 permission \u00B7 model] \u2026elastic gap\u2026 [context ring \u00B7 send]. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] {\n justify-content: flex-start !important;\n gap: 5px !important;\n padding: 2px 8px 8px !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] {\n display: contents !important;\n }\n\n /* --- 2. the running order ---\n The attachment button (S3 placeholder, S7 wires it up) sits leftmost;\n the official \"+\" command menu keeps its seat right next to it, where\n the two \"insert something\" affordances read as one cluster. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] [data-mobile-nav=\"attach\"] {\n order: 1 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_add\"] {\n order: 2 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] {\n order: 3 !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n gap: 6px !important;\n }\n /* The model seat carries the elastic gap: everything after it is pushed to\n the right edge, so no spacer element is needed. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] {\n order: 4 !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n margin-right: auto !important;\n }\n /* Third-party input.right entries park next to the ring rather than\n landing at order 0 (= far left) once the groups are flattened. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.right\"] > * {\n order: 5 !important;\n }\n /* ContextMeter \u2014 the only `span` ending in _root inside the row. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"] {\n order: 6 !important;\n flex: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [class$=\"_primary\"] {\n order: 7 !important;\n }\n\n /* --- 3. permission + model as icon-only pills (real-device round 2, 2026-08-17) ---\n S3's icon-and-label capsules read as noise on an actual phone \u2014 there\n is no room to usefully show a preset name or a model id, so the label\n text is now hidden outright and both triggers collapse to a plain\n ~44x30 icon button. This AGREES with (rather than fights) the official\n container query (`@container (width <= 460px) { .trigger:has(.triggerIcon)\n .triggerLabel { display: none } }`) that S3 had to override \u2014 no need\n to override it back. Accessible name is unaffected: both official\n triggers already ship a descriptive aria-label independent of the\n visible text (PermissionSelect: t('input.accessMode', {name}); the\n model trigger: t('trigger.aria'/'trigger.ariaEffort')) \u2014 verified live\n in dsh-client-ui-conversation / dsh-client-ui-model-selection lib/\n client.js, 2026-08-17 \u2014 so hiding the label costs nothing for screen\n readers. The permission trigger only renders an icon for the\n \"read-only\" / \"workspace-write\" presets (permissionGlyphs in\n dsh-client-ui-conversation) \u2014 \"Full access\" and any host-configured\n preset name fall back to chevron-only, a known gap in the official\n markup this plugin cannot fill without inventing new icon meaning.\n The model trigger never renders an icon at all (only label + optional\n effort text + chevron), so its ::before below draws one from a\n primitives icon path (Sparkle \u2014 the closest existing \"model\" glyph) as\n a CSS-only pseudo-element: it survives React re-renders for free\n (unlike a DOM-injected node, which would need a MutationObserver, see\n the preview-full-toggle pitfall in AGENTS.md) and does not touch\n accessible-name computation (empty generated content). S3's\n rtl-ellipsis trick on the model label is simply inert under\n display:none now; left alone rather than unpicked. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [class$=\"_triggerLabel\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerLabel\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerEffort\"] {\n display: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] button[class$=\"_trigger\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n width: 44px !important;\n height: 30px !important;\n max-width: none !important;\n min-width: 0 !important;\n padding: 0 !important;\n gap: 2px !important;\n justify-content: center !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n }\n /* PermissionSelect wraps its trigger in the Menu primitive's root span,\n which must shrink to the icon button's fixed width. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] > span:has(> button[class$=\"_trigger\"]) {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n }\n /* ic_ds_sparkle_16 (@deepseek-ai/dsh-client-ui-primitives IconSparkle16\n path, copied verbatim) as a mask so it inherits currentColor like every\n other icon in the row \u2014 the model trigger has no official icon slot to\n hook into. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"]::before {\n content: '';\n width: 16px;\n height: 16px;\n flex: none;\n background: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z'/%3E%3Cpath d='M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z'/%3E%3Cpath d='M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n }\n\n /* --- 4. both menus become bottom sheets ---\n The permission menu is the Menu primitive (role=menu, absolute, side=top)\n and the model menu is ModelSelect's own `_menu` (absolute, bottom+right).\n Neither has a transformed ancestor between it and the viewport, so\n position:fixed re-anchors both to the screen edge. Only the shell moves \u2014\n the items, the two-level model panes and every selection handler stay\n official. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] {\n position: fixed !important;\n left: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n top: auto !important;\n transform: none !important;\n width: 100% !important;\n min-width: 0 !important;\n max-width: none !important;\n max-height: min(70dvh, 520px) !important;\n box-sizing: border-box !important;\n border-radius: 16px 16px 0 0 !important;\n border-bottom: none !important;\n padding: 8px 8px calc(8px + var(--mnav-sab)) !important;\n z-index: 60 !important;\n box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;\n }\n /* 44pt+ rows in both sheets (Menu items, model options, and the model\n sheet's two root cells that drill into the model / effort panes). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"] [role=\"menuitem\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_option\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_cell\"] {\n min-height: 48px !important;\n border-radius: 12px !important;\n font-size: 15px !important;\n }\n /* --- 4b. the official scroll-to-bottom button must not poke through the\n sheet (real-device follow-up, 2026-08-17) ---\n ChatView's own \"jump to latest\" button (aria-label t(\"chat.toBottom\"))\n is `position: sticky; z-index: 8` inside the message column, not\n `position: fixed` \u2014 it never escapes to the same top-level stacking\n context our sheets get promoted to, so raising the sheet's z-index\n further does nothing (measured live: it still rendered on top at\n z-index 60 vs 8). Rather than chase engine-specific stacking-context\n semantics (Chromium and WebKit do not always agree here \u2014 see AGENTS.md\n CDP/document.hidden lesson for another instance of that), this hides\n the button outright while either sheet is open and lets it reappear on\n close: a plain `display: none` behind a live `:has()` read is correct\n regardless of which stacking rules the engine happens to apply.\n Selector: no hashed classes (dsh-client-ui-conversation lib/client.js,\n verified 2026-08-17) \u2014 `data-chat-flow` is the one stable attribute on\n the message column, and the button's wrapper is its only sibling\n (ChatView only ever renders the column and, conditionally, this one\n slot), so the adjacent-sibling combinator pins it precisely. */\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"]) [data-chat-flow] + div,\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"]) [data-chat-flow] + div {\n display: none !important;\n }\n /* --- 5. input box: two lines minimum, five lines maximum ---\n The official autosizer drives the box off the hidden mirror's height, so\n a min-height on the mirror IS the min-height of the field; the scroll cap\n rides the official custom property. 52px = 2 x 24px line box + 4px pad,\n 124px = 5 lines. The hero card keeps its own one-line collapse (misc.css\n pins _scroll/_grow while the placeholder shows). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] [class$=\"_mirror\"] {\n min-height: 52px !important;\n }\n [class$=\"_composerSeat\"] {\n --dsh-composer-text-max-height: 124px;\n }\n\n /* --- 6. no divider above OR below the message list ---\n Instead of a rule the messages butt against, the message scroller\n fades out over its last 26px (S3, no divider above the composer) AND\n fades in over its first 20px (S3.1 real-device round 2: the header's\n own bottom line is removed too, styles/header.css.ts's\n `header::after` rule). The mask lives on the scroll body (NOT on the\n header or the composer): a mask clips everything it paints, and both\n surfaces host position:fixed children (composer's permission/model\n sheets, any future header overlay) that must not be clipped.\n `mask-image` can only be declared once per element, so both fades are\n ONE linear-gradient rather than two separate declarations (the second\n would silently replace the first) \u2014 this is the merge of what used to\n be S3's bottom-only mask. */\n [class$=\"_scrollBody\"] {\n -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 20px, #000 calc(100% - 26px), transparent 100%);\n mask-image: linear-gradient(to bottom, transparent 0, #000 20px, #000 calc(100% - 26px), transparent 100%);\n }\n [class$=\"_composerSeat\"],\n [class$=\"_composerStack\"] {\n border-top: none !important;\n }\n\n /* --- 7. dock entries become mini chips above the input card ---\n conversation.input.dock is display:contents (inline style), so its\n entries are stacked rows of the composer column. Forcing the slot itself\n to flex turns them into ONE scrollable chip line outside and above the\n card. Written against the slot, not against any particular plugin's chip\n (the git branch chip and the todo panel are third-party and may not be\n installed at all). */\n [data-slot=\"conversation.input.dock\"] {\n display: flex !important;\n flex-flow: row nowrap !important;\n align-items: center !important;\n gap: 6px !important;\n min-width: 0 !important;\n margin: 0 16px !important;\n overflow-x: auto !important;\n overflow-y: hidden !important;\n scrollbar-width: none !important;\n }\n [data-slot=\"conversation.input.dock\"]::-webkit-scrollbar {\n display: none !important;\n }\n [data-slot=\"conversation.input.dock\"] > * {\n flex: 0 0 auto !important;\n max-width: 70% !important;\n min-height: 26px !important;\n max-height: 26px !important;\n border-radius: 999px !important;\n font-size: 11.5px !important;\n line-height: 18px !important;\n overflow: hidden !important;\n }\n /* The git-graph chip carries a 34px tablet target from misc.css; that\n selector is more specific, so restate it at the phone breakpoint. */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip] {\n min-height: 26px !important;\n padding: 0 10px !important;\n font-size: 11.5px !important;\n }\n /* --- 7a. the native to-do card opts OUT of the 26px pill cage ---\n conversation.input.dock is not only a chip rail: DSH's own TodoPanel\n (dsh-client-ui-conversation) registers here, and it is an expandable\n card, not a pill \u2014 tapping its header renders the to-do list INSIDE\n itself. The cage's max-height:26px + overflow:hidden left that list\n rendered but clipped to nothing, so on a phone the bar looked dead:\n aria-expanded flipped, 8 rows mounted at 216px, and the card still\n painted 26px tall (measured live, 2026-08-20). Give it the official\n geometry back \u2014 full row, natural height, 12px radius. Its list caps\n itself at 180px with its own scroller, so this cannot run into the\n composer. Anchored on the official data-testid, a stable contract\n marker rather than a hashed class name. */\n [data-slot=\"conversation.input.dock\"] > [data-testid=\"todo-panel\"] {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n overflow: visible !important;\n border-radius: 12px !important;\n /* The cage shrinks dock text to 11.5px/18px; the card's rows inherit it\n and turn cramped. Restore the official 13px body scale. */\n font-size: 13px !important;\n line-height: normal !important;\n }\n\n /* --- 7b. attachment chips (S7.1) ---\n Our own dock entry opts OUT of the 26px pill cage above: an image chip is\n a 48px tile. The row sits tight under the card's top edge, so the chips\n read as part of the composer rather than as a floating strip. */\n [data-slot=\"conversation.input.dock\"] > [data-mobile-nav=\"attach-chips\"] {\n display: flex !important;\n align-items: center !important;\n gap: 6px !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n border-radius: 0 !important;\n overflow: visible !important;\n margin-bottom: -2px;\n }\n [data-mobile-nav=\"attach-chip\"] {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 62vw;\n background: var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n }\n [data-mobile-nav=\"attach-chip\"][data-kind=\"file\"] {\n height: 26px;\n padding: 0 4px 0 8px;\n border-radius: 999px;\n font-size: 11.5px;\n line-height: 18px;\n }\n /* An image chip is the thumbnail: no name line, the filename lives in the\n title attribute (and in the draft text right below). */\n [data-mobile-nav=\"attach-chip\"][data-kind=\"image\"] {\n width: 48px;\n height: 48px;\n padding: 0;\n border-radius: 10px;\n overflow: hidden;\n }\n [data-mobile-nav=\"attach-chip-art\"] {\n position: relative;\n display: grid;\n place-items: center;\n flex: none;\n overflow: hidden;\n }\n [data-kind=\"file\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 16px;\n height: 16px;\n opacity: .7;\n }\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 100%;\n height: 100%;\n }\n /* The <img> is stacked over the paperclip, not swapped for it: a format the\n engine cannot decode (HEIC outside WebKit) simply paints nothing and the\n icon shows through \u2014 no onError state to carry. */\n [data-mobile-nav=\"attach-chip-art\"] img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .18));\n }\n [data-mobile-nav=\"attach-chip-name\"] {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n }\n [data-mobile-nav=\"attach-chip-remove\"] {\n display: grid;\n place-items: center;\n flex: none;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 999px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n touch-action: manipulation;\n }\n /* On a tile the \u00D7 floats in the corner over the picture, so it needs its own\n ground to stay legible against an arbitrary photo. */\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-remove\"] {\n position: absolute;\n top: 2px;\n right: 2px;\n background: rgba(0, 0, 0, .55);\n color: #fff;\n }\n [data-mobile-nav=\"attach-chip-remove\"]:active {\n transform: scale(.9);\n }\n\n /* --- 8. the official stats strip leaves the composer ---\n Its data moves into the session info card (S4). The row is the composer\n dock's own `_root` entry; the slot itself stays live for later entries. */\n [data-slot=\"conversation.composer.dock\"] > [class$=\"_root\"] {\n display: none !important;\n }\n\n /* --- 9. the attachment button (S7; its file input is hidden at the top of\n this file, at every width) --- */\n [data-mobile-nav=\"attach\"] {\n position: relative;\n width: 28px !important;\n height: 28px !important;\n flex: none !important;\n display: grid !important;\n place-items: center !important;\n padding: 0 !important;\n border: none !important;\n border-radius: 999px !important;\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n touch-action: manipulation;\n }\n [data-mobile-nav=\"attach\"]:active {\n transform: scale(.94);\n transition: transform .12s;\n }\n /* Busy: a ring sweeps around the paperclip. Drawn with a conic gradient in\n a pseudo-element rather than a spinner node, so the official React tree\n around us has nothing extra to re-render (same reasoning as the model\n pill's mask icon, S3.1). */\n [data-mobile-nav=\"attach\"][data-busy]::after {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 999px;\n background: conic-gradient(from 0deg, transparent 0 65%, currentColor 100%);\n mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n animation: mnav-attach-spin .9s linear infinite;\n pointer-events: none;\n }\n @keyframes mnav-attach-spin {\n to { transform: rotate(1turn); }\n }\n @media (prefers-reduced-motion: reduce) {\n [data-mobile-nav=\"attach\"][data-busy]::after {\n animation-duration: 3s;\n }\n }\n /* Failure note: one line above the button, tap-to-dismiss. Sits on the\n composer card (z 2) rather than in an overlay layer \u2014 nothing here needs\n to clear the permission/model sheets. */\n [data-mobile-nav=\"attach-error\"] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 0;\n z-index: 2;\n max-width: 62vw;\n padding: 5px 8px;\n border-radius: 8px;\n font-size: 12px;\n line-height: 1.3;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .82));\n color: var(--dsw-alias-label-primary, #fff);\n box-shadow: 0 2px 8px rgba(0, 0, 0, .24);\n }\n\n /* --- 9. home-indicator clearance (S4.1, 2026-08-17) ---\n Owned HERE, not by the gateway half. That plugin used to carry\n [data-slot=\"conversation.composer\"] { padding-bottom: max(env(safe-area-inset-bottom), 8px) }\n in both pwa/app.css and the gateway's inline DEVICE_CSS. Both were\n INERT and had always been: that slot element is `display: contents`\n (measured 2026-08-17 \u2014 the slot wrapper generates no box, so padding on\n it is discarded), which is why raising it never moved anything. The rule\n is deleted on the PWA side and restated here on an element that actually\n lays out, reading --mnav-sab so ?mobile-nav-inset=54,34 can regress it\n off-device (env() is hard 0 on desktop \u2014 the whole reason S2.1 exists).\n\n Target: the card should look EQUALLY inset on all four sides. This same\n element carries the card's side inset (`padding: 0 16px` officially,\n measured 16px left and 16px right at 390px), so the bottom gap is simply\n capped at that same 16px. Clearing the FULL 34px inset \u2014 the naive\n max(sab, 8px) \u2014 is what read as \"too thick\": it is more than double the\n side margin, so the card looked shoved up off the bottom edge.\n\n clamp() says all three requirements at once:\n - floor 8px -> a device with no home indicator (sab: 0) keeps the\n official 8px exactly, so this is a strict no-op there;\n - track sab -> a shallower inset than 16px is honoured as-is;\n - cap 16px -> a full-size indicator (sab: 34) lands on 16px, equal\n to the side inset, which is the look being asked for.\n Keep the 16px in step with the side padding above if that ever changes;\n that equality IS the spec here, not a coincidence. */\n [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;\n }\n\n /* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the\n layout viewport hidden behind the software keyboard into --mnav-kb-lift\n and stamps the html attribute while (and only while) it is non-zero \u2014\n a resting transform, even translateY(0), would silently become the\n containing block for any fixed-position descendant of the bar. In every\n environment where the browser's own focus-reveal works the attribute is\n simply never set. */\n html[data-mnav-kb] [data-slot=\"conversation.composer.bar\"] {\n transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));\n }\n}\n\n/* The attachment button and its preview row only exist for the phone shell.\n Both render at every width (the slots are not breakpoint-aware), so both\n need hiding here \u2014 the dock-row rules that shape the chips live in the\n < 768px block and would otherwise leave a bare, unstyled chip line in the\n desktop composer. */\n@media (min-width: 768px) {\n [data-mobile-nav=\"attach\"],\n [data-mobile-nav=\"attach-chips\"] {\n display: none !important;\n }\n}\n";
1
+ export declare const COMPOSER_CSS = "/* ---------- phone composer (< 768px) ---------- */\n\n/* The file picker the attachment button drives (S7) \u2014 hidden at EVERY width,\n deliberately OUTSIDE the phone media block below. A bare <input type=\"file\">\n renders as a native \"Choose Files\" control, and the slot wrapper around it is\n `display: contents`, so an un-hidden one becomes a flex item of the official\n tool row. Scoping this to < 768px once put that control in the DESKTOP\n composer (caught on a live deploy, 2026-08-17): the button that drives it is\n phone-only, but the input it drives is in the DOM at all widths. */\n[data-mobile-nav=\"attach-picker\"] {\n display: none !important;\n}\n\n@media (max-width: 767px) {\n /* --- 1. flatten the two official groups ---\n `_tools` and `_trailing` only exist to cluster controls left/right.\n display:contents dissolves them into `_row`, so the six leaf controls\n become siblings in one flex line and `order` alone drives the layout:\n [attach \u00B7 + \u00B7 permission \u00B7 model] \u2026elastic gap\u2026 [context ring \u00B7 send]. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] {\n justify-content: flex-start !important;\n gap: 5px !important;\n padding: 2px 8px 8px !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] {\n display: contents !important;\n }\n\n /* --- 2. the running order ---\n The attachment button (S3 placeholder, S7 wires it up) sits leftmost;\n the official \"+\" command menu keeps its seat right next to it, where\n the two \"insert something\" affordances read as one cluster. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] [data-mobile-nav=\"attach\"] {\n order: 1 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_add\"] {\n order: 2 !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] {\n order: 3 !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n gap: 6px !important;\n }\n /* The model seat carries the elastic gap: everything after it is pushed to\n the right edge, so no spacer element is needed. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] {\n order: 4 !important;\n flex: 0 1 auto !important;\n min-width: 0 !important;\n margin-right: auto !important;\n }\n /* Third-party input.right entries park next to the ring rather than\n landing at order 0 (= far left) once the groups are flattened. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.right\"] > * {\n order: 5 !important;\n }\n /* ContextMeter \u2014 the only `span` ending in _root inside the row. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > span[class$=\"_root\"] {\n order: 6 !important;\n flex: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [class$=\"_primary\"] {\n order: 7 !important;\n }\n\n /* --- 3. permission + model as icon-only pills (real-device round 2, 2026-08-17) ---\n S3's icon-and-label capsules read as noise on an actual phone \u2014 there\n is no room to usefully show a preset name or a model id, so the label\n text is now hidden outright and both triggers collapse to a plain\n ~44x30 icon button. This AGREES with (rather than fights) the official\n container query (`@container (width <= 460px) { .trigger:has(.triggerIcon)\n .triggerLabel { display: none } }`) that S3 had to override \u2014 no need\n to override it back. Accessible name is unaffected: both official\n triggers already ship a descriptive aria-label independent of the\n visible text (PermissionSelect: t('input.accessMode', {name}); the\n model trigger: t('trigger.aria'/'trigger.ariaEffort')) \u2014 verified live\n in dsh-client-ui-conversation / dsh-client-ui-model-selection lib/\n client.js, 2026-08-17 \u2014 so hiding the label costs nothing for screen\n readers. The permission trigger only renders an icon for the\n \"read-only\" / \"workspace-write\" presets (permissionGlyphs in\n dsh-client-ui-conversation) \u2014 \"Full access\" and any host-configured\n preset name fall back to chevron-only, a known gap in the official\n markup this plugin cannot fill without inventing new icon meaning.\n The model trigger never renders an icon at all (only label + optional\n effort text + chevron), so its ::before below draws one from a\n primitives icon path (Sparkle \u2014 the closest existing \"model\" glyph) as\n a CSS-only pseudo-element: it survives React re-renders for free\n (unlike a DOM-injected node, which would need a MutationObserver, see\n the preview-full-toggle pitfall in AGENTS.md) and does not touch\n accessible-name computation (empty generated content). S3's\n rtl-ellipsis trick on the model label is simply inert under\n display:none now; left alone rather than unpicked. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [class$=\"_triggerLabel\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerLabel\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] > [class$=\"_triggerEffort\"] {\n display: none !important;\n }\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] button[class$=\"_trigger\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"] {\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;\n width: 44px !important;\n height: 30px !important;\n max-width: none !important;\n min-width: 0 !important;\n padding: 0 !important;\n gap: 2px !important;\n justify-content: center !important;\n border-radius: 999px !important;\n touch-action: manipulation !important;\n }\n /* PermissionSelect wraps its trigger in the Menu primitive's root span,\n which must shrink to the icon button's fixed width. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] > span:has(> button[class$=\"_trigger\"]) {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n }\n /* ic_ds_sparkle_16 (@deepseek-ai/dsh-client-ui-primitives IconSparkle16\n path, copied verbatim) as a mask so it inherits currentColor like every\n other icon in the row \u2014 the model trigger has no official icon slot to\n hook into. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_trigger\"]::before {\n content: '';\n width: 16px;\n height: 16px;\n flex: none;\n background: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z'/%3E%3Cpath d='M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z'/%3E%3Cpath d='M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n }\n\n /* --- 4. both menus become bottom sheets ---\n The permission menu is the Menu primitive (role=menu, absolute, side=top)\n and the model menu is ModelSelect's own `_menu` (absolute, bottom+right).\n Neither has a transformed ancestor between it and the viewport, so\n position:fixed re-anchors both to the screen edge. Only the shell moves \u2014\n the items, the two-level model panes and every selection handler stay\n official. */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] {\n position: fixed !important;\n left: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n top: auto !important;\n transform: none !important;\n width: 100% !important;\n min-width: 0 !important;\n max-width: none !important;\n max-height: min(70dvh, 520px) !important;\n box-sizing: border-box !important;\n border-radius: 16px 16px 0 0 !important;\n border-bottom: none !important;\n padding: 8px 8px calc(8px + var(--mnav-sab)) !important;\n z-index: 60 !important;\n box-shadow: 0 -8px 32px rgba(0, 0, 0, .18) !important;\n }\n /* 44pt+ rows in both sheets (Menu items, model options, and the model\n sheet's two root cells that drill into the model / effort panes). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"] [role=\"menuitem\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_option\"],\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"] [class$=\"_cell\"] {\n min-height: 48px !important;\n border-radius: 12px !important;\n font-size: 15px !important;\n }\n /* --- 4b. the official scroll-to-bottom button must not poke through the\n sheet (real-device follow-up, 2026-08-17) ---\n ChatView's own \"jump to latest\" button (aria-label t(\"chat.toBottom\"))\n is `position: sticky; z-index: 8` inside the message column, not\n `position: fixed` \u2014 it never escapes to the same top-level stacking\n context our sheets get promoted to, so raising the sheet's z-index\n further does nothing (measured live: it still rendered on top at\n z-index 60 vs 8). Rather than chase engine-specific stacking-context\n semantics (Chromium and WebKit do not always agree here \u2014 see AGENTS.md\n CDP/document.hidden lesson for another instance of that), this hides\n the button outright while either sheet is open and lets it reappear on\n close: a plain `display: none` behind a live `:has()` read is correct\n regardless of which stacking rules the engine happens to apply.\n Selector: no hashed classes (dsh-client-ui-conversation lib/client.js,\n verified 2026-08-17) \u2014 `data-chat-flow` is the one stable attribute on\n the message column, and the button's wrapper is its only sibling\n (ChatView only ever renders the column and, conditionally, this one\n slot), so the adjacent-sibling combinator pins it precisely. */\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_tools\"] > [class$=\"_modes\"] [role=\"menu\"]) [data-chat-flow] + div,\n body:has([data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] > [class$=\"_row\"] > [class$=\"_trailing\"] > [data-slot=\"conversation.input.model\"] > [class$=\"_root\"] > [class$=\"_menu\"]) [data-chat-flow] + div {\n display: none !important;\n }\n /* --- 5. input box: two lines minimum, five lines maximum ---\n The official autosizer drives the box off the hidden mirror's height, so\n a min-height on the mirror IS the min-height of the field; the scroll cap\n rides the official custom property. 52px = 2 x 24px line box + 4px pad,\n 124px = 5 lines. The hero card keeps its own one-line collapse (misc.css\n pins _scroll/_grow while the placeholder shows). */\n [data-slot=\"conversation.composer.bar\"] [class$=\"_card\"] [class$=\"_mirror\"] {\n min-height: 52px !important;\n }\n [class$=\"_composerSeat\"] {\n --dsh-composer-text-max-height: 124px;\n }\n\n /* --- 6. no divider above OR below the message list ---\n Instead of a rule the messages butt against, the message scroller\n fades out over its last 26px (S3, no divider above the composer) AND\n fades in over its first 20px (S3.1 real-device round 2: the header's\n own bottom line is removed too, styles/header.css.ts's\n `header::after` rule). The mask lives on the scroll body (NOT on the\n header or the composer): a mask clips everything it paints, and both\n surfaces host position:fixed children (composer's permission/model\n sheets, any future header overlay) that must not be clipped.\n `mask-image` can only be declared once per element, so both fades are\n ONE linear-gradient rather than two separate declarations (the second\n would silently replace the first) \u2014 this is the merge of what used to\n be S3's bottom-only mask. */\n [class$=\"_scrollBody\"] {\n -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 20px, #000 calc(100% - 26px), transparent 100%);\n mask-image: linear-gradient(to bottom, transparent 0, #000 20px, #000 calc(100% - 26px), transparent 100%);\n }\n [class$=\"_composerSeat\"],\n [class$=\"_composerStack\"] {\n border-top: none !important;\n }\n\n /* --- 7. dock entries become mini chips above the input card ---\n conversation.input.dock is display:contents (inline style), so its\n entries are stacked rows of the composer column. Forcing the slot itself\n to flex turns them into ONE scrollable chip line outside and above the\n card. Written against the slot, not against any particular plugin's chip\n (the git branch chip and the todo panel are third-party and may not be\n installed at all). */\n [data-slot=\"conversation.input.dock\"] {\n display: flex !important;\n flex-flow: row nowrap !important;\n align-items: center !important;\n gap: 6px !important;\n min-width: 0 !important;\n margin: 0 16px !important;\n overflow-x: auto !important;\n overflow-y: hidden !important;\n scrollbar-width: none !important;\n }\n [data-slot=\"conversation.input.dock\"]::-webkit-scrollbar {\n display: none !important;\n }\n [data-slot=\"conversation.input.dock\"] > * {\n flex: 0 0 auto !important;\n max-width: 70% !important;\n min-height: 26px !important;\n max-height: 26px !important;\n border-radius: 999px !important;\n font-size: 11.5px !important;\n line-height: 18px !important;\n overflow: hidden !important;\n }\n /* The git-graph chip carries a 34px tablet target from misc.css; that\n selector is more specific, so restate it at the phone breakpoint. */\n [data-slot=\"conversation.input.dock\"] [data-gitgraph-chip-anchor] [data-gitgraph-chip] {\n min-height: 26px !important;\n padding: 0 10px !important;\n font-size: 11.5px !important;\n }\n /* --- 7a. the native to-do card opts OUT of the 26px pill cage ---\n conversation.input.dock is not only a chip rail: DSH's own TodoPanel\n (dsh-client-ui-conversation) registers here, and it is an expandable\n card, not a pill \u2014 tapping its header renders the to-do list INSIDE\n itself. The cage's max-height:26px + overflow:hidden left that list\n rendered but clipped to nothing, so on a phone the bar looked dead:\n aria-expanded flipped, 8 rows mounted at 216px, and the card still\n painted 26px tall (measured live, 2026-08-20). Give it the official\n geometry back \u2014 full row, natural height, 12px radius. Its list caps\n itself at 180px with its own scroller, so this cannot run into the\n composer. Anchored on the official data-testid, a stable contract\n marker rather than a hashed class name. */\n [data-slot=\"conversation.input.dock\"] > [data-testid=\"todo-panel\"] {\n flex: 1 1 auto !important;\n min-width: 0 !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n overflow: visible !important;\n border-radius: 12px !important;\n /* The cage shrinks dock text to 11.5px/18px; the card's rows inherit it\n and turn cramped. Restore the official 13px body scale. */\n font-size: 13px !important;\n line-height: normal !important;\n }\n\n /* --- 7b. attachment chips (S7.1) ---\n Our own dock entry opts OUT of the 26px pill cage above: an image chip is\n a 48px tile. The row sits tight under the card's top edge, so the chips\n read as part of the composer rather than as a floating strip. */\n [data-slot=\"conversation.input.dock\"] > [data-mobile-nav=\"attach-chips\"] {\n display: flex !important;\n align-items: center !important;\n gap: 6px !important;\n max-width: none !important;\n min-height: 0 !important;\n max-height: none !important;\n border-radius: 0 !important;\n overflow: visible !important;\n margin-bottom: -2px;\n }\n [data-mobile-nav=\"attach-chip\"] {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 62vw;\n background: var(--dsw-alias-interactive-bg-hover, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n }\n [data-mobile-nav=\"attach-chip\"][data-kind=\"file\"] {\n height: 26px;\n padding: 0 4px 0 8px;\n border-radius: 999px;\n font-size: 11.5px;\n line-height: 18px;\n }\n /* An image chip is the thumbnail: no name line, the filename lives in the\n title attribute (and in the draft text right below). */\n [data-mobile-nav=\"attach-chip\"][data-kind=\"image\"] {\n width: 48px;\n height: 48px;\n padding: 0;\n border-radius: 10px;\n overflow: hidden;\n }\n [data-mobile-nav=\"attach-chip-art\"] {\n position: relative;\n display: grid;\n place-items: center;\n flex: none;\n overflow: hidden;\n }\n [data-kind=\"file\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 16px;\n height: 16px;\n opacity: .7;\n }\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-art\"] {\n width: 100%;\n height: 100%;\n }\n /* The <img> is stacked over the paperclip, not swapped for it: a format the\n engine cannot decode (HEIC outside WebKit) simply paints nothing and the\n icon shows through \u2014 no onError state to carry. */\n [data-mobile-nav=\"attach-chip-art\"] img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .18));\n }\n [data-mobile-nav=\"attach-chip-name\"] {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n }\n [data-mobile-nav=\"attach-chip-remove\"] {\n display: grid;\n place-items: center;\n flex: none;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 999px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n touch-action: manipulation;\n }\n /* On a tile the \u00D7 floats in the corner over the picture, so it needs its own\n ground to stay legible against an arbitrary photo. */\n [data-kind=\"image\"] > [data-mobile-nav=\"attach-chip-remove\"] {\n position: absolute;\n top: 2px;\n right: 2px;\n background: rgba(0, 0, 0, .55);\n color: #fff;\n }\n [data-mobile-nav=\"attach-chip-remove\"]:active {\n transform: scale(.9);\n }\n\n /* --- 8. the official stats strip leaves the composer ---\n Its data moves into the session info card (S4). The row is the composer\n dock's own `_root` entry; the slot itself stays live for later entries. */\n [data-slot=\"conversation.composer.dock\"] > [class$=\"_root\"] {\n display: none !important;\n }\n\n /* --- 9. the attachment button (S7; its file input is hidden at the top of\n this file, at every width) --- */\n [data-mobile-nav=\"attach\"] {\n position: relative;\n width: 28px !important;\n height: 28px !important;\n flex: none !important;\n display: grid !important;\n place-items: center !important;\n padding: 0 !important;\n border: none !important;\n border-radius: 999px !important;\n background: var(--dsw-specific-selector, rgba(127, 127, 127, .12));\n color: var(--dsw-alias-label-primary, inherit);\n cursor: pointer;\n touch-action: manipulation;\n }\n [data-mobile-nav=\"attach\"]:active {\n transform: scale(.94);\n transition: transform .12s;\n }\n /* Busy: a ring sweeps around the paperclip. Drawn with a conic gradient in\n a pseudo-element rather than a spinner node, so the official React tree\n around us has nothing extra to re-render (same reasoning as the model\n pill's mask icon, S3.1). */\n [data-mobile-nav=\"attach\"][data-busy]::after {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 999px;\n background: conic-gradient(from 0deg, transparent 0 65%, currentColor 100%);\n mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));\n animation: mnav-attach-spin .9s linear infinite;\n pointer-events: none;\n }\n @keyframes mnav-attach-spin {\n to { transform: rotate(1turn); }\n }\n @media (prefers-reduced-motion: reduce) {\n [data-mobile-nav=\"attach\"][data-busy]::after {\n animation-duration: 3s;\n }\n }\n /* Failure note: one line above the button, tap-to-dismiss. Sits on the\n composer card (z 2) rather than in an overlay layer \u2014 nothing here needs\n to clear the permission/model sheets. */\n [data-mobile-nav=\"attach-error\"] {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 0;\n z-index: 2;\n max-width: 62vw;\n padding: 5px 8px;\n border-radius: 8px;\n font-size: 12px;\n line-height: 1.3;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, .82));\n color: var(--dsw-alias-label-primary, #fff);\n box-shadow: 0 2px 8px rgba(0, 0, 0, .24);\n }\n\n /* --- 9. home-indicator clearance (S4.1, 2026-08-17) ---\n Owned HERE, not by the gateway half. That plugin used to carry\n [data-slot=\"conversation.composer\"] { padding-bottom: max(env(safe-area-inset-bottom), 8px) }\n in both pwa/app.css and the gateway's inline DEVICE_CSS. Both were\n INERT and had always been: that slot element is `display: contents`\n (measured 2026-08-17 \u2014 the slot wrapper generates no box, so padding on\n it is discarded), which is why raising it never moved anything. The rule\n is deleted on the PWA side and restated here on an element that actually\n lays out, reading --mnav-sab so ?mobile-nav-inset=54,34 can regress it\n off-device (env() is hard 0 on desktop \u2014 the whole reason S2.1 exists).\n\n Target: the card should look EQUALLY inset on all four sides. This same\n element carries the card's side inset (`padding: 0 16px` officially,\n measured 16px left and 16px right at 390px), so the bottom gap is simply\n capped at that same 16px. Clearing the FULL 34px inset \u2014 the naive\n max(sab, 8px) \u2014 is what read as \"too thick\": it is more than double the\n side margin, so the card looked shoved up off the bottom edge.\n\n clamp() says all three requirements at once:\n - floor 8px -> a device with no home indicator (sab: 0) keeps the\n official 8px exactly, so this is a strict no-op there;\n - track sab -> a shallower inset than 16px is honoured as-is;\n - cap 16px -> a full-size indicator (sab: 34) lands on 16px, equal\n to the side inset, which is the look being asked for.\n Keep the 16px in step with the side padding above if that ever changes;\n that equality IS the spec here, not a coincidence. */\n [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n padding-bottom: clamp(8px, var(--mnav-sab), 16px) !important;\n }\n\n /* S10 keyboard avoid: effects/keyboard-avoid.ts mirrors the band of the\n layout viewport hidden behind the software keyboard into --mnav-kb-lift\n and stamps the html attribute while (and only while) it is non-zero \u2014\n a resting transform, even translateY(0), would silently become the\n containing block for any fixed-position descendant of the bar. In every\n environment where the browser's own focus-reveal works the attribute is\n simply never set.\n The target is the slot's _root CHILD, not the slot wrapper: the wrapper\n is display:contents \u2014 it generates no box, so a transform on it is a\n silent no-op (found live on-device via CDP, 2026-08-21; the variable and\n attribute were set and nothing moved). */\n html[data-mnav-kb] [data-slot=\"conversation.composer.bar\"] > [class$=\"_root\"] {\n transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));\n }\n}\n\n/* The attachment button and its preview row only exist for the phone shell.\n Both render at every width (the slots are not breakpoint-aware), so both\n need hiding here \u2014 the dock-row rules that shape the chips live in the\n < 768px block and would otherwise leave a bare, unstyled chip line in the\n desktop composer. */\n@media (min-width: 768px) {\n [data-mobile-nav=\"attach\"],\n [data-mobile-nav=\"attach-chips\"] {\n display: none !important;\n }\n}\n";
2
2
  //# sourceMappingURL=composer.css.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composer.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/composer.css.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,YAAY,w+zBAqgBxB,CAAA"}
1
+ {"version":3,"file":"composer.css.d.ts","sourceRoot":"","sources":["../../../../src/client/styles/composer.css.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,YAAY,ux0BAygBxB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zen-remote",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "把 DeepSeek Harness 变成手机 App:移动端界面重排(两级页面栈、会话列表主屏、composer 重排、手势、附件上传)+ 配对码认证网关 + PWA 安装 + 真 Web Push。Complete mobile plugin for DeepSeek Harness (DSH): app-shell mobile UI, pairing-code gateway for public-internet access behind your own reverse proxy, installable PWA, offline service worker, and real Web Push agent-done notifications.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,8 @@
3
3
  // so the boundary cases of the pure geometry are asserted numerically.
4
4
  // Run: node scripts/check-keyboard-avoid.mjs (needs Node >= 23.6 type stripping)
5
5
  import assert from 'node:assert/strict'
6
- import { keyboardLift as lift } from '../src/client/effects/keyboard-avoid.ts'
6
+ import { readFileSync } from 'node:fs'
7
+ import { composerLift, estimatedLift, keyboardLift as lift, safetyPad } from '../src/client/effects/keyboard-avoid.ts'
7
8
 
8
9
  // The reported class of bug: keyboard shrinks the visual viewport, browser
9
10
  // does not pan it — the whole shrink is an occluded band at the bottom.
@@ -35,4 +36,56 @@ assert.equal(lift({ innerHeight: 800, vvHeight: 507.5, offsetTop: 0, scale: 1 })
35
36
  // Over-pan (elastic edge) must not produce a negative lift.
36
37
  assert.equal(lift({ innerHeight: 800, vvHeight: 500, offsetTop: 320, scale: 1 }), 0)
37
38
 
39
+ // Dumb-keyboard estimate (issue #1 确诊设备): the reporter's WeType measured
40
+ // ~315 CSS px on an 858px viewport — the estimate must clear it, and never
41
+ // exceed its 400px cap on tall viewports.
42
+ assert.equal(estimatedLift(858), 360)
43
+ assert.ok(estimatedLift(858) > 315, 'estimate clears the one measured WeType height')
44
+ assert.equal(estimatedLift(2000), 400)
45
+ assert.equal(estimatedLift(600), 252)
46
+
47
+ // Cached verdict (first-focus-only delay): the effect must persist the dumb
48
+ // verdict, lift immediately for a convicted browser, and revoke the cache
49
+ // the moment a probe sees the viewport move.
50
+ const effectSrc = readFileSync(new URL('../src/client/effects/keyboard-avoid.ts', import.meta.url), 'utf8')
51
+ assert.match(effectSrc, /'dsh-mobile-nav\.kb-dumb'/, 'verdict cache key exists')
52
+ assert.match(effectSrc, /localStorage\.setItem\(DUMB_KEY, '1'\)/, 'verdict is persisted on conviction')
53
+ assert.match(effectSrc, /if \(localStorage\.getItem\(DUMB_KEY\) === '1'\) \{\s*estimate = estimatedLift/, 'convicted browser lifts immediately on focus')
54
+ assert.match(effectSrc, /if \(moved\(\)\) \{\s*[\s\S]{0,200}localStorage\.removeItem\(DUMB_KEY\)/, 'a moving viewport revokes the verdict')
55
+
56
+ // The safety pad is ANDROID-ONLY: the under-reporting is an Android IME
57
+ // behaviour, and iOS reports its one system keyboard exactly — padding there
58
+ // would be a visible gap bought for nothing.
59
+ const ANDROID = 'Mozilla/5.0 (Linux; Android 17) AppleWebKit/537.36 Chrome/135 Mobile'
60
+ const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 Mobile'
61
+ assert.equal(safetyPad(ANDROID), 15)
62
+ assert.equal(safetyPad(IPHONE), 0)
63
+ assert.equal(safetyPad('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'), 0)
64
+
65
+ // Three-source composition. The pad rides on a measured occlusion and stands
66
+ // alone when a keyboard is up with nothing occluded on paper, but never
67
+ // inflates the (already generous) dumb-keyboard estimate.
68
+ const A = safetyPad(ANDROID)
69
+ assert.equal(composerLift(300, 0, true, A), 315, 'measured occlusion gets the pad')
70
+ assert.equal(composerLift(0, 0, true, A), 15, 'under-reporting IME: pad alone')
71
+ assert.equal(composerLift(0, 360, false, A), 360, 'the estimate is never padded')
72
+ assert.equal(composerLift(0, 0, false, A), 0, 'no keyboard, no lift')
73
+ // A shrunk viewport must not add the pad twice via both paths.
74
+ assert.equal(composerLift(300, 360, true, A), 315, 'geometry outranks the estimate')
75
+
76
+ // Off Android the pad vanishes and every path is exactly the measurement.
77
+ const I = safetyPad(IPHONE)
78
+ assert.equal(composerLift(300, 0, true, I), 300, 'iOS: measured occlusion, unpadded')
79
+ assert.equal(composerLift(0, 0, true, I), 0, 'iOS: a handled keyboard lifts nothing')
80
+
81
+ // The lift rule must target the slot's _root CHILD: the slot wrapper itself
82
+ // is display:contents (no box), a transform there is a silent no-op — the
83
+ // exact on-device failure of 2026-08-21.
84
+ const composerCss = readFileSync(new URL('../src/client/styles/composer.css.ts', import.meta.url), 'utf8')
85
+ assert.match(
86
+ composerCss,
87
+ /html\[data-mnav-kb\] \[data-slot="conversation\.composer\.bar"\] > \[class\$="_root"\]\s*\{\s*transform/,
88
+ 'keyboard-avoid transform must hit the box-generating _root child',
89
+ )
90
+
38
91
  console.log('KEYBOARD AVOID CHECK OK')
@@ -17,6 +17,62 @@ const MIN_LIFT_PX = 2
17
17
  /** Above this visualViewport scale the geometry is pinch-zoom, not keyboard. */
18
18
  const MAX_SCALE = 1.01
19
19
 
20
+ /** Extra clearance whenever the keyboard is up AND the browser reacted to it.
21
+ * Third-party IMEs routinely under-report their height — the toolbar strip
22
+ * above the keys is left out of what they hand the system, so the viewport
23
+ * shrinks by less than the keyboard actually covers and the composer lands
24
+ * slightly under it (搜狗 ~30px is a documented case; one reporter saw the
25
+ * same with 微信输入法 after the viewport DID shrink). Deliberately small:
26
+ * it only has to clear a toolbar strip, not a keyboard.
27
+ *
28
+ * ANDROID ONLY ({@link safetyPad}). The under-reporting is an Android IME
29
+ * behaviour; iOS has one system keyboard whose height WebKit reports exactly,
30
+ * so padding there would be a visible gap bought for nothing. */
31
+ const SAFETY_PAD_PX = 15
32
+
33
+ /** The safety pad this platform gets: the clearance above, or nothing off
34
+ * Android. UA sniffing is the right tool here — the target is a platform
35
+ * defect, not a feature that could be detected. */
36
+ export function safetyPad(userAgent: string): number {
37
+ return /Android/iu.test(userAgent) ? SAFETY_PAD_PX : 0
38
+ }
39
+
40
+ /** A viewport that lost at least this much against its no-keyboard baseline
41
+ * has a keyboard up. Comfortably above the ~56px a collapsing URL bar moves,
42
+ * comfortably below any real keyboard. */
43
+ const KEYBOARD_MIN_SHRINK_PX = 100
44
+
45
+ /** The official composer slot wrapper (same marker keyboard-guard reads). */
46
+ const COMPOSER = '[data-slot="conversation.composer.bar"]'
47
+
48
+ /** Dumb-keyboard probe: after a touch-granted focus, the viewport gets this
49
+ * long to move before the keyboard is declared invisible to the browser. */
50
+ const PROBE_TOTAL_MS = 1200
51
+ const PROBE_INTERVAL_MS = 120
52
+
53
+ /** Per-browser cache of the probe's verdict. Once a keyboard has proven
54
+ * invisible, later focuses lift IMMEDIATELY instead of sitting through the
55
+ * probe again — the probe still runs in the background on every focus, and
56
+ * any viewport movement it sees revokes the cache (IME switched, engine
57
+ * fixed), returning the browser to the pure geometric path. */
58
+ const DUMB_KEY = 'dsh-mobile-nav.kb-dumb'
59
+ /** Any viewport movement beyond this during the probe means the browser can
60
+ * see the keyboard (or is panning) — the geometric path owns the job then. */
61
+ const PROBE_EPSILON_PX = 8
62
+
63
+ /**
64
+ * Fallback lift for a keyboard the browser cannot see (issue #1 确诊根因:
65
+ * 微信输入法在该设备上不向系统 insets 上报键盘高度, Chrome 键盘高度恒 0).
66
+ * There is no signal to measure, so this is an estimate: the reporter's
67
+ * measured WeType is ~315 CSS px on a 858px viewport (~37%); 42% capped at
68
+ * 400px covers taller IME toolbars without stranding the composer mid-screen.
69
+ * ponytail: single heuristic constant; per-IME calibration only if reports
70
+ * show it misses.
71
+ */
72
+ export function estimatedLift(innerHeight: number): number {
73
+ return Math.min(Math.round(innerHeight * 0.42), 400)
74
+ }
75
+
20
76
  /** One visualViewport reading, in CSS pixels. */
21
77
  export interface ViewportReading {
22
78
  /** Layout viewport height (window.innerHeight). */
@@ -50,6 +106,33 @@ export function keyboardLift(reading: ViewportReading): number {
50
106
  return occluded >= MIN_LIFT_PX ? Math.round(occluded) : 0
51
107
  }
52
108
 
109
+ /**
110
+ * The lift the composer actually gets, from the three sources in priority
111
+ * order.
112
+ * @param geometric - {@link keyboardLift} of the current reading.
113
+ * @param estimate - the dumb-keyboard estimate, 0 when not in that mode.
114
+ * @param keyboardShrunk - the viewport lost {@link KEYBOARD_MIN_SHRINK_PX}
115
+ * or more against its no-keyboard baseline, i.e. the browser reacted.
116
+ * @param pad - this platform's safety clearance, from {@link safetyPad}.
117
+ * @returns pixels to translate the composer up by.
118
+ */
119
+ export function composerLift(
120
+ geometric: number,
121
+ estimate: number,
122
+ keyboardShrunk: boolean,
123
+ pad: number,
124
+ ): number {
125
+ // Measured occlusion, plus clearance for the strip the IME did not declare.
126
+ if (geometric > 0) return geometric + pad
127
+ // The estimate is a generous fraction already; padding it would strand the
128
+ // composer mid-screen.
129
+ if (estimate > 0) return estimate
130
+ // Nothing occluded on paper, but a keyboard IS up and the browser handled
131
+ // it: the only thing that can still cover the composer is an under-reported
132
+ // toolbar, so clear exactly that.
133
+ return keyboardShrunk ? pad : 0
134
+ }
135
+
53
136
  /**
54
137
  * S10 — keep the composer above the software keyboard (< 768px).
55
138
  *
@@ -60,13 +143,32 @@ export function keyboardLift(reading: ViewportReading): number {
60
143
  * is the increment that covers it: mirror the occluded band into a root CSS
61
144
  * variable, and let the stylesheet translate the composer up by it. In every
62
145
  * environment where the browser already handles the keyboard the band
63
- * computes to zero and nothing changes; if the IME reports no height at all
64
- * (the research's候选 1/2) no event fires and this is inert — that class
65
- * needs the polling fallback, deliberately not built until confirmed.
146
+ * computes to zero and nothing changes.
66
147
  *
67
148
  * `scroll` is listened to as well as `resize`: panning the visual viewport
68
149
  * changes offsetTop without a resize (CSSOM View §13.2), and both sides of
69
150
  * the subtraction must stay fresh.
151
+ *
152
+ * Second layer (2026-08-21, after on-device confirmation): the reporter's
153
+ * 小米 + 微信输入法 keyboard is INVISIBLE to Chrome — vv.height stayed 859/858
154
+ * with the keyboard open, so no event ever fires and the geometry above is
155
+ * honestly zero. For that class only, a touch-granted composer focus starts a
156
+ * short probe; if the viewport has not moved at all by the end, the composer
157
+ * gets an ESTIMATED lift until blur. Guards against false positives:
158
+ * - probe only after a recent touch pointerdown (a hardware-keyboard focus
159
+ * never lifts anything);
160
+ * - any viewport movement ≥ {@link PROBE_EPSILON_PX} cancels the probe — a
161
+ * browser that shows any reaction owns the reveal itself (iOS pans,
162
+ * working Android resizes);
163
+ * - a non-zero geometric lift always wins over the estimate.
164
+ *
165
+ * Third layer: an IME that under-reports its height (toolbar strip left out
166
+ * of what it declares) makes the browser shrink the viewport by less than the
167
+ * keyboard covers — every number the page can read is self-consistent, so no
168
+ * occlusion is computable. Whenever a keyboard is up and the browser DID
169
+ * react, the composer therefore also gets {@link safetyPad} of clearance —
170
+ * Android only, where the under-reporting happens.
171
+ * See {@link composerLift} for how the three sources compose.
70
172
  */
71
173
  export function installKeyboardAvoid(ctx: ClientContext): void {
72
174
  ctx.effect(() => {
@@ -78,14 +180,30 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
78
180
  let frame = 0
79
181
  let applied = 0
80
182
  let attached = false
183
+ /** Estimated lift while a browser-invisible keyboard is up; 0 otherwise. */
184
+ let estimate = 0
185
+ let probeTimer = 0
186
+ let lastTouch = 0
187
+ /** Viewport height with no keyboard up, the shrink is measured against
188
+ * it. Re-read on every sync that finds the composer unfocused, so a
189
+ * rotation or a URL-bar change rebases it without extra listeners. */
190
+ let baseline = 0
191
+ const pad = safetyPad(navigator.userAgent)
192
+
193
+ const composerTextarea = (node: unknown): boolean =>
194
+ node instanceof HTMLElement && node.tagName === 'TEXTAREA' && node.closest(COMPOSER) !== null
81
195
 
82
196
  const sync = (): void => {
83
- const lift = keyboardLift({
197
+ const focused = composerTextarea(document.activeElement)
198
+ if (!focused) baseline = viewport.height
199
+ const geometric = keyboardLift({
84
200
  innerHeight: window.innerHeight,
85
201
  vvHeight: viewport.height,
86
202
  offsetTop: viewport.offsetTop,
87
203
  scale: viewport.scale,
88
204
  })
205
+ const shrunk = focused && baseline - viewport.height >= KEYBOARD_MIN_SHRINK_PX
206
+ const lift = composerLift(geometric, estimate, shrunk, pad)
89
207
  if (lift === applied) return
90
208
  applied = lift
91
209
  if (lift === 0) {
@@ -104,11 +222,103 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
104
222
  })
105
223
  }
106
224
 
225
+ const stopProbe = (): void => {
226
+ if (probeTimer !== 0) window.clearTimeout(probeTimer)
227
+ probeTimer = 0
228
+ }
229
+ /** End the estimated lift AND close the keyboard (blur), keeping the two
230
+ * in one coherent state. Only ever called while an estimate is active, so
231
+ * healthy environments never feel it. */
232
+ const retract = (): void => {
233
+ stopProbe()
234
+ if (estimate !== 0) {
235
+ estimate = 0
236
+ sync()
237
+ }
238
+ const el = document.activeElement
239
+ if (composerTextarea(el) && el instanceof HTMLElement) el.blur()
240
+ }
241
+ /** True when a touch outside the composer means "the reader moved on". */
242
+ const outside = (target: EventTarget | null): boolean =>
243
+ estimate !== 0 && target instanceof Element && target.closest(COMPOSER) === null
244
+ const onPointerDown = (event: PointerEvent): void => {
245
+ if (event.pointerType === 'touch') lastTouch = Date.now()
246
+ // An invisible keyboard also closes invisibly (system back / the IME's
247
+ // own collapse chevron keep the textarea focused), so blur alone cannot
248
+ // end the estimate. A tap OR a scroll outside the composer is the
249
+ // reader moving on — retract and dismiss the keyboard together, the
250
+ // same gesture mainstream chat apps use.
251
+ // ponytail: collapse-then-just-read keeps the lift until the next
252
+ // touch; a real close signal does not exist in this class.
253
+ if (outside(event.target)) retract()
254
+ }
255
+ const onTouchMove = (event: TouchEvent): void => {
256
+ if (outside(event.target)) retract()
257
+ }
258
+ const onFocusIn = (event: FocusEvent): void => {
259
+ if (!composerTextarea(event.target)) return
260
+ // Focus not born from a touch (hardware keyboard, programmatic) raises
261
+ // no on-screen keyboard — never estimate for it.
262
+ if (Date.now() - lastTouch > 1000) return
263
+ stopProbe()
264
+ // A browser already convicted by an earlier probe lifts right away —
265
+ // the probe below still runs and revokes the verdict if the viewport
266
+ // turns out to react after all.
267
+ if (localStorage.getItem(DUMB_KEY) === '1') {
268
+ estimate = estimatedLift(window.innerHeight)
269
+ sync()
270
+ }
271
+ const h0 = viewport.height
272
+ const t0 = viewport.offsetTop
273
+ const i0 = window.innerHeight
274
+ const deadline = Date.now() + PROBE_TOTAL_MS
275
+ const moved = (): boolean =>
276
+ Math.abs(viewport.height - h0) >= PROBE_EPSILON_PX
277
+ || Math.abs(viewport.offsetTop - t0) >= PROBE_EPSILON_PX
278
+ || Math.abs(window.innerHeight - i0) >= PROBE_EPSILON_PX
279
+ const step = (): void => {
280
+ probeTimer = 0
281
+ if (moved()) {
282
+ // The browser can see this keyboard: the geometric path owns the
283
+ // reveal. Drop any stale verdict and estimated lift.
284
+ localStorage.removeItem(DUMB_KEY)
285
+ if (estimate !== 0) {
286
+ estimate = 0
287
+ sync()
288
+ }
289
+ return
290
+ }
291
+ if (Date.now() < deadline) {
292
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
293
+ return
294
+ }
295
+ // Keyboard is up (touch focus on a phone) yet the viewport never
296
+ // reacted: the browser cannot see it. Estimate until blur, and
297
+ // remember the verdict so the next focus skips the wait.
298
+ if (!composerTextarea(document.activeElement)) return
299
+ localStorage.setItem(DUMB_KEY, '1')
300
+ estimate = estimatedLift(window.innerHeight)
301
+ sync()
302
+ }
303
+ probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS)
304
+ }
305
+ const onFocusOut = (event: FocusEvent): void => {
306
+ if (!composerTextarea(event.target)) return
307
+ stopProbe()
308
+ if (estimate === 0) return
309
+ estimate = 0
310
+ sync()
311
+ }
312
+
107
313
  const attach = (): void => {
108
314
  if (attached) return
109
315
  attached = true
110
316
  viewport.addEventListener('resize', schedule)
111
317
  viewport.addEventListener('scroll', schedule)
318
+ document.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true })
319
+ document.addEventListener('touchmove', onTouchMove, { capture: true, passive: true })
320
+ document.addEventListener('focusin', onFocusIn, true)
321
+ document.addEventListener('focusout', onFocusOut, true)
112
322
  sync()
113
323
  }
114
324
  const detach = (): void => {
@@ -116,9 +326,16 @@ export function installKeyboardAvoid(ctx: ClientContext): void {
116
326
  attached = false
117
327
  viewport.removeEventListener('resize', schedule)
118
328
  viewport.removeEventListener('scroll', schedule)
329
+ document.removeEventListener('pointerdown', onPointerDown, true)
330
+ document.removeEventListener('touchmove', onTouchMove, true)
331
+ document.removeEventListener('focusin', onFocusIn, true)
332
+ document.removeEventListener('focusout', onFocusOut, true)
333
+ stopProbe()
119
334
  if (frame !== 0) cancelAnimationFrame(frame)
120
335
  frame = 0
121
336
  applied = 0
337
+ estimate = 0
338
+ baseline = 0
122
339
  root.style.removeProperty(LIFT_VAR)
123
340
  root.removeAttribute(LIFT_ATTR)
124
341
  }
@@ -520,8 +520,12 @@ export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- *
520
520
  a resting transform, even translateY(0), would silently become the
521
521
  containing block for any fixed-position descendant of the bar. In every
522
522
  environment where the browser's own focus-reveal works the attribute is
523
- simply never set. */
524
- html[data-mnav-kb] [data-slot="conversation.composer.bar"] {
523
+ simply never set.
524
+ The target is the slot's _root CHILD, not the slot wrapper: the wrapper
525
+ is display:contents — it generates no box, so a transform on it is a
526
+ silent no-op (found live on-device via CDP, 2026-08-21; the variable and
527
+ attribute were set and nothing moved). */
528
+ html[data-mnav-kb] [data-slot="conversation.composer.bar"] > [class$="_root"] {
525
529
  transform: translateY(calc(-1 * var(--mnav-kb-lift, 0px)));
526
530
  }
527
531
  }
@@ -1,321 +0,0 @@
1
- # 调研:Android 软键盘弹出时输入框被遮挡(小米 + 微信输入法必现)
2
-
3
- 2026-08-21 调研。对应现象:手机 Chrome / 系统 WebView 访问本 PWA,点击底部
4
- composer textarea,微信输入法(WeType)弹出后页面不上移,输入内容被键盘挡住;
5
- 其他环境(如 Gboard、iOS)表现正常。
6
-
7
- 置信度标注约定:
8
-
9
- - **[规范/官方]** —— 规范原文、官方文档、官方博客、官方 bug tracker 结论
10
- - **[源码]** —— 浏览器 / 项目源码直接读到
11
- - **[社区]** —— 社区帖子 / 第三方 issue,属案例线索,未经一手证实
12
-
13
- ---
14
-
15
- ## 一、问题概述
16
-
17
- 页面底部有一个 textarea(composer,`position: sticky; bottom: 0`,位于内部滚动
18
- 容器中;`html/body` 是 `overflow: hidden`,本插件刻意不做 visualViewport JS,
19
- 依赖浏览器自己把聚焦元素挪进可视区 —— 见 `src/client/styles/home.css.ts` 注释
20
- **[源码]**)。在「小米手机 + 微信输入法」组合下,键盘弹出后视口没有任何变化,
21
- 浏览器也没有把光标滚进可视区,文字被键盘盖住。
22
-
23
- 要理解根因,关键是弄清一条完整的信息链:
24
-
25
- > Android IME 弹出 → 系统把键盘占用的高度以 window insets 形式发给应用
26
- > → Chrome 读取 insets 算出键盘高度 → 缩小 visual viewport(视觉视口)
27
- > → 触发 `visualViewport` resize 事件 + 自动把聚焦的输入框滚进可视区。
28
-
29
- 这条链上**任何一环断掉**,页面都不会上移。下面逐环给出平台事实。
30
-
31
- ---
32
-
33
- ## 二、平台机制事实(带来源)
34
-
35
- ### 2.1 Chrome 108 起的键盘-视口行为
36
-
37
- - **[规范/官方]** Chrome 108(2022-11)起,Android Chrome 键盘弹出时**不再缩小
38
- layout viewport(布局视口)**,只缩小 visual viewport。`window.innerHeight`、
39
- `100vh/100dvh`、`position: fixed` 元素的位置都不再随键盘变化。此行为与
40
- iOS Safari 对齐。
41
- 来源:[Chrome 官方博客 viewport-resize-behavior](https://developer.chrome.com/blog/viewport-resize-behavior)、
42
- [blink-dev Intent to Ship](https://groups.google.com/a/chromium.org/g/blink-dev/c/ge7xTu-VhJ0)
43
- - **[规范/官方]** 同版本引入 viewport meta 的 `interactive-widget` 键,三个值:
44
- - `resizes-visual`(**默认**):只缩 visual viewport;
45
- - `resizes-content`:布局视口一起缩(等于 108 之前的老行为);
46
- - `overlays-content`:什么都不缩,键盘纯覆盖。
47
- 规范条文见 [CSS Viewport Module Level 1](https://drafts.csswg.org/css-viewport/)
48
- (`interactive-widget` 属性;无值或非法值按 `resizes-visual` 处理),值语义另见
49
- [MDN viewport meta](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport)。
50
- - **[规范/官方]** 支持版本(MDN BCD 数据,`html/elements/meta/name/viewport/interactive-widget.json`):
51
- Chrome Android **108+**,Firefox Android **133+**(Firefox Android 132 先把默认行为
52
- 改成 resizes-visual,见 [Firefox 132 for Android release notes](https://www.mozilla.org/en-US/firefox/android/132.0/releasenotes/)),
53
- 桌面各浏览器与 Safari 均不支持(桌面无 OSK 概念,Safari iOS 未实现)。
54
- 来源:[mdn/browser-compat-data](https://github.com/mdn/browser-compat-data/blob/main/html/elements/meta/name/viewport/interactive-widget.json)。
55
- - **[源码]** Chrome Android 端的键盘高度是这样算出来的:
56
- `KeyboardUtils.calculateKeyboardHeightFromWindowInsets()` 读
57
- `getRootWindowInsets()` → `WindowInsetsCompat.Type.ime().bottom` 再减去
58
- `systemBars().bottom`;`isAndroidSoftKeyboardShowing()` 就是「算出的高度 > 0」。
59
- 源码注释原话:*"This is a best guess based on the height of the keyboard as
60
- there is no standardized/foolproof way to do this."*(“这是基于键盘高度的
61
- 最佳猜测,没有标准化的万无一失的办法”)。
62
- 来源:[base/android/.../KeyboardUtils.java](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/base/android/java/src/org/chromium/base/ui/KeyboardUtils.java)、
63
- [ui/android/.../KeyboardVisibilityDelegate.java](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/ui/android/java/src/org/chromium/ui/KeyboardVisibilityDelegate.java)。
64
- **推论:键盘高度完全取决于 IME 通过系统 window insets 上报的数值。IME(或被
65
- OEM 定制过的系统)报 0,Chrome 就认为没有键盘。**
66
- - **[规范/官方]** 键盘弹出时把聚焦输入框挪进可视区,是浏览器侧
67
- `ScrollFocusedEditableNodeIntoRect` 这条 IME 专用通道做的("used by IMEs
68
- (on-screen keyboards) to bring an element into view"),同样由键盘几何信息驱动。
69
- 来源:[crbug 40774196(EditContext scroll into view)](https://issues.chromium.org/issues/40774196)。
70
-
71
- ### 2.2 visualViewport API 的事件语义
72
-
73
- - **[规范/官方]** CSSOM View §13.1 "Resizing viewports" 原文:只有当
74
- VisualViewport 的 *scale / width / height 相比上次 run the resize steps 时
75
- 发生了变化*,才在 VisualViewport 上 fire `resize`。
76
- 来源:[drafts.csswg.org/cssom-view §13.1](https://drafts.csswg.org/cssom-view/#resizing-viewports)。
77
- **推论:如果键盘弹出但视口尺寸根本没被改(上一节的 insets 链断了),不触发
78
- resize 是"符合规范"的 —— 事件不来不是 bug,而是视口真的没变。任何"只监听
79
- 事件"的方案在这种设备上天然失效。**
80
- - **[规范/官方]** 已知相关事件 bug(都不是"键盘弹出完全无事件",而是时序/数值噪声):
81
- - [crbug 40924170](https://issues.chromium.org/issues/40924170):使用
82
- `interactive-widget=overlays-content`(VirtualKeyboard API 通道)时,键盘
83
- 动画期间 `window.innerHeight` 短暂变成无意义的大/小值。2024-04 修复
84
- (CL [5431673](https://chromium-review.googlesource.com/c/chromium/src/+/5431673)),
85
- 2025-10 又有人在 Chrome 141 复现,追踪新 bug 450752874。
86
- - [crbug 41343106](https://issues.chromium.org/issues/41343106):URL 栏动画期间
87
- visualViewport resize 不触发(旧案)。
88
- - [crbug 40768751](https://issues.chromium.org/issues/40768751):iframe 里
89
- visualViewport 不发 resize。
90
- - 第三方 IME 是否影响事件触发:**没有找到 Chromium 官方就"某第三方 IME 导致
91
- visualViewport 事件不触发"立案的一手记录**。但按 2.1 的源码链,IME insets
92
- 上报异常 ⇒ 视口不缩 ⇒ 无事件,逻辑上成立且与本 bug 症状吻合(**[源码]推论**,
93
- 非官方结论)。
94
-
95
- ### 2.3 第三方 IME / OEM 定制的特殊性
96
-
97
- - **[规范/官方]** 原生层的两种老模式:`android:windowSoftInputMode` 的
98
- `adjustResize`(窗口整体缩小)与 `adjustPan`(窗口整体上平移、不缩小、
99
- **不产生任何页面内 resize**)。
100
- 来源:[Android activity-element 文档](https://developer.android.com/guide/topics/manifest/activity-element#wsoft)。
101
- Chrome 浏览器本体不走这套(自己读 insets),但 **WebView 完全由宿主 App 的
102
- 这个设置决定**(见 2.4)。
103
- - **[社区]** 第三方 IME 键盘高度与系统上报不一致的公开案例:搜狗输入法键盘比默认
104
- 输入法高约 30px,导致输入框被压住一截
105
- ([CSDN:固定在 H5 底部的输入框的兼容性问题](https://blog.csdn.net/kill370354/article/details/128390440))。
106
- - **[社区]** 小米设备网页端键盘遮挡的公开案例:
107
- - [CSDN:小米手机浏览器的 input/textarea 底部被遮挡](https://blog.csdn.net/wngzhem/article/details/102524828)
108
- (小米自带浏览器,聚焦后键盘盖住吸底输入框);
109
- - [微信开放社区:小程序 webview 中 fixed 定位 input,小米 6 软键盘遮挡必现](https://developers.weixin.qq.com/community/develop/doc/0002ee32d84c1020523aaeab75b000);
110
- - [微信开放社区:web-view 在 Android 检测不到键盘弹出,造成遮挡](https://developers.weixin.qq.com/community/develop/doc/167b6d37a9efaffb0722c7950ede5adc);
111
- - [微信开放社区:软键盘弹出状态影响页面显示(安卓兼容)](https://developers.weixin.qq.com/community/develop/doc/000042e586c698167262e4b1961400)。
112
- - **微信输入法(WeType)+ 小米的组合,没有检索到公开的一手 bug 记录**(Chromium
113
- tracker、微信开放社区、GitHub 均未见立案)。“WeType 在 MIUI/HyperOS 上 insets
114
- 上报异常”目前只能算**与源码机制吻合的假设**,需要在设备上实测证实(见第四节
115
- 的判别方法)。此点如实标注:**传闻/未证实**。
116
- - **[社区]** WeType 与微信键盘生态的杂项 bug 汇总(非本问题直接证据):
117
- [知乎:微信输入法发布 1 月了,你发现了哪些 bug](https://zhuanlan.zhihu.com/p/599576914)。
118
-
119
- ### 2.4 系统 WebView 的差异(重要)
120
-
121
- - **[规范/官方]** Chrome 108 的行为变化**明确不适用于 WebView**。blink-dev 原话:
122
- *"There is no intended behavior change for Android WebView. The Android app is
123
- responsible for sizing the WebView and can implement either mode via
124
- windowSoftInputMode."*
125
- 来源:[blink-dev](https://groups.google.com/a/chromium.org/g/blink-dev/c/ge7xTu-VhJ0/m/hq_kcusHAQAJ),
126
- 转引自 [crbug 40287394](https://issues.chromium.org/issues/40287394)。
127
- - **[规范/官方]** crbug 40287394(Adobe 报告,Feature Request)实测映射关系:
128
- - 宿主 App `adjustResize` ⇒ 等价 `resizes-content`(布局+视觉一起缩);
129
- - 宿主 App `adjustPan` / `adjustUnspecified` ⇒ 等价 `overlays-content`
130
- (**什么都不缩、页面完全不知道键盘存在**);
131
- - Chrome 默认的 `resizes-visual` 在 WebView 里**无法实现**;
132
- - **VirtualKeyboard API 在 WebView 里不可用**:boundingRect 全零、
133
- `geometrychange` 永不触发(issue 原文)。
134
- 该 issue 2025 年标记 Fixed(CL [6616171](https://chromium-review.googlesource.com/6616171)),
135
- 但报告者在 WebView Canary 139 实测仍未见效(issue #21 楼),落地状态存疑。
136
- - **推论:如果"系统 WebView 访问"指从某个 App(如微信、小米自带应用)内打开,
137
- 且宿主是 adjustPan/unspecified,则遮挡是设计使然,与 IME 无关。** 判别方法见
138
- 第四节。
139
-
140
- ### 2.5 PWA standalone 模式的差异
141
-
142
- - **[社区]** 有零散反馈称同一站点"浏览器 tab 正常、装成 PWA 后键盘行为异常":
143
- - [code-server #7149](https://github.com/coder/code-server/issues/7149)
144
- (Android 15 / Chrome 131,PWA 内键盘输入异常,"the regular chrome website
145
- doesn't have these issues";已关闭,归为上游问题);
146
- - [PWABuilder #2572](https://github.com/pwa-builder/PWABuilder/issues/2572)
147
- (TWA 中键盘收起后视口卡在缩小状态)。
148
- - **没有找到"standalone 下键盘视口行为与 tab 系统性不同"的 Chromium 官方一手
149
- 记录**。WebAPK 安装的 PWA 仍由 Chrome 渲染,理论上走 Chrome 的 insets 链而非
150
- WebView 的 windowSoftInputMode 链。此点标注:**社区案例,未证实**。
151
-
152
- ### 2.6 VirtualKeyboard API
153
-
154
- - **[规范/官方]** `navigator.virtualKeyboard`:设 `overlaysContent = true` 后浏览器
155
- 完全不再为键盘调整视口,改由页面用 `geometrychange` 事件 + `boundingRect` +
156
- CSS `env(keyboard-inset-*)` 自己布局。**只有 opt-in 之后这些能力才生效**。
157
- 来源:[MDN VirtualKeyboard API](https://developer.mozilla.org/en-US/docs/Web/API/VirtualKeyboard_API)、
158
- [Chrome 官方文档](https://developer.chrome.com/docs/web-platform/virtual-keyboard)、
159
- [W3C 草案](https://w3c.github.io/editing/docs/virtualkeyboard/)。
160
- - **[规范/官方]** 支持范围(MDN BCD `api/VirtualKeyboard.json`):Chromium 系
161
- **94+**(含 Android Chrome);Firefox、Safari 均未实现(分别见
162
- [bugzil.la/1730568](https://bugzilla.mozilla.org/show_bug.cgi?id=1730568)、
163
- [webkit.org/b/230225](https://bugs.webkit.org/show_bug.cgi?id=230225))。
164
- 非 Baseline、标注实验性。
165
- - **[规范/官方]** WebView 中名义上存在但实际不可用(boundingRect 全零,见 2.4)。
166
- - **适用性结论:它的键盘几何数据和 visual viewport 用的是同一条 insets 链
167
- ([源码]推论),所以对"IME 不上报高度"这类根因**同样无能为力**,还把 Firefox/
168
- Safari 排除在外。本场景不推荐作为主方案。**
169
-
170
- ### 2.7 业界聊天 UI 的实际做法(源码)
171
-
172
- - **[源码]** Telegram Web A([Ajaxy/telegram-tt](https://github.com/Ajaxy/telegram-tt)):
173
- - viewport meta **没有** `interactive-widget`(`index.html`);
174
- - [`src/util/windowSize.ts`](https://github.com/Ajaxy/telegram-tt/blob/master/src/util/windowSize.ts):
175
- iOS 用 `visualViewport.height + visualViewport.pageTop` 维护 `--vh` CSS 变量并监听
176
- `visualViewport resize`;**Android 只听 `window.resize`**,即在 Android 上
177
- 基本信任浏览器默认的 resizes-visual 自动行为(跟本插件目前思路一致);
178
- - 键盘可见性判断就是「innerHeight 比初值小」的启发式。
179
- - **[社区]** 通用做法参考:用 visualViewport 把底栏 `translateY` 到
180
- `innerHeight - vv.height - vv.offsetTop` 处
181
- ([dev.to: Fix mobile keyboard overlap with visualViewport](https://dev.to/franciscomoretti/fix-mobile-keyboard-overlap-with-visualviewport-3a4a))。
182
-
183
- ---
184
-
185
- ## 三、已知案例 / 相关 bug 清单
186
-
187
- | 编号/链接 | 内容 | 状态 | 置信度 |
188
- | --- | --- | --- | --- |
189
- | [crbug 40287394](https://issues.chromium.org/issues/40287394) | WebView 无法只缩 visual viewport;adjustPan≈overlays-content;VirtualKeyboard API 在 WebView 全零 | Fixed(2025,落地存疑) | 官方 |
190
- | [crbug 40924170](https://issues.chromium.org/issues/40924170) | overlays-content 下键盘动画期间 innerHeight 出现瞬时错误值 | Fixed,2025-10 复发报告 → bug 450752874 | 官方 |
191
- | [crbug 41176235](https://issues.chromium.org/issues/41176235) | 108 之前"键盘缩窗口打乱 SPA 布局"的历史诉求 | 历史参考 | 官方 |
192
- | [crbug 41343106](https://issues.chromium.org/issues/41343106) / [40768751](https://issues.chromium.org/issues/40768751) | visualViewport 事件在 URL 栏动画 / iframe 场景不触发 | — | 官方 |
193
- | [微信开放社区(小米 6 webview fixed input 必现遮挡)](https://developers.weixin.qq.com/community/develop/doc/0002ee32d84c1020523aaeab75b000) 等 3 帖 | 小米/华为设备上 webview 检测不到键盘、fixed 输入框被盖 | 无官方结论 | 社区 |
194
- | [CSDN 小米浏览器输入框被盖](https://blog.csdn.net/wngzhem/article/details/102524828)、[搜狗键盘高 30px](https://blog.csdn.net/kill370354/article/details/128390440) | OEM 浏览器 / 第三方 IME 高度不一致案例 | — | 社区 |
195
- | [code-server #7149](https://github.com/coder/code-server/issues/7149)、[PWABuilder #2572](https://github.com/pwa-builder/PWABuilder/issues/2572) | PWA/TWA 形态下键盘视口异常 | 关闭/未决 | 社区 |
196
- | WeType + 小米组合 | **未检索到公开一手记录** | — | 传闻/未证实 |
197
-
198
- ---
199
-
200
- ## 四、根因候选排序(附判别性预期)
201
-
202
- 按可能性从高到低。每条给出「如果是它,会观察到什么」,用项目自带的
203
- `?debug` 面板(`src/client/debug.ts` 已经显示 visualViewport 数据)即可判别。
204
-
205
- 1. **WeType(或 MIUI/HyperOS 对其的 insets 处理)没有把键盘高度报进
206
- `WindowInsets.ime()`,Chrome 端算得键盘高度为 0。**([源码]链推论;
207
- WeType 环节本身未证实)
208
- 预期观察:键盘弹出前后 `visualViewport.height` **纹丝不动**、无 resize 事件、
209
- `innerHeight` 不变、浏览器也不自动滚动光标。切回 Gboard 立即正常。
210
- 若再验证:同一设备上 Chrome 换成 Firefox Android(走自己的 insets 消费逻辑)
211
- 看是否同样失效,可区分"IME 没报"还是"Chrome 没读到"。
212
- 2. **WeType 处于悬浮/迷你键盘等特殊窗口形态**(悬浮键盘本来就不占 insets,
213
- overlay 是预期行为)。
214
- 预期观察:键盘不是全宽贴底的常规形态;改回标准全键盘模式立刻正常。
215
- 3. **访问入口实为某 App 内嵌 WebView,宿主 windowSoftInputMode 是
216
- adjustPan/unspecified** ⇒ 等价 overlays-content,设计如此([官方] crbug 40287394)。
217
- 预期观察:同一页面在独立 Chrome 里正常,仅从该 App 打开时遮挡;且此时换
218
- Gboard 也一样遮挡(与 IME 无关)。
219
- 4. **visualViewport 缩了、事件也来了,但页面结构令浏览器自动滚动失效**
220
- (sticky 底栏 + `html/body overflow:hidden`,浏览器把光标滚进可视区时
221
- 没有可滚的祖先)。
222
- 预期观察:debug 面板里 `vv.height` 明显变小、resize 有触发,但 composer
223
- 仍在键盘下面。此时问题在我们页面侧,走第五节方案 2 必能修。
224
- 5. **PWA standalone 形态特有 bug**([社区],未证实)。
225
- 预期观察:同一 Chrome、同一输入法,浏览器 tab 里正常、仅"添加到主屏幕"的
226
- 窗口里遮挡。
227
- 6. **Chrome 版本撞上瞬时高度 bug 族**(crbug 40924170 / 450752874,
228
- 仅在用了 overlays-content / VirtualKeyboard API 时相关)。本项目没用这些
229
- 模式,基本可排除;若未来启用需注意。
230
-
231
- > 注:候选 1/2 里"页面完全不知道键盘存在"时,**不触发事件是符合 CSSOM View
232
- > 规范的**(§13.1 只有尺寸真变了才 fire),所以"补监听"类方案救不了它们。
233
-
234
- ---
235
-
236
- ## 五、解决路径清单(按性价比排序)
237
-
238
- 1. **先做 10 分钟判别实验,再选方案**(零代码)。
239
- 在故障机上开 `?debug` 面板:点 textarea,看 `visualViewport.height` 变不变。
240
- 变 ⇒ 根因 4,走下面第 2 条,必修得好;不变 ⇒ 根因 1/2/3,第 2 条的事件监听
241
- 部分对它无效,需要第 3/4 条兜底。同时记录:换 Gboard 是否正常、WeType 是否
242
- 悬浮模式、入口是 Chrome 还是 App 内。
243
- 依据:CSSOM View §13.1(事件只在尺寸变化时触发)+ KeyboardUtils 源码链。
244
- 2. **visualViewport `resize` + `scroll` 双监听,把 composer 平移到可视区底边**
245
- (适用:根因 4,以及所有"视口确实缩了"的环境;iOS 也受益)。
246
- 公式:`offset = innerHeight - vv.height - vv.offsetTop`,对 composer 施加
247
- `translateY(-offset)` 或把它的容器高度设为 `vv.height`。
248
- 注意:要同时听 `scroll`(visual viewport 平移时 offsetTop 变化不发 resize,
249
- 规范 §13.2);用 rAF 合并;键盘收起时归零。
250
- 来源:[MDN VisualViewport](https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport)、
251
- [dev.to 实现示例](https://dev.to/franciscomoretti/fix-mobile-keyboard-overlap-with-visualviewport-3a4a)、
252
- telegram-tt iOS 分支同思路([windowSize.ts](https://github.com/Ajaxy/telegram-tt/blob/master/src/util/windowSize.ts))。
253
- 风险:低;纯增量,事件不来时等于没装。
254
- 3. **meta viewport 加 `interactive-widget=resizes-content`**
255
- (适用:想回到"键盘顶起整个布局"的老行为,让 sticky/fixed 底栏天然可见;
256
- Chrome Android 108+ / Firefox Android 133+,其他浏览器安全忽略)。
257
- 来源:[Chrome 博客](https://developer.chrome.com/blog/viewport-resize-behavior)、
258
- [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport)。
259
- 风险:a) 它依赖同一条 insets 链,**对根因 1/2 大概率同样无效**(键盘高度
260
- 为 0 时无从缩起)—— 但值得实测,因为 resizes-content 在 Chrome 内部走
261
- `adjustResize` 型窗口缩放路径,与 visual-viewport 路径不完全相同;
262
- b) 键盘弹出时 `100vh/dvh`、媒体查询、整页布局都会跳一次,本插件大量
263
- "document 永不滚动"的假设要回归测试。工作量:一行 meta + 回归测试。
264
- 4. **focus 后轮询兜底(专治"事件不来"的 IME)**
265
- (适用:根因 1/2 确诊,又不能要求用户换输入法时)。
266
- focusin 到 composer 后,短周期(如每 100ms,持续 ~1.5s)比对
267
- `vv.height`/`innerHeight`;若始终没变化,视为"哑键盘",给 composer 加一段
268
- 估计高度的底部让位(如 `min(40vh, 320px)`),blur 后撤掉。
269
- 这是社区通行的启发式([微信开放社区案例](https://developers.weixin.qq.com/community/develop/doc/167b6d37a9efaffb0722c7950ede5adc)
270
- 即"监听 focus 前后 window 高度变化"思路的引申,**[社区]**,无官方背书)。
271
- 风险:估高不准(键盘高度因输入法/表情面板而异);必现误判面(外接键盘用户
272
- 会被白白顶起 —— 可用 `navigator.virtualKeyboard`/触摸能力启发式减噪)。
273
- 建议只在「判别实验证实事件确实不来」后再上。
274
- 5. **focus 时 `scrollIntoView` 兜底**:对本项目**基本无效** —— composer 已经
275
- sticky 在滚动容器底部、document 又 `overflow:hidden`,没有可滚的余地;它只
276
- 对"输入框在长文档中部"的常规页面有意义。列出仅为完整性(**[社区]** 通行做法)。
277
- 6. **VirtualKeyboard API(`overlaysContent` + `env(keyboard-inset-*)`)**:
278
- 本场景不推荐。仅 Chromium 94+、WebView 全零([crbug 40287394](https://issues.chromium.org/issues/40287394))、
279
- 与根因 1/2 同链失效,且曾有瞬时高度 bug 族([crbug 40924170](https://issues.chromium.org/issues/40924170))。
280
- 7. **运维/用户侧路径**(正规修复之外的现实选项):让该用户在 WeType 里关闭
281
- 悬浮键盘、或临时切 Gboard 验证;升级 Android System WebView / Chrome;
282
- 若确诊是 WeType insets 问题,向微信输入法反馈(其 Android 端无公开 tracker,
283
- 走 App 内反馈)。**[社区/操作建议]**
284
-
285
- 推荐组合:**路径 1(判别)→ 路径 2(无条件先装上,覆盖大多数环境并给 iOS
286
- 兜底)→ 视判别结果决定是否加 3 或 4**。
287
-
288
- ---
289
-
290
- ## 六、参考链接汇总
291
-
292
- 规范 / 官方文档:
293
-
294
- - CSS Viewport Module Level 1(interactive-widget):<https://drafts.csswg.org/css-viewport/>
295
- - CSSOM View §13 事件触发条件:<https://drafts.csswg.org/cssom-view/#resizing-viewports>
296
- - Chrome 108 视口行为变更官方博客:<https://developer.chrome.com/blog/viewport-resize-behavior>
297
- - blink-dev Intent(含 WebView 不适用声明):<https://groups.google.com/a/chromium.org/g/blink-dev/c/ge7xTu-VhJ0>
298
- - MDN viewport meta / interactive-widget:<https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport>
299
- - MDN VirtualKeyboard API:<https://developer.mozilla.org/en-US/docs/Web/API/VirtualKeyboard_API>
300
- - Chrome VirtualKeyboard 文档:<https://developer.chrome.com/docs/web-platform/virtual-keyboard>
301
- - W3C VirtualKeyboard 草案:<https://w3c.github.io/editing/docs/virtualkeyboard/>
302
- - Android windowSoftInputMode:<https://developer.android.com/guide/topics/manifest/activity-element#wsoft>
303
- - BCD 支持数据:<https://github.com/mdn/browser-compat-data>(interactive-widget、api/VirtualKeyboard)
304
-
305
- 源码:
306
-
307
- - Chromium 键盘高度计算:<https://chromium.googlesource.com/chromium/src/+/refs/heads/main/base/android/java/src/org/chromium/base/ui/KeyboardUtils.java>
308
- - Chromium KeyboardVisibilityDelegate:<https://chromium.googlesource.com/chromium/src/+/refs/heads/main/ui/android/java/src/org/chromium/ui/KeyboardVisibilityDelegate.java>
309
- - telegram-tt 视口处理:<https://github.com/Ajaxy/telegram-tt/blob/master/src/util/windowSize.ts>
310
- - viewport-resize-behavior explainer(bramus,Chrome DevRel):<https://github.com/bramus/viewport-resize-behavior/blob/main/explainer.md>
311
-
312
- Bug tracker:
313
-
314
- - crbug 40287394(WebView 视口/VK API):<https://issues.chromium.org/issues/40287394>
315
- - crbug 40924170(overlays-content 瞬时高度):<https://issues.chromium.org/issues/40924170>
316
- - crbug 41176235 / 41343106 / 40768751(历史与事件类):见第三节表格
317
-
318
- 社区案例(未证实):
319
-
320
- - 微信开放社区三帖、CSDN 两文、知乎 WeType bug 汇总、code-server #7149、
321
- PWABuilder #2572 —— 链接见第二、三节行内。