fix(client): retain mobile viewport and input behavior on the split base

This commit is contained in:
Stevring committed 2026-10-20 12:16:57 +08:00
1 parent f23d7801bd
commit feaed5016b
17 files changed
+117 -23

No files matched your search

@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
'@yao-pkg/[email protected]': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
'[email protected]': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
'[email protected]': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
'[email protected]': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
'[email protected]': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
}
@@ -43,8 +43,9 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
const plan = prepareRuntimePatches(project, repository)
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/[email protected]', '[email protected]'])
expect(plan.workspaceOnly).toHaveLength(7)
expect(plan.workspaceOnly).toHaveLength(8)
expect(plan.workspaceOnly).toContain('@botiverse/oar')
expect(plan.workspaceOnly).toContain('lexical')
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
expect(settings.overrides['@earendil-works/pi-ai@^1.3.0']).toBe('1.3.0')
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" media="(prefers-color-scheme: light)" />
+5 -1
View File
@@ -55,21 +55,25 @@ async function exerciseKeyboardViewport(page: Page): Promise<string> {
const scroll = window.scrollY
const offset = Object.getOwnPropertyDescriptor(viewport, 'offsetTop')
style.setProperty('height', `${window.innerHeight * 2}px`)
window.scrollTo(0, window.innerHeight)
const input = document.querySelector<HTMLElement>('[data-composer-input]')!
window.scrollTo(0, input.getBoundingClientRect().top + scroll - window.innerHeight / 4)
// Safari can report the keyboard's page displacement as offsetTop even
// though DOM rectangles already account for that page scroll.
Object.defineProperty(viewport, 'offsetTop', { configurable: true, get: () => window.scrollY })
window.dispatchEvent(new Event('scroll'))
return () => {
if (offset === undefined) Reflect.deleteProperty(viewport, 'offsetTop')
else Object.defineProperty(viewport, 'offsetTop', offset)
if (height === '') style.removeProperty('height')
else style.setProperty('height', height, priority)
window.scrollTo(0, scroll)
window.dispatchEvent(new Event('scroll'))
}
})
try {
const before = await input.evaluate(el => ({ top: el.getBoundingClientRect().top, scroll: window.scrollY }))
expect(before.scroll).toBeGreaterThan(0)
expect(before.top).toBeGreaterThanOrEqual(0)
await page.keyboard.insertText('z')
const after = await input.evaluate(el => new Promise<{ top: number; scroll: number }>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
+9 -2
View File
@@ -201,10 +201,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
it.each([
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', automatic: false },
{ device: 'Android Chrome', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', automatic: false },
{ device: 'iPhone Chrome', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 CriOS/139.0.0.0 Mobile/15E148 Safari/604.1', automatic: false },
{ device: 'narrow desktop', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', automatic: true },
])('respects $device search focus when opening /model', async ({ userAgent, automatic }) => {
])('respects $device viewport and search focus when opening /model', async ({ userAgent, automatic }) => {
const context = await browser.newContext({
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE,
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: !automatic,
})
try {
const devicePage = await context.newPage()
@@ -213,14 +215,19 @@ describe('web e2e: the composer model switch is the default for later sessions',
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
const composer = devicePage.locator('[data-composer-input][contenteditable="true"]').first()
await composer.waitFor()
expect(await composer.evaluate(element => getComputedStyle(element).fontSize)).toBe(automatic ? '14px' : '16px')
await composer.fill('/model')
await devicePage.getByRole('option', { name: /^模型/ }).click()
const search = devicePage.getByRole('textbox', { name: '筛选选项', exact: true })
await search.waitFor()
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
expect(await devicePage.locator('meta[name="viewport"]').getAttribute('content')).toBe(
'width=device-width, initial-scale=1, viewport-fit=cover',
)
expect(await search.evaluate(input => document.activeElement === input)).toBe(automatic)
await search.click()
expect(await search.evaluate(input => document.activeElement === input)).toBe(true)
expect(await devicePage.evaluate(() => window.visualViewport?.scale)).toBe(1)
await search.fill('Origin')
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
expect(await devicePage.getByRole('option', { name: /Acme/ }).count()).toBe(0)
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 6cbb0800f2db9220
zh: 405701def5431492
en: 9379a248ebd47974
zh: 584bc4eaa7fa73fd
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
@@ -64,6 +64,8 @@ Queue previews enable whitespace collapsing and document-edge trimming, retain e
The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox.
The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply.
The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit.
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
@@ -64,6 +64,8 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。
在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。
控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
@@ -10,8 +10,10 @@
-webkit-box-decoration-break: clone;
}
.reference:hover {
background: var(--dsw-alias-state-business-tertiary);
@media (hover: hover) {
.reference:hover {
background: var(--dsw-alias-state-business-tertiary);
}
}
.openable {
@@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<span
className={css.fishHitbox}
onMouseEnter={() => {
if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
onPointerEnter={(event) => {
if (event.pointerType !== 'touch'
&& window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
setHovering(true)
}
}}
onMouseLeave={() => { setHovering(false) }}
onPointerLeave={() => { setHovering(false) }}
>
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
fallback: <HeroFish hovering={hovering} />,
@@ -76,6 +76,12 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@media (pointer: coarse) {
.card {
font-size: max(16px, var(--dsh-content-font-size, 14px));
}
}
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
workspace picker rather than a live composer. Keeping the complete stroke
inside the rounded box prevents clipped corner pixels from reading heavier. */
@@ -103,8 +109,10 @@
pointer-events: none;
}
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
@media (hover: hover) {
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
}
}
.accessory {
@@ -330,8 +338,10 @@
cursor: pointer;
}
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
@media (hover: hover) {
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.add:disabled {
@@ -362,8 +372,10 @@
appearance: none;
}
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
@media (hover: hover) {
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.select:disabled {
@@ -395,8 +407,10 @@
transform: translateY(-2px);
}
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
@media (hover: hover) {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
}
.primary:disabled {
@@ -23,7 +23,7 @@ function fixture(viewport: { height: number; offsetTop: number; pageTop: number
document.body.append(root)
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
onUpdate: () => { runtime.refreshProjection() },
openReference: () => false, activeClaimToken: () => null,
openReference: () => false,
lexicon: () => new Map(), resolveLexicon: () => undefined,
})
const dispose = runtime.register()
@@ -4,9 +4,20 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
import { focusWithoutRing } from '../src/focus.ts'
afterEach(cleanup)
it('retains automatic focus styling while typing and restores it for keyboard navigation', () => {
render(<button>Automatic destination</button>)
const button = screen.getByRole('button')
focusWithoutRing(button)
fireEvent.keyDown(button, { key: 'a' })
expect(button.hasAttribute('data-dsh-automatic-focus')).toBe(true)
fireEvent.keyDown(button, { key: 'ArrowDown' })
expect(button.hasAttribute('data-dsh-automatic-focus')).toBe(false)
})
describe('Button', () => {
it('exposes its native button for focus and releases the ref on unmount', () => {
const ref = createRef<HTMLButtonElement>()
+5 -2
View File
@@ -15,8 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-web/use-this-package:
en: 3105391bad2e9f06
zh: fe0c5af5db6c241c
en: 5dba7f9e0c6a94c8
zh: f160f806c487337b
/deepseek-ai-dsh-client-web/use-this-package/viewport-sizing:
en: 8c0b533d25f077ce
zh: 0cf0b6ae1b23f344
/deepseek-ai-dsh-client-web/use-this-package/what-boot-looks-like:
en: f3b01aa878764e5c
zh: f02eab65d441bfe0
+11
View File
@@ -31,8 +31,19 @@ Static application pages install `__DSH_BOOT_READY__` before the entry runs. The
The shell base styles apply automatic CJK/Latin spacing to ordinary content in supporting browsers. Semantic code and terminal, diff, read, and search output containers retain literal source spacing and column alignment; browsers without `text-autospace` support ignore both declarations.
The shell sets text-size adjustment to 100%, so mobile browsers render configured font sizes without automatically enlarging text after viewport changes. Application font-size preferences still apply.
Web and Desktop mathematical rendering requires WOFF2 support. The [Web build](../../../apps/web/katex-fonts.ts) retains every KaTeX font face in WOFF2 and omits WOFF/TTF fallbacks; a face without WOFF2 rejects the build. Font glyphs, weights, styles, and layout rules are preserved.
<a id="viewport-sizing"></a>
### Viewport sizing
The root fills the CSS layout viewport with absolute positioning. Opening the keyboard retains the application's layout height, so the homepage and navigation drawer do not resize with the keyboard. The browser pans the page to reveal focused inputs; inner panes own content scrolling.
The root applies horizontal safe-area padding and publishes `--dsh-safe-area-top` and `--dsh-safe-area-bottom` for frame content padding.
The [Web application entry](../../../apps/web/index.html) declares `width=device-width, initial-scale=1, viewport-fit=cover` without limiting user zoom. Touch inputs use a minimum font size of 16px to avoid focus-triggered page enlargement.
### What boot looks like
Boot runs in two stages: the module stage adopts the parser-loaded bootstrap batch, builds the module system from the Host-provided boot graph, and prefetches the `immediately` tier through the shared application-batch URL, which executes once. The plugin stage then activates every graph entry and waits for all of them before handing the marked boot DOM to the UI renderer, which hydrates it and switches to the complete UI.
+11
View File
@@ -31,8 +31,19 @@ kind: "package-library"
外壳基础样式会在支持的浏览器中为普通内容自动添加中西文间距。语义化代码以及终端、diff、读取和搜索输出容器会保留源码中的原始间距和列对齐;不支持 `text-autospace` 的浏览器会忽略这两项声明。
外壳将文字尺寸调整比例设为 100%,让移动浏览器按配置的字号渲染文字,避免视口变化后自动放大。应用内的字号偏好仍然生效。
Web 和 Desktop 的数学公式渲染要求支持 WOFF2。[Web 构建](../../../apps/web/katex-fonts.ts)以 WOFF2 保留全部 KaTeX 字体样式,并排除 WOFF/TTF 后备格式;任一字体样式缺少 WOFF2 都会使构建失败。字体的字形、字重、样式和布局规则保持不变。
<a id="viewport-sizing"></a>
### 视口尺寸
根容器通过绝对定位铺满 CSS 布局视口。键盘抬起时,应用保留原有布局高度,首页和导航抽屉不会随键盘缩放。浏览器平移页面以露出获得焦点的输入框,内容滚动由内部面板负责。
根容器应用左右安全区边距,并发布 `--dsh-safe-area-top` 和 `--dsh-safe-area-bottom`,供框架为内容保留上下边距。
[Web 应用入口](../../../apps/web/index.html)声明 `width=device-width, initial-scale=1, viewport-fit=cover`,不限制用户缩放。触摸输入框使用至少 16px 的字号,避免获得焦点时自动放大页面。
### 启动过程是怎样的
启动分两个阶段:模块阶段接纳 parser 已加载的 bootstrap 批次,从 Host 提供的启动图构建模块系统,并通过只执行一次的共享 application 批次 URL 预取 `immediately` 层级。插件阶段随后激活每个图 entry 并等待全部就绪,之后才把带标记的启动 DOM 交给 UI 渲染器,由它 hydrate 并切换到完整 UI。
+24
View File
@@ -8,6 +8,30 @@ body,
margin: 0;
}
html,
body {
overflow: hidden;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Inner panes own scrolling. The browser pans focused inputs without resizing
the application or recentering its content when the keyboard opens. */
#root {
--dsh-safe-area-top: env(safe-area-inset-top, 0px);
--dsh-safe-area-bottom: env(safe-area-inset-bottom, 0px);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
}
body {
font-family: var(
--dsw-font-family,