diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index 1bca41b947..d285f9bab8 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly const before = readFileSync(join(repository, 'pnpm-lock.yaml')) const plan = prepareRuntimePatches(project, repository) expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.3.0', 'node-pty@1.2.0-beta.15']) - 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 } expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined() expect(settings.overrides['@earendil-works/pi-ai@^1.3.0']).toBe('1.3.0') diff --git a/apps/web/index.html b/apps/web/index.html index f5c2ffac06..598c7a62f2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + diff --git a/apps/web/tests/composer-focus.e2e.ts b/apps/web/tests/composer-focus.e2e.ts index f3a15020c9..fb110595c4 100644 --- a/apps/web/tests/composer-focus.e2e.ts +++ b/apps/web/tests/composer-focus.e2e.ts @@ -55,21 +55,25 @@ async function exerciseKeyboardViewport(page: Page): Promise { 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('[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(() => { diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index c1fee1e87a..5fd4cab81f 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -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) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 681f796105..aedfa270e2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f15bb0e7a3..5bad942451 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 76a0b25ca4..d756b06020 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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)。 diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css index 3d153065b4..c1b1cae009 100644 --- a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -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 { diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 40df87269b..b46db926da 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) { {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} { - 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: , diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 123a0fcbea..67ad49f958 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 { diff --git a/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts index 3a430024d6..42f9a51e85 100644 --- a/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts +++ b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts @@ -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() diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index bc48ea0d38..44f56b97ca 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -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() + 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() diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index bd0e87b2db..2fa9af0ee2 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/README.i18n.yaml @@ -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 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 93f6bb9529..36cf3a9556 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -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. + +### 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. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index c913362067..ffb8756129 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -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 都会使构建失败。字体的字形、字重、样式和布局规则保持不变。 + +### 视口尺寸 + +根容器通过绝对定位铺满 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。 diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index af74988cf1..3cb02ff38c 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -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,