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 +4 -2
- package/lib/client.js +225 -6
- package/lib/types/client/effects/keyboard-avoid.d.ts +47 -3
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/check-keyboard-avoid.mjs +54 -1
- package/src/client/effects/keyboard-avoid.ts +221 -4
- package/src/client/styles/composer.css.ts +6 -2
- package/docs/research-ime-keyboard-occlusion.md +0 -321
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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,
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 —— 链接见第二、三节行内。
|