Merge pull request #6673 from deepseek-harness/fix/touch-tooltip-interactions
fix(client): align touch tooltips and hover affordances
This commit is contained in:
commit
b839823118
197 files changed
+2933
-841
No files matched your search
@@ -56,7 +56,7 @@ Scrollbars stay inside their container: use the shared scrollbar styles rather t
|
||||
|
||||
## Responsive layouts
|
||||
|
||||
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes.
|
||||
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes.
|
||||
|
||||
## Platform window adaptation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-responsive-ui
|
||||
description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
|
||||
description: Use when designing, implementing, or reviewing responsive client layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
|
||||
---
|
||||
|
||||
# Responsive client UI
|
||||
@@ -39,10 +39,10 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
|
||||
|
||||
## 6. Mobile hover behavior
|
||||
|
||||
1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this.
|
||||
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover.
|
||||
3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction.
|
||||
4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result.
|
||||
1. Enable hover effects under `@media (hover: hover)`, independently of viewport width. This queries the primary pointing device, not the current event; JavaScript hover handlers must ignore touch events on mixed-input devices.
|
||||
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. Without hover, actions remain visible or reachable through an explicit control. Keyboard users can reach and reveal the same actions.
|
||||
3. Preserve normal selected, expanded, enabled, and pressed states, as well as focus indicators. Keep these styles independent of hover capability. Do not clear focus to remove a hover effect; `:focus-visible` can also apply to text fields focused by touch.
|
||||
4. Preserve focus during width changes when the control remains visible and interactive. Avoid remounting controls only to change layout. If the focused control is hidden or removed, move focus to a suitable remaining control without opening the mobile keyboard unnecessarily.
|
||||
|
||||
## 7. Mobile tooltips
|
||||
|
||||
@@ -52,3 +52,8 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
|
||||
## 8. Mobile keyboard
|
||||
|
||||
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
|
||||
|
||||
## 9. Input capabilities and mobile text fields
|
||||
|
||||
1. Prefer CSS media features for presentation: `(pointer: coarse)` describes the primary pointer's accuracy, `(hover: none)` describes its inability to hover, and width queries select layout breaks. These queries do not detect a physical keyboard. Never branch layout on a parsed user agent.
|
||||
2. Text fields take a 16px floor under `(pointer: coarse)` to avoid iOS Safari's focus zoom. Fine pointers keep the existing compact field sizes. Use the field rules in `packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css` as the reference.
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 1f9c004f3eb465fb
|
||||
zh: 3e45ef8014bad48d
|
||||
/apps-web-browser-e2e/these-are-host-face-tests:
|
||||
en: 8c406d423c8e2137
|
||||
zh: a66a144fd146f56c
|
||||
en: 101d687b1d98c5b7
|
||||
zh: f8c31115e8a15be7
|
||||
/apps-web-browser-e2e/do-not-import-deepseek-ai-dsh-client-here:
|
||||
en: 7cc75a6413647142
|
||||
zh: a8c541f9b93e9f7f
|
||||
@@ -32,6 +32,8 @@ Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the loca
|
||||
|
||||
They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile.
|
||||
|
||||
A new scenario joins both program lists in the same change: its file in the root `tsconfig.host.json` include and in the Client aggregate's `apps/web/tsconfig.json` exclude. That project includes all of `tests`, so a file missing from the exclude compiles as Client code, and the Client compiler gate of `pnpm run typecheck` keeps failing with Host-service errors until it is listed.
|
||||
|
||||
## Do not import `@deepseek-ai/dsh-client-*` here
|
||||
|
||||
Importing a Client package — a value or a type — pulls its TypeScript project and referenced projects into the Host build graph. Generated Remote declarations are available before package compilation, but this does not make the Host and Client Cordis `Context` declarations compatible. Keep these tests on the Host face and use only the explicit built-client harness exception below.
|
||||
|
||||
@@ -32,6 +32,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
|
||||
|
||||
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。
|
||||
|
||||
新增场景要在同一次改动里登记到两份工程清单:根 `tsconfig.host.json` 的 include,以及 Client aggregate `apps/web/tsconfig.json` 的 exclude。该工程包含整个 `tests` 目录,漏登记 exclude 的文件会按 Client 代码编译,`pnpm run typecheck` 的 Client compiler 关卡会一直以 Host 服务错误失败,直到它被列入。
|
||||
|
||||
## 不要在此 import `@deepseek-ai/dsh-client-*`
|
||||
|
||||
import 一个 Client 包——无论值还是类型——都会把它的 TypeScript 工程及其引用的工程拉进 Host 构建图。生成的 Remote 声明在包编译之前就绪,但这并不使 Host 与 Client 的 Cordis `Context` 声明兼容。保留这些测试的 Host face 归属,只使用下文明确的 built-client harness 例外。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Touch tooltips
|
||||
|
||||
Touch refresh: one refresh request; no tooltip
|
||||
Touch explanation: visible within the 390px viewport
|
||||
- tooltip "在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看"
|
||||
Explanation closes on another tap or an outside touch
|
||||
Keyboard focus after touch: refresh tooltip visible
|
||||
Mouse hover after touch: refresh tooltip visible
|
||||
@@ -0,0 +1,5 @@
|
||||
# Touch copy feedback
|
||||
|
||||
Copy succeeds and the button returns from Copied to Copy.
|
||||
Tooltips during copy feedback: 0
|
||||
Tooltips after emulated mouse entry: 0
|
||||
@@ -0,0 +1,242 @@
|
||||
/** Pointer, keyboard, and resize behavior in the assembled client. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Locator } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
it.each([false, true])('shows the new-session shortcut on keyboard focus with touch=%s', async (hasTouch) => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
onTestFinished(() => scaffold.close())
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
|
||||
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
|
||||
const page = await context.newPage()
|
||||
onTestFailed(() => saveFailureShot(page, `new-session-focus-${String(hasTouch)}`))
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
const button = page.getByRole('button', { name: 'New session', exact: true }).filter({ has: page.locator('kbd') })
|
||||
await button.waitFor()
|
||||
const shortcut = button.locator('[class*="newSessionShortcut"]')
|
||||
const mask = button.locator('[class*="newSessionLabelMask"]')
|
||||
const opacity = () => shortcut.evaluate(element => getComputedStyle(element).opacity)
|
||||
const maskImage = () => mask.evaluate(element => getComputedStyle(element).maskImage)
|
||||
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
|
||||
expect(await opacity()).toBe('0')
|
||||
expect(await maskImage()).toBe('none')
|
||||
await button.focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await button.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await opacity()).toBe('1')
|
||||
expect(await maskImage()).not.toBe('none')
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await opacity()).toBe('0')
|
||||
expect(await maskImage()).toBe('none')
|
||||
if (hasTouch) {
|
||||
await button.tap()
|
||||
expect(await button.evaluate(element => element.matches(':focus-visible'))).toBe(false)
|
||||
expect(await opacity()).toBe('0')
|
||||
expect(await maskImage()).toBe('none')
|
||||
} else {
|
||||
await button.hover()
|
||||
expect(await opacity()).toBe('1')
|
||||
expect(await maskImage()).not.toBe('none')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps workspace and archived search actions reachable with touch=%s', async (hasTouch) => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
onTestFinished(() => scaffold.close())
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'ROW_ACTIONS', title: 'Archived row actions', turns: 1 })
|
||||
const id = await seedSession(scaffold, fixture.log, 'row-actions')
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' })
|
||||
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
|
||||
onTestFailed(() => saveFailureShot(page, `row-actions-${String(hasTouch)}`))
|
||||
const activate = async (target: Locator) => {
|
||||
if (hasTouch) await target.tap()
|
||||
else await target.click()
|
||||
}
|
||||
const openFixture = async () => {
|
||||
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
|
||||
await activate(page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem'))
|
||||
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
|
||||
}
|
||||
const row = page.locator(`[data-row-key="session:${id}"]`)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await openFixture()
|
||||
await activate(page.getByRole('button', { name: 'View options', exact: true }))
|
||||
await activate(page.getByRole('menuitem', { name: 'WorkSpace', exact: true }))
|
||||
const menu = page.getByRole('button', { name: 'Workspace actions for workspace', exact: true, includeHidden: true })
|
||||
const project = page.locator('[data-row-key^="workspace:"]').filter({ has: menu })
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch)
|
||||
expect(await menu.isVisible()).toBe(hasTouch)
|
||||
if (!hasTouch) await project.hover()
|
||||
await activate(menu)
|
||||
await page.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
if (!hasTouch) await project.hover()
|
||||
await activate(page.getByRole('button', { name: 'New session in workspace', exact: true }))
|
||||
await expect.poll(() => row.getAttribute('aria-selected')).not.toBe('true')
|
||||
expect(await project.getAttribute('aria-expanded')).toBe('true')
|
||||
await openFixture()
|
||||
if (!hasTouch) await row.hover()
|
||||
const actions = row.getByRole('button', { name: /^Session actions for / })
|
||||
await activate(actions)
|
||||
const archive = page.getByRole('menuitem', { name: 'Archive session', exact: true })
|
||||
const shortcut = archive.locator('[aria-hidden="true"]').filter({ has: page.locator('kbd') })
|
||||
await archive.waitFor()
|
||||
expect(await shortcut.count()).toBe(1)
|
||||
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
|
||||
expect(await shortcut.isVisible()).toBe(!hasTouch)
|
||||
for (const key of ['ArrowDown', 'Tab']) {
|
||||
await page.keyboard.press(key)
|
||||
expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard')
|
||||
expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true)
|
||||
expect(await shortcut.isVisible()).toBe(true)
|
||||
if (hasTouch) {
|
||||
await page.setViewportSize({ width: 390, height: 900 })
|
||||
await expect.poll(() => shortcut.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return bounds.left >= 0 && bounds.right <= window.innerWidth
|
||||
})).toBe(true)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
if (!hasTouch) await row.hover()
|
||||
await activate(actions)
|
||||
await archive.waitFor()
|
||||
expect(await shortcut.isVisible()).toBe(!hasTouch)
|
||||
}
|
||||
await activate(archive)
|
||||
await row.waitFor({ state: 'detached' })
|
||||
await activate(page.getByRole('button', { name: 'View options', exact: true }))
|
||||
await activate(page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }))
|
||||
await activate(page.getByRole('button', { name: 'Search sessions', exact: true }))
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await result.waitFor()
|
||||
const restore = result.getByRole('button', { name: 'Unarchive session', exact: true, includeHidden: true })
|
||||
expect(await restore.isVisible()).toBe(hasTouch)
|
||||
if (!hasTouch) await result.hover()
|
||||
await activate(restore)
|
||||
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(id)).toBe(false)
|
||||
await restore.waitFor({ state: 'detached' })
|
||||
await activate(result)
|
||||
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps actions and focus usable with touch=%s across layout widths', async (hasTouch) => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
onTestFinished(() => scaffold.close())
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'HOVER', title: 'Hover input', turns: 1 })
|
||||
const id = await seedSession(scaffold, fixture.log, 'hover-input')
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US', timezoneId: 'Asia/Shanghai',
|
||||
})
|
||||
const page = await context.newPage()
|
||||
const consoleWatch = watchConsole(page)
|
||||
onTestFailed(() => saveFailureShot(page, `hover-input-${String(hasTouch)}`))
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
expect(await page.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(!hasTouch)
|
||||
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count()).toBe(1)
|
||||
await result.click()
|
||||
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
|
||||
const row = page.locator(`[data-row-key="session:${id}"]`)
|
||||
await row.waitFor({ state: 'visible' })
|
||||
expect(await row.getAttribute('aria-selected')).toBe('true')
|
||||
const copy = page.locator('[data-clock="end"] > button').first()
|
||||
const background = () => copy.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await page.mouse.move(0, 0)
|
||||
const resting = await background()
|
||||
if (hasTouch) {
|
||||
await copy.tap()
|
||||
expect(await background()).toBe(resting)
|
||||
expect(await row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
} else {
|
||||
await copy.hover()
|
||||
await expect.poll(background).not.toBe(resting)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect.poll(background).toBe(resting)
|
||||
}
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await copy.focus()
|
||||
expect(await copy.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
await row.focus()
|
||||
expect(await row.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
expect(await row.getAttribute('aria-selected')).toBe('true')
|
||||
|
||||
const image = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
|
||||
await page.locator('input[type="file"]').setInputFiles(image)
|
||||
const remove = page.getByRole('button', { name: 'Remove image red.png', exact: true })
|
||||
await remove.waitFor({ state: 'visible' })
|
||||
if (hasTouch) {
|
||||
expect(await remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
await remove.tap()
|
||||
} else {
|
||||
await remove.focus()
|
||||
await expect.poll(() => remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
await remove.waitFor({ state: 'detached' })
|
||||
|
||||
const input = page.locator('[data-composer-input]')
|
||||
await input.fill('keep this draft')
|
||||
await input.press('Home')
|
||||
await input.press('Shift+End')
|
||||
const element = await input.elementHandle()
|
||||
const selection = () => input.evaluate(node => ({
|
||||
focused: document.activeElement === node,
|
||||
text: node.textContent,
|
||||
selected: window.getSelection()?.toString(),
|
||||
}))
|
||||
const before = await selection()
|
||||
expect(before.focused).toBe(true)
|
||||
for (const width of [390, 768, 1280]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
expect(await element!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await selection()).toEqual(before)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
|
||||
await panel.locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
await page.mouse.move(0, 0)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('tooltip').waitFor({ state: 'detached' })
|
||||
const blockedSplit = panel.locator('[data-dockkit-split-button]').last()
|
||||
expect(await blockedSplit.isDisabled()).toBe(true)
|
||||
const hit = await blockedSplit.evaluate((button) => {
|
||||
const bounds = button.getBoundingClientRect()
|
||||
const x = bounds.x + bounds.width / 2
|
||||
const y = bounds.y + bounds.height / 2
|
||||
const target = document.elementFromPoint(x, y)
|
||||
return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor }
|
||||
})
|
||||
expect(hit.wrapperReceivesInput).toBe(true)
|
||||
expect(['auto', 'default']).toContain(hit.cursor)
|
||||
// Coordinate input exercises CSS hit testing over the disabled button itself.
|
||||
if (hasTouch) await page.touchscreen.tap(hit.x, hit.y)
|
||||
else await page.mouse.click(hit.x, hit.y)
|
||||
await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
})
|
||||
@@ -20,8 +20,8 @@
|
||||
// NO_ADAPTER.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
@@ -374,6 +374,44 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.each(['chromium', 'webkit'] as const)('keeps wide tables horizontally scrollable without hover in %s', async (engine) => {
|
||||
const touchBrowser = await ({ chromium, webkit })[engine].launch()
|
||||
onTestFinished(() => touchBrowser.close())
|
||||
// Mobile WebKit has no wheel driver; touch emulation also disables hover in its desktop viewport.
|
||||
const context = await touchBrowser.newContext({
|
||||
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: engine === 'chromium', locale: 'en-US',
|
||||
})
|
||||
const touchPage = await context.newPage()
|
||||
const touchConsole = watchConsole(touchPage)
|
||||
onTestFailed(() => saveFailureShot(touchPage, `markdown-wide-table-touch-${engine}`))
|
||||
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const group = touchPage.getByRole('treeitem').first()
|
||||
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
|
||||
await group.click()
|
||||
await touchPage.getByRole('treeitem').filter({ hasText: 'Markdown wide tables' }).click()
|
||||
await touchPage.getByText(TAIL_MARKER, { exact: true }).waitFor()
|
||||
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
|
||||
const wide = touchPage.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
|
||||
await wide.scrollIntoViewIfNeeded()
|
||||
expect(await wide.evaluate(element => ({
|
||||
overflow: getComputedStyle(element).overflowX,
|
||||
overflowing: element.scrollWidth > element.clientWidth,
|
||||
focused: element.matches(':focus-visible'),
|
||||
}))).toEqual({ overflow: 'auto', overflowing: true, focused: false })
|
||||
const box = await wide.boundingBox()
|
||||
if (box === null) throw new Error('Wide table is not visible')
|
||||
// A wheel input must scroll the native overflow container without hover or focus.
|
||||
// Assigning scrollLeft would also work with overflow:hidden and miss this regression.
|
||||
await touchPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await touchPage.mouse.wheel(5_000, 0)
|
||||
await expect.poll(() => wide.evaluate(element => element.scrollLeft)).toBeGreaterThan(0)
|
||||
await expect.poll(() => wide.evaluate((element) => {
|
||||
const last = element.querySelector('thead th:last-child')!
|
||||
return last.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1
|
||||
})).toBe(true)
|
||||
expect(touchConsole.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('reveals the wide table scrollbar on hover only', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
|
||||
await sweep()
|
||||
|
||||
@@ -488,6 +488,88 @@ describe('web e2e: plugin manager', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps touch actions direct before keyboard focus and mouse hover', async () => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 }, locale: ZH_BROWSER_LOCALE,
|
||||
})
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const input = await context.newCDPSession(probe)
|
||||
// Native touch input preserves desktop hover capability for mixed-input devices.
|
||||
const tap = async (target: Locator): Promise<void> => {
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
const bounds = await target.boundingBox()
|
||||
if (bounds === null) throw new Error('Touch target is not visible')
|
||||
await input.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart', touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }],
|
||||
})
|
||||
await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
}
|
||||
const consoleWatch = watchConsole(probe)
|
||||
onTestFailed(() => saveFailureShot(probe, 'web-e2e-touch-tooltips'))
|
||||
await probe.clock.install()
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
|
||||
const info = panel.getByRole('button', { name: '插件说明', exact: true })
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
await probe.setViewportSize({ width: 390, height: 844 })
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
const calls = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
|
||||
try {
|
||||
await tap(refresh)
|
||||
await expect.poll(() => calls.mock.calls.length).toBe(1)
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
} finally {
|
||||
calls.mockRestore()
|
||||
}
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
await probe.clock.runFor(500)
|
||||
expect(await probe.getByRole('tooltip').count()).toBe(0)
|
||||
const facts = ['Touch refresh: one refresh request; no tooltip']
|
||||
|
||||
await tap(info)
|
||||
const help = probe.getByRole('tooltip')
|
||||
await help.waitFor()
|
||||
const bounds = await help.boundingBox()
|
||||
if (bounds === null) throw new Error('Tapped plugin explanation is not visible')
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(12)
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390 - 12)
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(12)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(844 - 12)
|
||||
facts.push('Touch explanation: visible within the 390px viewport', await help.ariaSnapshot())
|
||||
await tap(info)
|
||||
expect(await help.count()).toBe(0)
|
||||
await tap(info)
|
||||
await help.waitFor()
|
||||
await tap(panel.getByRole('heading', { name: '插件', exact: true }))
|
||||
expect(await help.count()).toBe(0)
|
||||
facts.push('Explanation closes on another tap or an outside touch')
|
||||
|
||||
await probe.keyboard.press('Tab')
|
||||
await refresh.focus()
|
||||
await probe.clock.runFor(500)
|
||||
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
|
||||
facts.push('Keyboard focus after touch: refresh tooltip visible')
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await probe.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(true)
|
||||
await refresh.hover()
|
||||
await probe.clock.runFor(500)
|
||||
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
|
||||
facts.push('Mouse hover after touch: refresh tooltip visible')
|
||||
await probe.mouse.move(0, 843)
|
||||
expect(await help.count()).toBe(0)
|
||||
await input.detach()
|
||||
await probe.clock.resume()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'),
|
||||
['# Touch tooltips', '', ...facts].join('\n'), MODE)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -1139,7 +1221,7 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
|
||||
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md',
|
||||
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,6 +175,31 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
expect(await opened()).toHaveLength(count + 1)
|
||||
expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
|
||||
}
|
||||
const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]')
|
||||
await success.waitFor()
|
||||
const animation = await success.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const fade = element.getAnimations()[0]
|
||||
if (fade === undefined) throw new Error('Successful file feedback has no fade animation')
|
||||
// Seek the CSS animation so the 200 ms fade does not race the assertion.
|
||||
fade.pause()
|
||||
fade.currentTime = 5100
|
||||
return {
|
||||
delay: style.animationDelay, duration: style.animationDuration,
|
||||
fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity),
|
||||
}
|
||||
})
|
||||
expect(animation.delay).toBe('5s')
|
||||
expect(animation.duration).toBe('0.2s')
|
||||
expect(animation.fill).toBe('forwards')
|
||||
expect(animation.opacity).toBeGreaterThan(0)
|
||||
expect(animation.opacity).toBeLessThan(1)
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none')
|
||||
} finally {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
}
|
||||
}
|
||||
expect(downloads).toEqual([])
|
||||
const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
|
||||
|
||||
@@ -71,17 +71,22 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
probe.remove()
|
||||
return value
|
||||
}
|
||||
// Computed pseudo style folds in hover even at rest, so inspect the cascade.
|
||||
// Computed pseudo style folds in hover even at rest, so inspect active CSS groups.
|
||||
// Cross-origin sheets may throw and cannot contain the app-owned rule.
|
||||
const styleRules = (rules: CSSRuleList): CSSStyleRule[] => [...rules].flatMap((rule) => {
|
||||
if (rule instanceof CSSStyleRule) return [rule]
|
||||
if (rule instanceof CSSMediaRule && !matchMedia(rule.conditionText).matches) return []
|
||||
if (rule instanceof CSSSupportsRule && !CSS.supports(rule.conditionText)) return []
|
||||
return rule instanceof CSSGroupingRule ? styleRules(rule.cssRules) : []
|
||||
})
|
||||
const hoverRules = [...document.styleSheets]
|
||||
.flatMap((sheet) => {
|
||||
try {
|
||||
return [...sheet.cssRules]
|
||||
return styleRules(sheet.cssRules)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
.filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
|
||||
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
|
||||
.map(rule => rule.style.getPropertyValue('background-color'))
|
||||
const style = getComputedStyle(list)
|
||||
|
||||
@@ -12,14 +12,15 @@ import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { chromium, devices, webkit } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import { openSettings, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
|
||||
@@ -347,3 +348,49 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps copy tooltips hidden during a WebKit touch copy', async () => {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
const browserPromise = webkit.launch()
|
||||
onTestFinished(async () => {
|
||||
try { await (await browserPromise).close() }
|
||||
finally { await scaffold.close() }
|
||||
})
|
||||
const browser = await browserPromise
|
||||
const context = await browser.newContext({
|
||||
...devices['iPhone 13'], locale: 'en-US', viewport: { width: 1280, height: 900 },
|
||||
})
|
||||
const page = await context.newPage()
|
||||
// LAN HTTP pages use the clipboard fallback on iOS.
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
||||
})
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'TOOLTIP_TOUCH', title: 'Touch tooltips', turns: 1 })
|
||||
await seedSession(scaffold, fixture.log, 'tooltip-touch-web-e2e')
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'tooltip-touch')
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
expect(await page.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(false)
|
||||
const copy = page.getByRole('button', { name: 'Copy', exact: true }).last()
|
||||
await copy.tap()
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count()).toBe(1)
|
||||
const duringCopy = await page.getByRole('tooltip').count()
|
||||
expect(duringCopy).toBe(0)
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
// iOS may synthesize mouse entry when the checkmark is replaced by the copy icon.
|
||||
await copy.dispatchEvent('pointerover', { pointerType: 'mouse', bubbles: true })
|
||||
const afterCopy = await page.getByRole('tooltip').count()
|
||||
expect(afterCopy).toBe(0)
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/tooltip-touch/copy.expected.md', import.meta.url)),
|
||||
['# Touch copy feedback', '', 'Copy succeeds and the button returns from Copied to Copy.',
|
||||
`Tooltips during copy feedback: ${duringCopy}`, `Tooltips after emulated mouse entry: ${afterCopy}`].join('\n'),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
// this client-registered project must not also hold them (one program
|
||||
// cannot see both sides of the cordis Context merges).
|
||||
"exclude": [
|
||||
"tests/hover-input.e2e.ts",
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
"tests/fold-blank-matrix.e2e.ts",
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
en: dc8c41fbf9869179
|
||||
zh: e45976a585cb9c86
|
||||
/web-ui-style-reference/component-rules:
|
||||
en: 6715806a6f84d141
|
||||
zh: 94b7a6ede4919512
|
||||
en: 4ee4a6fd2917306a
|
||||
zh: d7d68c140a698ab0
|
||||
/web-ui-style-reference/input-capabilities:
|
||||
en: bf2e98bce68743e2
|
||||
zh: fc58dcbb60bb944a
|
||||
/web-ui-style-reference/corner-radii-and-settings-cards:
|
||||
en: 118972f6c2bd6ce4
|
||||
zh: 85f8416b03e4dd8b
|
||||
|
||||
+12
-1
@@ -21,7 +21,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
|
||||
- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors.
|
||||
- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches.
|
||||
- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten.
|
||||
- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls.
|
||||
- Put `:hover` selectors under `@media (hover: hover)`, independently of viewport width. Keep selected, expanded, pressed, and keyboard-focus styles outside that condition. Without hover, actions remain visible or accessible through an explicit control; pointer handlers ignore touch when opening hover-only previews. Preserve keyboard focus visibility and reduced-motion behavior.
|
||||
- Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
|
||||
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
|
||||
- Existing and new dropdown, context, submenu, and selection menus use `Menu` or wrap custom content in `MenuSurface`. The material is the theme-owned `--dsw-menu-surface-fill` plus `--dsw-menu-backdrop-filter`; feature and platform CSS must not override either. The ui-theme menu spec rejects unwrapped menu/listbox renderers, material overrides, and token redefinitions. The schedule-owned `TaskMenu` and `ClockPicker` retain their existing containers and material as explicit exceptions ([owner](../packages/client/ui-schedule/README.md)).
|
||||
@@ -30,6 +30,17 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
|
||||
- Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
|
||||
- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance.
|
||||
|
||||
<a id="input-capabilities"></a>
|
||||
## Input capabilities
|
||||
|
||||
`pointer` and `hover` describe the primary pointing device chosen by the browser; `any-hover` considers every available pointing device. None detects a physical keyboard.
|
||||
|
||||
- Use `(pointer: coarse)` for presentation that depends on pointing accuracy, such as touch-sized controls and text fields.
|
||||
- Keep CSS hover effects under `(hover: hover)` so a touch-first layout remains usable without hover.
|
||||
- JavaScript hover previews use `(any-hover: hover)` when accepting mouse entry and ignore touch entry. A secondary mouse can therefore open a preview even when the touchscreen remains the primary pointer. Enabling all CSS hover styles through `any-hover` would also enable them for touch-generated `:hover` states.
|
||||
|
||||
On a mixed-input device, a mouse preview can appear while the primary-pointer CSS hover style stays inactive. Keyboard focus and shortcut hints follow actual keyboard interaction independently of these capability queries; [Menu and input modality](../packages/client/ui-primitives/README.md#input-modality) define the shared behavior.
|
||||
|
||||
<a id="corner-radii-and-settings-cards"></a>
|
||||
## Corner radii and settings cards
|
||||
|
||||
|
||||
+12
-1
@@ -21,7 +21,7 @@
|
||||
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
|
||||
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
|
||||
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。
|
||||
- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。
|
||||
- 将 `:hover` 选择器放在 `@media (hover: hover)` 内,不依赖视口宽度。选中、展开、按下和键盘焦点样式保留在该条件之外。无 hover 时,操作保持可见或通过明确控件访问;打开纯悬停预览的指针事件处理忽略触摸。保留清晰可见的键盘焦点和减少动态效果行为。
|
||||
- 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
|
||||
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
|
||||
- 存量和新增的下拉菜单、上下文菜单、子菜单及选择菜单使用 `Menu`,自定义内容用 `MenuSurface` 包裹。材质由主题的 `--dsw-menu-surface-fill` 和 `--dsw-menu-backdrop-filter` 组成;功能及平台 CSS 不得覆盖两者。ui-theme 菜单检查拒绝未使用共享容器的 menu/listbox 渲染、材质覆盖和 token 重定义。计划任务自有的 `TaskMenu` 与 `ClockPicker` 是明确例外,保留现有容器和材质([所属包](../packages/client/ui-schedule/README.zh.md))。
|
||||
@@ -30,6 +30,17 @@
|
||||
- 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
|
||||
- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。
|
||||
|
||||
<a id="input-capabilities"></a>
|
||||
## 输入能力
|
||||
|
||||
`pointer` 与 `hover` 描述浏览器选定的主指针,`any-hover` 则考虑所有可用指针。三者都不检测物理键盘。
|
||||
|
||||
- `(pointer: coarse)` 用于依赖指针精度的呈现,例如适合触摸的控件和文本框尺寸。
|
||||
- CSS 悬停效果保留在 `(hover: hover)` 下,使以触屏为主的布局无需悬停也能使用。
|
||||
- JavaScript 悬停预览在接受 mouse 进入事件时使用 `(any-hover: hover)`,并忽略 touch 进入事件。因此,即使触屏仍是主指针,外接鼠标也能打开预览。如果通过 `any-hover` 启用全部 CSS 悬停样式,触摸产生的 `:hover` 状态也会应用这些样式。
|
||||
|
||||
混合输入设备上,鼠标预览可以出现,而基于主指针的 CSS 悬停样式保持关闭。键盘焦点和快捷键提示根据实际键盘交互显示,不依赖这些能力查询;共享行为见 [Menu 与输入方式](../packages/client/ui-primitives/README.zh.md#input-modality)。
|
||||
|
||||
<a id="corner-radii-and-settings-cards"></a>
|
||||
## 圆角与设置卡片
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain
|
||||
|
||||
## Styling and localization
|
||||
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md).
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md); responsive layout and telling a mobile or touch client from a desktop one follow [dsh-responsive-ui](../../.agents/skills/dsh-responsive-ui/SKILL.md).
|
||||
|
||||
Menus use `Menu` or `MenuSurface`, including custom listboxes, except the explicitly listed schedule containers. Preserve the shared transparency and blur ([rules](../../docs/web-styling.md#component-rules)).
|
||||
|
||||
|
||||
@@ -42,8 +42,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: b3ad7867f23dad1c
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
|
||||
en: e015215c32d78b52
|
||||
zh: e8a8029903de7515
|
||||
en: 475fae2164ced556
|
||||
zh: 4d0c729ffc210a83
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
|
||||
en: d7a38ad9e5497789
|
||||
zh: 5f5595ddd90fa6bd
|
||||
|
||||
@@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
|
||||
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Coarse-pointer devices show complete card descriptions; other devices clamp descriptions to four lines with the full text available on hover. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
|
||||
|
||||
With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
|
||||
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。粗指针设备完整显示卡片描述;其他设备最多显示四行描述,悬停可查看全文。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
|
||||
|
||||
关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
|
||||
|
||||
|
||||
@@ -34,11 +34,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat:not(:disabled):hover,
|
||||
.seat[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.seat:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.seat:disabled {
|
||||
cursor: default;
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
|
||||
@@ -61,8 +61,10 @@
|
||||
}
|
||||
|
||||
|
||||
.card:hover:not(.cardActive) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.card:hover:not(.cardActive) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* The default preset reads as selected exactly like the Appearance cubes:
|
||||
@@ -81,8 +83,10 @@
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.cardBroken:hover {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
@media (hover: hover) {
|
||||
.cardBroken:hover {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.brokenBadge {
|
||||
@@ -127,11 +131,16 @@
|
||||
/* Focusing the card reveals it too: a broken card keeps its place in the tab
|
||||
order (`aria-disabled`, not `disabled`), so this is the keyboard path to a
|
||||
reason the card face no longer shows. */
|
||||
.brokenBadge:hover .brokenTip,
|
||||
.cardMain:focus-visible .brokenTip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.brokenBadge:hover .brokenTip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cardMain[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -155,6 +164,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.cardBrokenReason {
|
||||
position: static;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* The card body is the control that picks the preset. */
|
||||
.cardMain {
|
||||
flex: 1;
|
||||
@@ -230,6 +251,12 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.cardDesc {
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.cardId {
|
||||
flex-shrink: 0;
|
||||
max-width: 35%;
|
||||
@@ -267,11 +294,16 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.helpButton:hover,
|
||||
.helpButton:focus-visible {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.helpButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon-only actions: the label rides `data-tip` so the row stays quiet until
|
||||
someone reaches for it. */
|
||||
.iconButton {
|
||||
@@ -287,9 +319,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
@@ -315,11 +349,16 @@
|
||||
transition: opacity .12s;
|
||||
}
|
||||
|
||||
.iconButton:hover::after,
|
||||
.iconButton:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
@@ -370,8 +409,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.creatorButton:disabled {
|
||||
|
||||
@@ -287,7 +287,7 @@ it.each([false, true])('only offers a description tooltip when the card clips it
|
||||
const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect')
|
||||
vi.useFakeTimers()
|
||||
view({ rows: [{ id: 'mine', isDefault: false, description: 'Preset description' }] })
|
||||
fireEvent.mouseEnter(screen.getByText('Preset description'))
|
||||
fireEvent.pointerEnter(screen.getByText('Preset description'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(screen.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Preset description' : null)
|
||||
cleanup()
|
||||
|
||||
@@ -60,8 +60,10 @@
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
--dsh-button-outline-color: transparent;
|
||||
@media (hover: hover) {
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
--dsh-button-outline-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,18 @@
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.item:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.item:hover .remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch surfaces have no hover to reveal the control. */
|
||||
@media (pointer: coarse) {
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -102,8 +107,10 @@
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
@media (hover: hover) {
|
||||
.arrow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
|
||||
@@ -101,12 +101,17 @@
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@media (hover: hover) {
|
||||
.card:hover .remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -118,11 +123,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card:hover .name,
|
||||
.card:focus-within .name {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.card:hover .name {
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.removeFailed {
|
||||
background: var(--dsw-alias-state-error-primary, #d54941);
|
||||
color: #fff;
|
||||
@@ -158,7 +168,7 @@
|
||||
to { transform: translateX(220%); }
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -50,12 +50,17 @@
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.imageItem:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@media (hover: hover) {
|
||||
.imageItem:hover .remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: bb20da372b144302
|
||||
zh: 75a8c5ab7ec8d665
|
||||
/deepseek-ai-dsh-client-ui-chat/completed-turn-footer:
|
||||
en: b4cd36e7ab91281e
|
||||
zh: 8ccf412b9dde61d5
|
||||
en: 382821ca3ffa60dd
|
||||
zh: 9840ffa07c13082a
|
||||
/deepseek-ai-dsh-client-ui-chat/turn-process-folding:
|
||||
en: b0d806604bc17006
|
||||
zh: 18dabe5dc2d0d419
|
||||
|
||||
@@ -79,7 +79,7 @@ Preference menus restore focus to their trigger without scrolling before publish
|
||||
|
||||
Artifact extensions can subscribe to one Turn and Node kind through `ChatNodeStore.turnDataSource`. The source includes hidden Nodes and exposes their business data in anchor order. Membership updates incrementally; only observed collections materialize ordered arrays, and unrelated Turns or kinds do not notify them.
|
||||
|
||||
The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible.
|
||||
The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible. An unavailable branch action opens its explanation on click or tap without creating a branch.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
|
||||
|
||||
产物扩展可通过 `ChatNodeStore.turnDataSource` 订阅一个 Turn 中指定类型的节点数据。来源包含隐藏节点,并按锚点顺序提供其业务数据。成员关系增量更新;只有被订阅的集合才生成有序数组,其他 Turn 或类型的更新不通知该集合。
|
||||
|
||||
已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。
|
||||
已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。分支操作不可用时,鼠标或触摸点击可查看原因,不会创建分支。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.title:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* The leading box and its glyph ride the body font-size delta so the icon
|
||||
@@ -65,14 +67,26 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title:is(:hover, :focus-visible) .activityIcon {
|
||||
.title:focus-visible .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title:is(:hover, :focus-visible) .chevron {
|
||||
@media (hover: hover) {
|
||||
.title:hover .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title:focus-visible .chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.title:hover .chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:where(.column > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
|
||||
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
|
||||
[data-chat-group-key]
|
||||
[data-chat-group-key]
|
||||
)) ~ .running {
|
||||
animation: running-follows-output 110ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -199,6 +199,7 @@
|
||||
turning a switch on completes a running move at once. The structural rules
|
||||
above are wrapped in :where() so these rules outrank them. An input fold
|
||||
runs both moves at zero duration. */
|
||||
|
||||
.column[data-input-fold] .running {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
@@ -207,9 +208,10 @@
|
||||
status keeps its on-screen position, so the first-output slide runs at zero
|
||||
duration. Admitted steering still passes its pending echo below the status,
|
||||
so the steering entrance keeps its duration. */
|
||||
|
||||
.column[data-chat-tail-pinned] > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
|
||||
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
|
||||
[data-chat-group-key]
|
||||
[data-chat-group-key]
|
||||
) ~ .running {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
@@ -415,8 +417,10 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toBottom:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
@media (hover: hover) {
|
||||
.toBottom:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Host open-path refusal: same dialog family as the workspace folder error. */
|
||||
|
||||
@@ -86,9 +86,11 @@
|
||||
height: calc(17px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Unavailable stays focusable and hoverable so Tooltip can explain why. */
|
||||
@@ -97,9 +99,11 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.action[data-unavailable]:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@media (hover: hover) {
|
||||
.action[data-unavailable]:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
|
||||
@@ -89,7 +89,7 @@ export function MessageIconActions({
|
||||
</Tooltip>
|
||||
{extraActions}
|
||||
{onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom" openOnClick={branchUnavailable}>
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -105,20 +105,24 @@
|
||||
/* Pinned, the toggle needs an opaque hover fill: the default translucent hover
|
||||
token below would let the scrolling prose show through the moment the pointer
|
||||
lands on the toggle to collapse it. */
|
||||
.compactionRow:has(.compactionBody) .compactionButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
@media (hover: hover) {
|
||||
.compactionRow:has(.compactionBody) .compactionButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compactionButton:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.compactionButton:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
.compactionButton:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.compactionLeading {
|
||||
@@ -150,16 +154,26 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon,
|
||||
@media (hover: hover) {
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
@@ -231,8 +245,10 @@
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.retrySummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.retrySummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -39,12 +39,18 @@ button.pill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.pill:hover,
|
||||
button.pill[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button.pill:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.root:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
background: var(--dsw-alias-turn-trigger-bg, var(--dsw-alias-markdown-code-block));
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
.root:hover { background: var(--dsw-alias-turn-trigger-bg-hover, var(--dsw-alias-interactive-bg-hover)); }
|
||||
@media (hover: hover) {
|
||||
.root:hover { background: var(--dsw-alias-turn-trigger-bg-hover, var(--dsw-alias-interactive-bg-hover)); }
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -20,7 +22,9 @@
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.header:hover { color: var(--dsw-alias-label-secondary); }
|
||||
@media (hover: hover) {
|
||||
.header:hover { color: var(--dsw-alias-label-secondary); }
|
||||
}
|
||||
.icon { display: inline-flex; flex: none; color: inherit; }
|
||||
.title { flex: none; font-size: var(--dsh-content-font-size, 14px); }
|
||||
.time { flex: none; margin-left: auto; color: inherit; font: var(--dsw-font-xxs-12); }
|
||||
|
||||
@@ -46,12 +46,18 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.trigger:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow viewport: the pills collapse to bare icons with the sibling
|
||||
`.action` geometry (28px circle, 6px padding, centered glyph); the
|
||||
label-padding rebate no longer applies, so the pair keeps the row's plain
|
||||
|
||||
@@ -47,8 +47,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
@@ -3534,8 +3534,13 @@ describe('ChatView', () => {
|
||||
expect(chat.locations.getTurn(1)).toContain(later.key)
|
||||
const branch = view.getByRole('button', { name: '在新对话中分支' })
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.pointerDown(branch, { pointerType: 'touch' })
|
||||
fireEvent.click(branch)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
expect(view.getByRole('tooltip', { hidden: true }).textContent).toBe(h.props.t('message.branchUnavailable'))
|
||||
fireEvent.pointerDown(branch, { pointerType: 'touch' })
|
||||
fireEvent.click(branch)
|
||||
expect(view.queryByRole('tooltip', { hidden: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -231,8 +231,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
|
||||
@@ -47,8 +47,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
@@ -24,12 +24,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.trigger:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
fill: none;
|
||||
stroke: var(--dsw-alias-border-l3);
|
||||
|
||||
@@ -144,8 +144,10 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
button.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
button.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.crumbCurrent {
|
||||
@@ -344,11 +346,16 @@ button.crumb:hover {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.widthHandle:hover::after,
|
||||
.widthHandle[data-dragging]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.widthHandle:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* A drag starts above the composer band. Pointer capture keeps it active when
|
||||
crossing the footer; lift only the active handle so its indicator remains
|
||||
visible without making the resting footer gutter draggable. */
|
||||
|
||||
@@ -166,11 +166,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace:not(:disabled):hover,
|
||||
.workspace[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.workspace:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Locked form (bound guidance state): a static echo — no hover feedback, no
|
||||
pointer affordance; label keeps full contrast. */
|
||||
.workspace:disabled {
|
||||
|
||||
@@ -103,8 +103,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 +332,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 +366,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 +401,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 {
|
||||
|
||||
@@ -990,7 +990,7 @@ describe('running and lock semantics', () => {
|
||||
t: makeTranslate(messages, {}),
|
||||
})
|
||||
const stop = view.getByRole('button', { name: label })
|
||||
if (trigger === 'hover') fireEvent.mouseEnter(stop)
|
||||
if (trigger === 'hover') fireEvent.pointerEnter(stop)
|
||||
else fireEvent.focus(stop)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe(`${label} Esc Esc`)
|
||||
@@ -1005,7 +1005,7 @@ describe('running and lock semantics', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { button, view, session } = bench({ running: true })
|
||||
fireEvent.mouseEnter(button)
|
||||
fireEvent.pointerEnter(button)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe('停止生成 Esc Esc')
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('QueueDock', () => {
|
||||
const single = snapshotWith([row('i-tip', 'queued draft')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
|
||||
fireEvent.mouseEnter(view.getByLabelText('删除排队消息'))
|
||||
fireEvent.pointerEnter(view.getByLabelText('删除排队消息'))
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const tooltip = view.getByRole('tooltip')
|
||||
expect(tooltip.textContent).toBe('删除排队消息')
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
:global(body[data-ds-dark-theme]) .card { --changes-fill: var(--dsw-static-neutral-850); --changes-hover: var(--dsw-static-neutral-800); }
|
||||
.header { display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; height: 60px; margin: 0; padding: 8px 10px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; }
|
||||
button.header { cursor: pointer; transition: background-color 120ms ease; }
|
||||
button.header:hover:not(:disabled), button.header:focus-visible { background: var(--changes-hover); }
|
||||
button.header:focus-visible { background: var(--changes-hover); }
|
||||
|
||||
@media (hover: hover) {
|
||||
button.header:hover:not(:disabled) { background: var(--changes-hover); }
|
||||
}
|
||||
button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
|
||||
button.header:disabled { cursor: progress; }
|
||||
.tile { display: grid; flex: none; place-items: center; box-sizing: border-box; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); }
|
||||
@@ -14,22 +18,38 @@ button.header:disabled { cursor: progress; }
|
||||
.stat { display: inline-flex; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
|
||||
.statCounts { display: inline-flex; gap: 6px; font-family: var(--ds-font-family-code); }
|
||||
.previewHint { display: none; }
|
||||
.header:hover .statCounts, .header:focus-visible .statCounts { display: none; }
|
||||
.header:hover .previewHint, .header:focus-visible .previewHint { display: inline; }
|
||||
.header:focus-visible .statCounts { display: none; }
|
||||
|
||||
@media (hover: hover) {
|
||||
.header:hover .statCounts { display: none; }
|
||||
}
|
||||
.header:focus-visible .previewHint { display: inline; }
|
||||
|
||||
@media (hover: hover) {
|
||||
.header:hover .previewHint { display: inline; }
|
||||
}
|
||||
.stat[data-error='true'], .counts[data-error='true'] { color: var(--dsw-alias-state-error-primary); }
|
||||
.added { color: var(--dsw-alias-state-success-primary); }
|
||||
.deleted { color: var(--dsw-alias-state-error-primary); }
|
||||
.list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; }
|
||||
.row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
|
||||
@media (hover: hover) {
|
||||
.row:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
|
||||
.row:disabled { cursor: progress; }
|
||||
.path { min-width: 0; overflow: hidden; font-family: var(--dsw-font-family); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
|
||||
.toggle { display: inline-flex; align-items: center; justify-content: flex-start; gap: 4px; box-sizing: border-box; width: 100%; margin: 0; padding: 10px 18px 10px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; text-align: left; }
|
||||
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.toggle svg { flex: none; width: 14px; height: 14px; }
|
||||
@media (pointer: coarse) { .row, .toggle { min-height: 44px; } }
|
||||
@media (pointer: coarse) {
|
||||
.row, .toggle { min-height: 44px; }
|
||||
}
|
||||
|
||||
.preview { max-height: 100%; overflow: hidden; }
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; }
|
||||
.presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); }
|
||||
.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-lg); background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; }
|
||||
.file:hover { background: var(--deliverable-hover); }
|
||||
@media (hover: hover) {
|
||||
.file:hover { background: var(--deliverable-hover); }
|
||||
}
|
||||
.cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
|
||||
.cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
|
||||
.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); color: var(--dsw-alias-link); }
|
||||
@@ -19,14 +21,21 @@
|
||||
.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.description[data-error='true'] { color: var(--dsw-alias-state-error-primary); }
|
||||
.previewHint { display: none; }
|
||||
.file:hover .description:not([role='status']) .secondaryText { display: none; }
|
||||
.file:hover .description:not([role='status']) .previewHint { display: inline; }
|
||||
@media (hover: hover) {
|
||||
.file:hover .description:not([role='status']) .secondaryText { display: none; }
|
||||
|
||||
.file:hover .description:not([role='status']) .previewHint { display: inline; }
|
||||
}
|
||||
.toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; }
|
||||
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.toggle svg { flex: none; width: 14px; height: 14px; }
|
||||
@container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } }
|
||||
.actions { display: inline-flex; flex: none; pointer-events: auto; }
|
||||
|
||||
.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; }
|
||||
@keyframes success-fade { to { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .secondaryText[data-success] { animation-name: none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.secondaryText[data-success] { animation-name: none; }
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
.selector { flex: 0 1 auto; min-width: 0; }
|
||||
.selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; }
|
||||
.selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
|
||||
@media (hover: hover) {
|
||||
.selectorButton:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.selectorButton svg { display: block; flex: none; }
|
||||
.item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; }
|
||||
.itemPath { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -15,7 +19,9 @@
|
||||
.tools { display: inline-flex; flex: none; gap: 2px; align-items: center; margin-left: auto; }
|
||||
.tool { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
|
||||
.tool svg { width: 15px; height: 15px; }
|
||||
.tool:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
||||
@media (hover: hover) {
|
||||
.tool:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
.tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); }
|
||||
.tool:disabled { cursor: progress; }
|
||||
.tool[data-error] { color: var(--dsw-alias-state-error-primary); }
|
||||
@@ -62,12 +62,17 @@
|
||||
|
||||
/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
|
||||
* offers navigation, not path entry. Editing keeps the outline standing. */
|
||||
.crumbBar:has(.crumbEditZone:enabled:hover),
|
||||
.crumbBar:has(.crumbEditZone:focus-visible),
|
||||
.crumbBar:has(.pathInput) {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.crumbBar:has(.crumbEditZone:enabled:hover) {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Deep chains scroll inside the trail (the effect pins the tail into view)
|
||||
* so the edit zone to the right never leaves the bar. */
|
||||
/* The Miller columns keep their own row so a status/error line below never
|
||||
@@ -127,8 +132,10 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.crumbChevron {
|
||||
@@ -159,11 +166,16 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.crumbEditZone:enabled:hover .crumbEditGlyph,
|
||||
.crumbEditZone:focus-visible .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.crumbEditZone:enabled:hover .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -251,16 +263,23 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection: pill fill + the open-folder glyph in the info accent. */
|
||||
.rowSelected,
|
||||
.rowSelected:hover {
|
||||
.rowSelected {
|
||||
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.rowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@@ -357,8 +376,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showHiddenToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.showHiddenToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.showHiddenToggle:disabled {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: ea872ff2c2089037
|
||||
zh: b3ec3a218eacd5c8
|
||||
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
|
||||
en: 47b9852393643b6a
|
||||
zh: ce5f2ab60eec9564
|
||||
en: bc9ecb9633fb3208
|
||||
zh: fa141e77c2bf6eae
|
||||
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
|
||||
en: 7e5c1e9a9f9f244f
|
||||
zh: 7e3a8fcef86bc7dd
|
||||
|
||||
@@ -52,7 +52,7 @@ Everything host-specific arrives through props:
|
||||
| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` |
|
||||
| `DockIntents` | the settled results of every gesture |
|
||||
|
||||
`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
|
||||
`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. A visible blocked split control supports click or tap to read its explanation without splitting or focusing the pane.
|
||||
|
||||
`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ kind: "package-reference"
|
||||
| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
|
||||
| `DockIntents` | 每次手势落定的结果 |
|
||||
|
||||
`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
|
||||
`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。可见的禁用分栏控件支持鼠标或触摸点击查看原因,不会分栏或聚焦窗格。
|
||||
|
||||
`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
|
||||
|
||||
|
||||
@@ -365,10 +365,11 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
)}
|
||||
<div className={css.stripFill} data-dockkit-strip-fill />
|
||||
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
|
||||
<Tooltip label={block === undefined ? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} shortcutKeys={callbacks.labels.splitPaneKeys} side="bottom" delayMs={500}>
|
||||
<Tooltip label={block === undefined ? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} shortcutKeys={callbacks.labels.splitPaneKeys} side="bottom" delayMs={500} openOnClick={block !== undefined}>
|
||||
<span
|
||||
tabIndex={block === undefined ? undefined : 0}
|
||||
aria-label={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
onClick={(event) => { if (block !== undefined) event.stopPropagation() }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -107,8 +107,10 @@
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.divider:hover::after {
|
||||
opacity: 1;
|
||||
@media (hover: hover) {
|
||||
.divider:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.splitRow > .divider::after {
|
||||
@@ -406,12 +408,17 @@
|
||||
Later than the clipped mask, so a clipped chip showing its close fades
|
||||
under the close. A floating panel's header chip has no close, so it keeps
|
||||
the clipped mask alone. */
|
||||
.tab:not(.floatTitle):hover .tabTitle,
|
||||
.tab:not(.floatTitle):focus-within .tabTitle,
|
||||
.tabActive .tabTitle {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px));
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab:not(.floatTitle):hover .tabTitle {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px));
|
||||
}
|
||||
}
|
||||
|
||||
/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in
|
||||
the tertiary ink, so it sits back from the title beside it. */
|
||||
.tabClose {
|
||||
@@ -438,13 +445,19 @@
|
||||
|
||||
/* Shown while the chip is hovered or holds focus, and always on the active
|
||||
chip: the tab in view is the one a reader closes next. */
|
||||
.tab:hover .tabClose,
|
||||
.tab:focus-within .tabClose,
|
||||
.tabActive .tabClose {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab:hover .tabClose {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The chip's height and corner, so it reads as one more tab in the row. */
|
||||
.addTab {
|
||||
display: flex;
|
||||
@@ -462,18 +475,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addTab:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.addTab:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tabClose:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
.tabClose:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
.tab:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* The active chip is the filled tab, in the tag fill; the rest are bare text. */
|
||||
@@ -485,19 +500,31 @@
|
||||
/* A pane's lone unclosable chip is a label, not a choice: no tab, no
|
||||
hover fill — the primary ink on the pane's own ground. Its title never
|
||||
yields to a close control, so it keeps the clipped fade alone. */
|
||||
.tab.tabQuiet,
|
||||
.tab.tabQuiet:hover {
|
||||
.tab.tabQuiet {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab.tabQuiet:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.tab.tabQuiet .tabTitle,
|
||||
.tab.tabQuiet:hover .tabTitle,
|
||||
.tab.tabQuiet:focus-within .tabTitle {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab.tabQuiet:hover .tabTitle {
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
|
||||
}
|
||||
@@ -529,8 +556,10 @@
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
@@ -538,6 +567,10 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iconButton[data-dockkit-split-blocked] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Portalled beside the app root, so its stacking level is stated here: above the
|
||||
* floating panels (which the embedder draws at 60), because a menu opened from
|
||||
@@ -569,8 +602,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.menuItem:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* No padding of its own: a tab's body reaches the pane's edges and the strip's
|
||||
@@ -782,8 +817,10 @@
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.tab.floatTitle:hover {
|
||||
background: transparent;
|
||||
@media (hover: hover) {
|
||||
.tab.floatTitle:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mirrored so the filled pane sits at the right, where the docked surface is. */
|
||||
@@ -828,11 +865,16 @@
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.float:hover .floatResize::after,
|
||||
.floatResize:active::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floatResize:hover::after {
|
||||
border-color: var(--dsw-alias-label-tertiary);
|
||||
@media (hover: hover) {
|
||||
.float:hover .floatResize::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floatResize:hover::after {
|
||||
border-color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,7 @@ describe('DockSurface', () => {
|
||||
const intents = spyIntents()
|
||||
const { unmount } = renderSurface(controller, intents)
|
||||
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
|
||||
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }).parentElement!)
|
||||
expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
|
||||
expect(intents.focusPane).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
@@ -280,6 +281,15 @@ describe('DockSurface', () => {
|
||||
expect(disabled.hasAttribute('disabled')).toBe(true)
|
||||
expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled)
|
||||
expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
|
||||
intents.splitPane.mockClear()
|
||||
const hint = disabled.parentElement!
|
||||
fireEvent.pointerDown(hint, { pointerType: 'touch' })
|
||||
fireEvent.click(hint)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPaneDisabled)
|
||||
expect(intents.splitPane).not.toHaveBeenCalled()
|
||||
expect(intents.focusPane).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(document.body, { pointerType: 'touch' })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the enabled split control through the shared tooltip, not a native title', () => {
|
||||
@@ -288,10 +298,10 @@ describe('DockSurface', () => {
|
||||
renderSurface(seededController(), spyIntents())
|
||||
const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
|
||||
expect(button.hasAttribute('title')).toBe(false)
|
||||
fireEvent.mouseEnter(button)
|
||||
fireEvent.pointerEnter(button)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
|
||||
fireEvent.mouseLeave(button)
|
||||
fireEvent.pointerLeave(button)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -17,7 +17,7 @@ const SEATS = ['sidebar.panellist.actions', 'conversation.session.header.views.a
|
||||
const runtimes = new Set<SlotTestRuntime>()
|
||||
afterEach(async () => {
|
||||
try { for (const runtime of runtimes) await runtime.dispose() }
|
||||
finally { runtimes.clear(); cleanup(); vi.useRealTimers() }
|
||||
finally { runtimes.clear(); cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }
|
||||
})
|
||||
|
||||
type ContentProps = ComponentProps<typeof EntryMenuContent>
|
||||
@@ -81,15 +81,16 @@ describe('More menu rows', () => {
|
||||
})
|
||||
|
||||
it('shows the pin hint after the shared tooltip delay outside the menu card', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' })))
|
||||
const { view } = content()
|
||||
const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
fireEvent.mouseEnter(unpin)
|
||||
fireEvent.pointerEnter(unpin, { pointerType: 'mouse' })
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(view.queryByRole('tooltip', { hidden: true })).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull()
|
||||
fireEvent.mouseLeave(unpin)
|
||||
fireEvent.pointerLeave(unpin, { pointerType: 'mouse' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -119,9 +119,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.iconBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtn:disabled {
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('GoalBar', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render(<GoalBar goal={makeGoal()} activation="armed" {...makeActions()} t={t} />)
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '暂停目标' }))
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: '暂停目标' }))
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip.textContent).toBe('暂停目标')
|
||||
|
||||
@@ -184,9 +184,11 @@
|
||||
color: var(--dsw-alias-menu-icon);
|
||||
}
|
||||
|
||||
.drill:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.drill:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Heading row above a source group: non-interactive small grey text,
|
||||
@@ -256,17 +258,26 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
/* The step the list is already showing reads as a label, not an action. */
|
||||
.crumbCurrent,
|
||||
.crumbCurrent:hover {
|
||||
.crumbCurrent {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.crumbCurrent:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.crumbSeparator {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -13,11 +13,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.trigger:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
@@ -99,14 +104,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sectionToggle:hover,
|
||||
.sectionToggle:focus-visible,
|
||||
.sectionClear:hover,
|
||||
.sectionClear:focus-visible {
|
||||
background: var(--dsw-alias-fill-l1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sectionToggle:hover,
|
||||
.sectionClear:hover {
|
||||
background: var(--dsw-alias-fill-l1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionChevron {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 120ms ease;
|
||||
@@ -138,8 +149,10 @@
|
||||
background: var(--dsw-alias-fill-l2);
|
||||
}
|
||||
|
||||
.rowLineLive:hover {
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent);
|
||||
@media (hover: hover) {
|
||||
.rowLineLive:hover {
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* The kind badge shares the card fill, so it pops on the menu surface instead. */
|
||||
@@ -154,8 +167,10 @@
|
||||
|
||||
/* The card carries the hover wash; the inner row stays transparent so the
|
||||
two washes never stack, keeping keyboard focus its own visible state. */
|
||||
.rowLineLive .row:hover {
|
||||
background: transparent;
|
||||
@media (hover: hover) {
|
||||
.rowLineLive .row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.rowLineLive .row {
|
||||
@@ -198,16 +213,21 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stop:hover,
|
||||
.stop:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.stop:hover {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* The armed press widens into a labeled danger pill so the confirm step is
|
||||
legible without hover; the state-error token is the one StateDot uses. */
|
||||
.stopArmed,
|
||||
.stopArmed:hover,
|
||||
.stopArmed:focus-visible {
|
||||
gap: 4px;
|
||||
width: auto;
|
||||
@@ -217,6 +237,17 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.stopArmed:hover {
|
||||
gap: 4px;
|
||||
width: auto;
|
||||
padding: 0 7px;
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.stopLabel {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
@@ -245,18 +276,25 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row:focus-visible {
|
||||
background: var(--dsw-alias-fill-l1);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-fill-l1);
|
||||
}
|
||||
}
|
||||
|
||||
/* A row with no observable output offers no interaction. */
|
||||
.rowStatic {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rowStatic:hover {
|
||||
background: transparent;
|
||||
@media (hover: hover) {
|
||||
.rowStatic:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.rowSettled {
|
||||
|
||||
@@ -27,9 +27,11 @@
|
||||
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.chip:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.chip:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
@@ -42,13 +44,20 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.chipActive,
|
||||
.chipActive:hover:not(:disabled) {
|
||||
.chipActive {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.chipActive:hover:not(:disabled) {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/* The field starts at three short lines, grows with its text where supported,
|
||||
and scrolls after the cap. */
|
||||
.detail {
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
height: calc(15px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
|
||||
@@ -29,8 +29,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger:focus-visible:not([data-selection-focus]) {
|
||||
@@ -193,12 +195,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchClear:hover,
|
||||
.searchClear:focus-visible {
|
||||
outline: none;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.searchClear:hover {
|
||||
outline: none;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.searchRow .search:focus-within {
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -236,12 +244,17 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:not(.modelOption):hover:not(:disabled),
|
||||
.option:focus-visible,
|
||||
.optionActive:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.option:not(.modelOption):hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection marker is the trailing check, not a fill — matches the Menu
|
||||
* primitive's selected treatment. */
|
||||
.selected {
|
||||
@@ -303,11 +316,16 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cell:hover,
|
||||
.cell:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.cell:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.cellLabel {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -43,13 +43,18 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.main:hover:not(:disabled),
|
||||
.main:focus-visible,
|
||||
.chevron:hover:not(:disabled),
|
||||
.chevron:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.main:hover:not(:disabled),
|
||||
.chevron:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pending disables clicks without a restyle; only hover feedback pauses. */
|
||||
.main:disabled,
|
||||
.chevron:disabled {
|
||||
|
||||
@@ -18,7 +18,7 @@ it('queries the authorized route, selects a handler, and keeps reveal last', asy
|
||||
render(<FileRouteAction {...p} />)
|
||||
await act(async () => {})
|
||||
expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) as AbortSignal })
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Open in Music' }))
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Open in Music' }))
|
||||
expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in Music')
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) })
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Music (default)', 'Show file location'])
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('OpenInAppAction launching', () => {
|
||||
|
||||
it('names the selected application in the tooltip', async () => {
|
||||
render(<OpenInAppAction {...bench({ apps: ['finder'], absolutePath: '/w' }).props} />)
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) }))
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) }))
|
||||
expect(await screen.findByText(t('open.title', { app: zh['app.finder'] }))).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,8 +47,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover:not(:disabled) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-tertiary),
|
||||
var(--dsw-alias-state-business-primary) 6%
|
||||
);
|
||||
@media (hover: hover) {
|
||||
.chip:hover:not(:disabled) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-business-tertiary),
|
||||
var(--dsw-alias-state-business-primary) 6%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
@@ -53,16 +55,26 @@
|
||||
}
|
||||
|
||||
.hoverGlyph,
|
||||
.chip:hover:not(:disabled) .restGlyph,
|
||||
.chip:focus-visible .restGlyph {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chip:hover:not(:disabled) .hoverGlyph,
|
||||
@media (hover: hover) {
|
||||
.chip:hover:not(:disabled) .restGlyph {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chip:focus-visible .hoverGlyph {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.chip:hover:not(:disabled) .hoverGlyph {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
:global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); }
|
||||
.card:hover { background: var(--plan-card-hover); }
|
||||
@media (hover: hover) {
|
||||
.card:hover { background: var(--plan-card-hover); }
|
||||
}
|
||||
.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; }
|
||||
.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); }
|
||||
:global(body[data-ds-dark-theme]) .cardIcon { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); }
|
||||
@@ -30,10 +32,14 @@
|
||||
.cardDescription { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; }
|
||||
.cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; }
|
||||
.reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; }
|
||||
.reviewLink:hover { color: var(--dsw-alias-label-primary); }
|
||||
@media (hover: hover) {
|
||||
.reviewLink:hover { color: var(--dsw-alias-label-primary); }
|
||||
}
|
||||
.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; }
|
||||
.preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; }
|
||||
.document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
|
||||
.message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; }
|
||||
.titleIcon { flex: none; }
|
||||
@media (max-width: 767px) { .preview { padding: 16px 18px 32px; } }
|
||||
@media (max-width: 767px) {
|
||||
.preview { padding: 16px 18px 32px; }
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: ddd9e45e21755108
|
||||
zh: 8af470315eecf8a4
|
||||
en: 10209bb3e7a9c058
|
||||
zh: 241c029e078d6862
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
|
||||
@@ -29,7 +29,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text.
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
|
||||
|
||||
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
|
||||
|
||||
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* Plugin manager page: a centred column with its header, notices, and the package cards. */
|
||||
/* Plugin manager page: a centred column with its header, notices, and the package cards.
|
||||
|
||||
Hover affordances sit under @media (hover: hover): a tap latches :hover, which
|
||||
would strand a card, row, or control in its pointer-only state. Text fields take
|
||||
a 16px floor under @media (pointer: coarse): iOS Safari zooms the visual viewport
|
||||
for a focused field under 16px and leaves the page pannable afterwards. */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
@@ -164,11 +169,16 @@
|
||||
color: color-mix(in srgb, var(--dsw-alias-label-secondary) 60%, var(--dsw-alias-label-tertiary));
|
||||
}
|
||||
|
||||
.groupMore:hover,
|
||||
.groupMore:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.groupMore:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.moreHead {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -180,19 +190,6 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The group's explanation, folded behind a hover/focus tooltip. */
|
||||
.groupInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.groupInfo:hover,
|
||||
.groupInfo:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Center the status label geometrically: beside larger titles the capsule's line-box offset shows. */
|
||||
.statusTag {
|
||||
height: 18px;
|
||||
@@ -311,8 +308,16 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cardLink:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
/* Press feedback carries the tap: with hover fenced off, a card would otherwise
|
||||
read as inert between touch-down and release. */
|
||||
.cardLink:active {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.cardLink:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.cardOpen {
|
||||
@@ -419,9 +424,15 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
.iconButton:active:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
@@ -499,10 +510,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deleteButton:hover:not(:disabled) {
|
||||
.deleteButton:active:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.deleteButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.deleteButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
@@ -634,8 +651,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guideToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.guideToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.guideChevron {
|
||||
@@ -765,8 +784,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.registryToggle:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.registryToggle:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.registryToggle:disabled {
|
||||
@@ -819,8 +840,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.registryOption:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.registryOption:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* An offered registry is one row: the radio beside its title and the line under it. */
|
||||
@@ -954,9 +977,11 @@ label.registryOption {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wizardClose:hover {
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.wizardClose:hover {
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.wizardHero {
|
||||
@@ -1240,8 +1265,10 @@ label.registryOption {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.crumb:focus-visible {
|
||||
@@ -1466,9 +1493,11 @@ label.registryOption {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowOpen:hover .rowId {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
@media (hover: hover) {
|
||||
.rowOpen:hover .rowId {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.rowOpenIcon {
|
||||
@@ -1476,8 +1505,10 @@ label.registryOption {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rowOpen:hover .rowOpenIcon {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.rowOpen:hover .rowOpenIcon {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.rowState {
|
||||
@@ -1577,8 +1608,10 @@ button.versionSummary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.versionSummary:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
button.versionSummary:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.versionSummary[aria-expanded='true'] {
|
||||
@@ -1942,3 +1975,11 @@ button.versionSummary:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.installField input[type='text'],
|
||||
.registryCustomField,
|
||||
.partsFilter input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Plugin page touch rules, asserted against the CSS text on disk.
|
||||
*
|
||||
* jsdom resolves neither `pointer` nor `hover` media features, so these gates read
|
||||
* the sheet instead: a `:hover` rule outside `@media (hover: hover)` latches onto a
|
||||
* tap and strands the control in its pointer-only state, and a text field under
|
||||
* 16px makes iOS Safari zoom the visual viewport when it takes focus.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/PluginManagerPage.module.css', import.meta.url)), 'utf8')
|
||||
/** Comment bodies blanked, so prose about `:hover` is not read as a rule. */
|
||||
const rules = css.replace(/\/\*[\s\S]*?\*\//g, text => ' '.repeat(text.length))
|
||||
|
||||
/** The line holding `index`, for failure messages. */
|
||||
function lineAt(source: string, index: number): string {
|
||||
const start = source.lastIndexOf('\n', index) + 1
|
||||
const end = source.indexOf('\n', index)
|
||||
return source.slice(start, end === -1 ? undefined : end).trim()
|
||||
}
|
||||
|
||||
/** Every state selector with the media query it sits in, read from the sheet's own blocks. */
|
||||
function statesIn(source: string, pattern: RegExp): { line: string; hoverMedia: boolean }[] {
|
||||
const stack: (string | null)[] = []
|
||||
const found: { line: string; hoverMedia: boolean }[] = []
|
||||
for (const token of source.matchAll(/@media[^{]*\{|:hover|:focus-visible|\{|\}/g)) {
|
||||
const text = token[0]
|
||||
if (text === '}') {
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
if (text === '{') {
|
||||
stack.push(null)
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('@media')) {
|
||||
// The matched text includes the block's opening brace, which the frame stands for.
|
||||
stack.push(text.slice(0, -1))
|
||||
continue
|
||||
}
|
||||
if (!pattern.test(text)) continue
|
||||
const index = token.index ?? 0
|
||||
found.push({
|
||||
line: lineAt(source, index),
|
||||
hoverMedia: stack.some(frame => frame !== null && /hover:\s*hover/.test(frame)),
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The declarations of one rule, by its literal selector. */
|
||||
function ruleBody(selector: string): string {
|
||||
const start = rules.indexOf(`${selector} {`)
|
||||
if (start === -1) throw new Error(`PluginManagerPage.module.css has no \`${selector}\` rule`)
|
||||
const open = rules.indexOf('{', start)
|
||||
return rules.slice(open + 1, rules.indexOf('}', open))
|
||||
}
|
||||
|
||||
describe('plugin page touch rules', () => {
|
||||
it('fences every hover affordance behind a hover-capable pointer', () => {
|
||||
// Unguarded, the browser keeps `:hover` on the tapped element until the next tap
|
||||
// elsewhere: a card or menu row stays painted as if the pointer were still on it.
|
||||
// Each affordance must still exist, so an emptied sheet cannot satisfy the gate.
|
||||
const unguarded = statesIn(rules, /^:hover$/).filter(state => !state.hoverMedia)
|
||||
expect(unguarded.map(state => state.line)).toEqual([])
|
||||
for (const selector of [
|
||||
'.groupMore:hover', '.cardLink:hover', '.iconButton:hover', '.deleteButton:hover', '.guideToggle:hover',
|
||||
'.registryToggle:hover', '.registryOption:hover', '.wizardClose:hover', '.crumb:hover', '.rowOpen:hover',
|
||||
]) {
|
||||
expect(rules).toContain(selector)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps focus rings outside the hover fence', () => {
|
||||
// A tablet with a keyboard reports no hover; fencing `:focus-visible` with the
|
||||
// hover rules would leave keyboard use without a visible focus ring.
|
||||
const fenced = statesIn(rules, /^:focus-visible$/).filter(state => state.hoverMedia)
|
||||
expect(fenced.map(state => state.line)).toEqual([])
|
||||
})
|
||||
|
||||
it('gives touch clients a press state where the hover state is fenced off', () => {
|
||||
// Touch-down feedback is the only cue left once hover is fenced: without it a
|
||||
// card reads as inert until its detail page appears.
|
||||
expect(ruleBody('.cardLink:active')).toContain('background: var(--dsw-alias-interactive-bg-active)')
|
||||
expect(ruleBody('.iconButton:active:not(:disabled)')).toContain('background: var(--dsw-alias-interactive-bg-active)')
|
||||
expect(ruleBody('.deleteButton:active:not(:disabled)'))
|
||||
.toContain('background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover))')
|
||||
})
|
||||
|
||||
it('keeps focused text fields at 16px under a coarse pointer', () => {
|
||||
// iOS Safari zooms the visual viewport for a focused field under 16px and leaves
|
||||
// the page pannable afterwards; the install dialog, the registry address, and the
|
||||
// component filter are this page's three text fields.
|
||||
const coarse = /@media \(pointer: coarse\) \{([\s\S]*?)\n\}/.exec(rules)?.[1] ?? ''
|
||||
const rule = /([^{}]+)\{([^{}]*)\}/.exec(coarse)
|
||||
expect(rule).not.toBeNull()
|
||||
for (const selector of [".installField input[type='text']", '.registryCustomField', '.partsFilter input']) {
|
||||
expect(rule?.[1]).toContain(selector)
|
||||
}
|
||||
expect(rule?.[2]).toContain('font-size: 16px')
|
||||
})
|
||||
})
|
||||
@@ -112,8 +112,10 @@
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.card:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -250,8 +252,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.crumb:focus-visible {
|
||||
@@ -439,6 +443,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
@media (hover: hover) {
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,14 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
en: d2f5701274be5110
|
||||
zh: d60e2617136a6f26
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: cb874a0ca53a6747
|
||||
zh: 31179193dd1d6e12
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: 09c61f61c560f8a2
|
||||
zh: 895ac73add1199b8
|
||||
en: 35403eb2a55cc77d
|
||||
zh: 8f4466f7e15ae473
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 3a31ad8de6dd0af8
|
||||
zh: 6b22881a929c33df
|
||||
|
||||
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
|
||||
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
|
||||
|
||||
@@ -88,9 +88,9 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
### Controls and icons
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hoveLine truncated
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nesteLine truncated
|
||||
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click.
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip.
|
||||
|
||||
<a id="rendering-agent-output"></a>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
|
||||
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
||||
|
||||
@@ -88,9 +88,9 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的Line truncated
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始Line truncated
|
||||
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。
|
||||
|
||||
<a id="rendering-agent-output"></a>
|
||||
|
||||
|
||||
@@ -38,12 +38,14 @@
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost:active:not(:disabled) {
|
||||
@@ -57,16 +59,20 @@
|
||||
box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsh-button-outline-color, var(--dsw-alias-border-l3));
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.outline:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: var(--dsw-alias-button-tool-bar-fill);
|
||||
}
|
||||
|
||||
.toolbar:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-tool-bar-hover);
|
||||
@media (hover: hover) {
|
||||
.toolbar:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-tool-bar-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -67,8 +67,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
|
||||
@@ -39,12 +39,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warning:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-tertiary),
|
||||
var(--dsw-alias-state-warn-primary) 6%
|
||||
);
|
||||
@media (hover: hover) {
|
||||
.warning:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-warn-tertiary),
|
||||
var(--dsw-alias-state-warn-primary) 6%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.warning:active {
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.row:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -89,12 +91,14 @@ button.leading {
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -18,6 +18,7 @@ const VIEWPORT_MARGIN = 8
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card independently of nested tooltips.
|
||||
* Touch entry does not open a preview or intercept the anchor's action.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content; the pointer may rest on it, so it is
|
||||
* readable and selectable, but it carries no dismissal affordance of its own.
|
||||
@@ -290,7 +291,8 @@ export function HoverCard({
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() }
|
||||
} : undefined}
|
||||
onPointerEnter={(event) => {
|
||||
if (disabled || (inline && event.pointerType === 'touch')) return
|
||||
if (disabled || event.pointerType === 'touch') return
|
||||
if (event.pointerType === 'mouse' && !window.matchMedia('(any-hover: hover)').matches) return
|
||||
// Coming back inside during the grace (the gap, or the card itself)
|
||||
// keeps the current card rather than restarting the dwell.
|
||||
cancelClose()
|
||||
|
||||
@@ -43,11 +43,16 @@
|
||||
padding: 6px 8px 8px 14px;
|
||||
}
|
||||
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
|
||||
background: var(--json-tree-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover) {
|
||||
background: var(--json-tree-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.expandedTopLevelContainer {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -75,7 +80,6 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
|
||||
.row:not(.topLevelBracket)[data-json-copy-active]::after,
|
||||
.row:has(> .expander:focus-visible)::after {
|
||||
position: absolute;
|
||||
@@ -89,6 +93,20 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--json-tree-row-height, 16px);
|
||||
background: var(--json-tree-hover);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row > span:not(.expander, .copySlot) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -182,8 +200,10 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.stringToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.stringToggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.stringToggle:focus-visible {
|
||||
@@ -254,13 +274,19 @@
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.row:hover:not(:has(.row:hover)) > .copySlot,
|
||||
.row[data-json-copy-active] > .copySlot,
|
||||
.copySlot:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.row:hover:not(:has(.row:hover)) > .copySlot {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
@@ -277,8 +303,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.actionButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton[aria-pressed='true'] {
|
||||
@@ -326,8 +354,10 @@
|
||||
transform: rotate(90deg) scale(0.75);
|
||||
}
|
||||
|
||||
.expander:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@media (hover: hover) {
|
||||
.expander:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.expander:focus-visible {
|
||||
@@ -343,3 +373,9 @@
|
||||
.collapsedContent::after {
|
||||
content: '…';
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.copySlot { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.row:has(> .copySlot) { padding-right: 24px; }
|
||||
}
|
||||
@@ -109,8 +109,10 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.item:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Arrow navigation moves real focus, so the row the keyboard is on carries the
|
||||
@@ -206,6 +208,12 @@
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(html:not([data-input-modality='keyboard'])) .shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -231,8 +239,10 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
@media (hover: hover) {
|
||||
.danger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.danger:focus-visible:not(:disabled) {
|
||||
|
||||
@@ -98,8 +98,10 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.close:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Description and body share the 332px content column (24px side pads). */
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@media (hover: hover) {
|
||||
.interactive:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,10 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@media (hover: hover) {
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -53,11 +53,16 @@
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled),
|
||||
.tab[aria-selected='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -30,12 +30,18 @@
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab[aria-selected='true'] {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tab:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tab[aria-selected='true'] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user