fix(client): address touch interaction review findings

This commit is contained in:
Stevring committed 2026-10-20 17:07:57 +08:00
1 parent 5ea275ca2e
commit 8fe2db10a0
50 files changed
+380 -302

No files matched your search

+3 -5
View File
@@ -53,9 +53,7 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
## 9. Telling a mobile or touch client from a desktop one
## 9. Input capabilities and mobile text fields
1. Prefer CSS media features to script: `(pointer: coarse)` selects touch input (phones, tablets, iPadOS, touch laptops), `(hover: none)` selects pointers that cannot hover, and width queries select layout breaks. Layout always comes from these; never branch layout on a parsed user agent.
2. Use `isMobileDevice()` from `@deepseek-ai/dsh-client-ui-primitives` where behavior rather than layout must differ, such as automatic text-input focus and keyboard dismissal after sending. It uses Bowser’s `mobile` and `tablet` platform types and recognizes desktop-mode iPads through `MacIntel` with multiple touch points. Unrecognized devices return false. Keep this identification in the shared helper; do not duplicate the UA checks in consumers. `bowser` is a browser-only library, so it belongs in `devDependencies`.
3. Treat a positive answer as permission for an enhancement, never as a reason to withhold a primary action: desktop-site mode and custom user-agent strings hide the mobile platform, and a desktop user agent alone cannot separate an Android tablet from a Linux computer.
4. Text fields take a 16px floor under `(pointer: coarse)` — iOS Safari zooms the visual viewport for a focused field under 16px and leaves the page pannable after the keyboard closes — while a fine pointer keeps the compact 13-14px steps. The Automation tasks page's field rule in `packages/client/ui-schedule/src/client/TaskManagerPage.module.css` is the reference.
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.
+1 -1
View File
@@ -1,4 +1,4 @@
/** Pointer, keyboard, and resize behavior in the assembled client. @author feiyu.xia */
/** Pointer, keyboard, and resize behavior in the assembled client. */
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
+25
View File
@@ -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)
@@ -43,10 +43,9 @@
}
@media (hover: hover) {
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.chevron {
@@ -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;
}
@@ -119,9 +119,7 @@
.compactionButton:hover {
color: var(--dsw-alias-label-secondary);
}
}
@media (hover: hover) {
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -48,10 +48,9 @@
}
@media (hover: hover) {
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.chevron {
@@ -11,10 +11,9 @@
}
@media (hover: hover) {
.reference:hover {
background: var(--dsw-alias-state-business-tertiary);
}
.reference:hover {
background: var(--dsw-alias-state-business-tertiary);
}
}
.openable {
@@ -48,10 +48,9 @@
}
@media (hover: hover) {
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.chevron {
@@ -104,10 +104,9 @@
}
@media (hover: hover) {
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
}
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
}
}
.accessory {
@@ -334,10 +333,9 @@
}
@media (hover: hover) {
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.add:disabled {
@@ -369,10 +367,9 @@
}
@media (hover: hover) {
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.select:disabled {
@@ -405,10 +402,9 @@
}
@media (hover: hover) {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
}
.primary:disabled {
@@ -47,7 +47,9 @@ button.header:disabled { cursor: progress; }
.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; }
@@ -23,8 +23,7 @@
.previewHint { display: none; }
@media (hover: hover) {
.file:hover .description:not([role='status']) .secondaryText { display: none; }
}
@media (hover: hover) {
.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; }
@@ -34,3 +33,9 @@
.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; }
}
@@ -480,16 +480,12 @@
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
}
@media (hover: hover) {
.tabClose:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
}
@media (hover: hover) {
.tab:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -877,9 +873,7 @@
.float:hover .floatResize::after {
opacity: 1;
}
}
@media (hover: hover) {
.floatResize:hover::after {
border-color: var(--dsw-alias-label-tertiary);
}
@@ -48,10 +48,9 @@
}
@media (hover: hover) {
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.selector:disabled {
@@ -40,4 +40,6 @@
.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,14 +15,14 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: 4b128b9faf4893fa
zh: 6f31b3701cfb5c07
en: 86bf820c4e52f41e
zh: 33565e9238b554b3
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: adaaabb712de1a3d
zh: 45d6286e39880274
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: b11e92f21c1bca74
zh: 18229739c7fbcb45
en: c44849932ecd2190
zh: 1de69b5b98ce56d5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: 3a31ad8de6dd0af8
zh: 6b22881a929c33df
+1 -3
View File
@@ -27,8 +27,6 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
`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`); 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.
`isMobileDevice()` identifies phones and tablets for behavior such as automatic text-input focus and keyboard dismissal. It returns true for Bowser's UA classifications `mobile` and `tablet`, and for iPads reporting `MacIntel` with more than one touch point. It returns false for other devices, including Windows touchscreen computers, regardless of window width. The composer and command popups suppress automatic text-input focus on identified mobile devices; user-requested focus remains available. Browser modes or custom UA strings that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
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.
This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
@@ -90,7 +88,7 @@ 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. Touch entry does not open its preview or intercept the anchor action. 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 visiblLine 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. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; 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.
+1 -3
View File
@@ -27,8 +27,6 @@ kind: "package-library"
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)时,菜单隐藏这些提示,快捷键绑定仍然生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`isMobileDevice()` 识别手机和平板,供自动聚焦和收起键盘等行为使用。对于 Bowser 从 UA 识别为 `mobile` 或 `tablet` 的设备,以及报告 `MacIntel` 且触摸点数大于一的 iPad,该函数返回 true。对于其他设备(包括 Windows 触屏电脑),该函数返回 false,不受窗口宽度影响。composer 和命令弹窗在识别出的移动设备上禁止自动聚焦;用户主动请求的聚焦仍然可用。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
@@ -90,7 +88,7 @@ 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` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。
@@ -42,9 +42,7 @@
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
}
@media (hover: hover) {
.ghost:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -95,9 +95,7 @@ button.leading {
.row:hover .iconIdle {
opacity: 0;
}
}
@media (hover: hover) {
.row:hover .chevronHover {
opacity: 1;
}
@@ -292,6 +292,7 @@ export function HoverCard({
} : undefined}
onPointerEnter={(event) => {
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()
@@ -376,5 +376,6 @@
@media (hover: none) {
.copySlot { opacity: 1; pointer-events: auto; }
.row:has(> .copySlot) { padding-right: 24px; }
}
@@ -57,6 +57,16 @@ function installClipboard(writeText: (text: string) => Promise<void>): () => voi
}
describe('HoverCard', () => {
it.each([false, true])('opens mouse previews only when a pointer can hover: %s', (canHover) => {
vi.stubGlobal('matchMedia', () => ({ matches: canHover }))
const { wrapper } = mount()
const event = new Event('pointerover', { bubbles: true })
Object.defineProperty(event, 'pointerType', { value: 'mouse' })
fireEvent(wrapper, event)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body') !== null).toBe(canHover)
})
it.each(['compact', 'preview'] as const)('does not open a %s card when a touch enters the anchor', (variant) => {
const { wrapper } = mount({ variant })
const event = new Event('pointerover', { bubbles: true })
@@ -625,6 +635,7 @@ describe('HoverCard', () => {
})
it('opens inline previews only for keyboard focus and closes when focus leaves the anchor', () => {
vi.stubGlobal('matchMedia', () => ({ matches: false }))
const anchor = <><button>first</button><button>second</button></>
const view = render(<HoverCard inline anchor={anchor} content={<span>thumbnail</span>} />)
const first = screen.getByRole('button', { name: 'first' })
@@ -134,10 +134,9 @@
}
@media (hover: hover) {
.filterTab:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.filterTab:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.filterTabActive {
@@ -162,10 +161,9 @@
}
@media (hover: hover) {
.searchField:hover {
border-color: var(--dsw-alias-border-l2);
}
.searchField:hover {
border-color: var(--dsw-alias-border-l2);
}
}
.searchField:focus-within {
@@ -254,10 +252,9 @@
}
@media (hover: hover) {
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.rowGlyph {
@@ -324,10 +321,9 @@
}
@media (hover: hover) {
.endedRow:hover .rowTitle {
color: inherit;
}
.endedRow:hover .rowTitle {
color: inherit;
}
}
.endedRow.selectedRow .rowTitle {
@@ -335,10 +331,9 @@
}
@media (hover: hover) {
.endedRow:hover .rowSummary {
color: var(--dsw-alias-label-tertiary);
}
.endedRow:hover .rowSummary {
color: var(--dsw-alias-label-tertiary);
}
}
.endedRow.selectedRow .rowSummary {
@@ -514,10 +509,9 @@
}
@media (hover: hover) {
.editName:hover {
box-shadow: 0 1px var(--dsw-alias-border-l3);
}
.editName:hover {
box-shadow: 0 1px var(--dsw-alias-border-l3);
}
}
.editName:focus {
@@ -725,10 +719,9 @@
}
@media (hover: hover) {
.retentionInfo:hover {
color: var(--dsw-alias-label-primary);
}
.retentionInfo:hover {
color: var(--dsw-alias-label-primary);
}
}
.retentionInfo:focus-visible {
@@ -872,10 +865,9 @@
}
@media (hover: hover) {
.ruleValue:not(:disabled):hover .ruleValueFace {
background: var(--dsw-alias-interactive-bg-hover);
}
.ruleValue:not(:disabled):hover .ruleValueFace {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* The trigger's own ring would box the row label as well; the value face
@@ -968,10 +960,9 @@
}
@media (hover: hover) {
.ruleInput:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.ruleInput:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* An ended task keeps its values readable: tertiary rather than dimmed ink. */
@@ -1029,10 +1020,9 @@
}
@media (hover: hover) {
.ruleIntervalInput:not(:disabled):hover {
background: transparent;
}
.ruleIntervalInput:not(:disabled):hover {
background: transparent;
}
}
.ruleRows .ruleIntervalInput:focus-visible {
@@ -1049,10 +1039,9 @@
}
@media (hover: hover) {
.ruleIntervalStepper:hover .ruleIntervalArrows {
opacity: 1;
}
.ruleIntervalStepper:hover .ruleIntervalArrows {
opacity: 1;
}
}
.ruleIntervalStepper:focus-within .ruleIntervalArrows {
@@ -1074,10 +1063,9 @@
}
@media (hover: hover) {
.ruleIntervalArrow:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.ruleIntervalArrow:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
}
.ruleIntervalArrow:disabled {
@@ -1161,10 +1149,9 @@
}
@media (hover: hover) {
.instruction:hover {
border-color: var(--dsw-alias-border-l2);
}
.instruction:hover {
border-color: var(--dsw-alias-border-l2);
}
}
.instruction:focus {
@@ -1309,10 +1296,9 @@
}
@media (hover: hover) {
.savedPromptToggle:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.savedPromptToggle:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.savedPromptToggle:focus-visible {
@@ -1524,6 +1510,7 @@
/* The mock narrows the title strip's gap at this width and closes the
actions' group gap between its icon buttons. */
.detailTabsBar {
gap: 16px;
}
@@ -184,8 +184,7 @@
}
@media (hover: hover) {
.card:hover::before { border-color: var(--dsw-alias-border-l4); }
}
@media (hover: hover) {
.card:hover::after { opacity: 0.35; }
}
.selected::before { opacity: 0; }
@@ -266,11 +265,17 @@
@media (max-height: 760px) {
.purpose, .process { top: calc(50% - 180px); }
.purpose .copy h1, .process .copy h1 { font-size: 28px; line-height: 39px; }
.subtitle { margin-top: 16px; line-height: 21px; }
.card { padding: 24.5px 32.5px 32.5px; min-height: 187px; }
.processCards { min-height: 0; padding-block: 0; }
.primaryAction { margin-top: 40px; }
.creditActions { margin-top: 32px; }
}
@@ -284,9 +289,13 @@
@media (max-width: 1100px) {
.page { --page-padding: 40px; }
.copy h1 { font-size: 26px; line-height: 40px; }
.heroDescription { font-size: 16px; line-height: 28px; }
.welcome .copy h1 { font-size: 28px; line-height: 48px; }
.fileIcons { gap: 3px; }
}
@@ -296,6 +305,7 @@
@media (prefers-reduced-motion: reduce) {
.page button, .card, .card::before, .card::after { transition: none; }
.page, .fileIcons > svg, .selected::after { animation: none; }
}
@@ -15,4 +15,6 @@
.spinner { flex: none; animation: spin 1s linear infinite; }
.primaryButton:has(.spinner) { opacity: 1; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
@@ -36,10 +36,9 @@
}
@media (hover: hover) {
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Rail trigger: the same 36x36 box and 12px rounding as the other rail
@@ -160,10 +159,9 @@
}
@media (hover: hover) {
.navCell:hover {
background: var(--dsw-specific-sidebar-nav-item-hover);
}
.navCell:hover {
background: var(--dsw-specific-sidebar-nav-item-hover);
}
}
.navCell.active {
@@ -227,10 +225,9 @@
}
@media (hover: hover) {
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
@@ -174,9 +174,7 @@
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
}
@media (hover: hover) {
.secondaryButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
@@ -26,8 +26,7 @@
}
@media (hover: hover) {
.link:hover {
text-decoration: underline;
}
.link:hover {
text-decoration: underline;
}
}
@@ -221,7 +221,9 @@ p.hint { margin: 6px 0; }
.toastSuccess { color: var(--dsw-alias-state-success-secondary); }
.toastError { color: var(--dsw-alias-state-warn-secondary); }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (hover: none) { .rowActions { opacity: 1; } }
@media (hover: none) {
.rowActions { opacity: 1; }
}
.button:disabled, .rowButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; }
.search::-webkit-search-cancel-button { -webkit-appearance: none; }
@@ -139,10 +139,9 @@
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* The expand button's glyph, so collapse and expand share one affordance. */
@@ -25,8 +25,7 @@
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
@@ -340,10 +340,9 @@
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
@@ -368,17 +367,13 @@
}
@media (hover: hover) {
.collapsed .toggle:hover .panelIcon {
display: inline;
}
.collapsed .toggle:hover .panelIcon {
display: inline;
}
}
@media (hover: hover) {
.collapsed .toggle:hover .railMark {
display: none;
}
.collapsed .toggle:hover .railMark {
display: none;
}
}
.railMark {
@@ -435,10 +430,9 @@
}
@media (hover: hover) {
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
}
/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy
@@ -449,10 +443,9 @@
}
@media (hover: hover) {
:global([data-platform='darwin']) .newSession:hover {
background: rgb(255 255 255 / 0.3);
}
:global([data-platform='darwin']) .newSession:hover {
background: rgb(255 255 255 / 0.3);
}
}
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession {
@@ -460,10 +453,9 @@
}
@media (hover: hover) {
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession:hover {
background: rgb(255 255 255 / 0.2);
}
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .newSession:hover {
background: rgb(255 255 255 / 0.2);
}
}
.collapsed .newSession {
@@ -478,10 +470,9 @@
}
@media (hover: hover) {
.collapsed .newSession:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.collapsed .newSession:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.newSessionLabel {
@@ -496,7 +487,7 @@
.newSessionLabelMask { flex: 1; min-width: 0; }
@media (hover: hover) {
.newSession:is(:hover, :focus-visible) .newSessionLabelMask:has(+ .newSessionShortcut) { overflow: hidden; mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); }
.newSession:is(:hover, :focus-visible) .newSessionLabelMask:has(+ .newSessionShortcut) { overflow: hidden; mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); }
}
.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; }
.newSessionContent > svg { flex: none; }
@@ -510,8 +501,7 @@
}
@media (hover: hover) {
.newSession:is(:hover, :focus-visible) .newSessionShortcut { opacity: 1; }
.newSession:is(:hover, :focus-visible) .newSessionShortcut { opacity: 1; }
}
.panelList {
@@ -543,10 +533,9 @@
}
@media (hover: hover) {
.panelRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.panelRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.panelRow.panelActive {
@@ -65,9 +65,7 @@
.row:hover .iconIdle {
opacity: 0;
}
}
@media (hover: hover) {
.row:hover .chevronHover {
opacity: 1;
}
@@ -187,9 +185,7 @@
.card:hover .inspectButton {
opacity: 1;
}
}
@media (hover: hover) {
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
@@ -46,10 +46,9 @@
}
@media (hover: hover) {
.themeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
.themeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
@@ -80,10 +80,9 @@
}
@media (hover: hover) {
.stepper:hover .arrows {
opacity: 1;
}
.stepper:hover .arrows {
opacity: 1;
}
}
.stepper:focus-within .arrows {
@@ -108,10 +107,9 @@
}
@media (hover: hover) {
.arrow:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.arrow:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
}
.arrow:disabled {
@@ -135,10 +133,9 @@
}
@media (hover: hover) {
.expander:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.expander:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.expander svg {
@@ -1,7 +1,7 @@
/** Hover capability checks for client stylesheets. */
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { atRuleBlock, packageStylesheets } from './stylesheet-scan.ts'
import { atRuleBlock, packageFiles, packageStylesheets } from './stylesheet-scan.ts'
function unguardedHover(source: string): string[] {
const css = source.replace(/\/\*[\s\S]*?\*\//g, '')
@@ -36,7 +36,11 @@ it('rejects unguarded hover and queries that also match devices without hover',
})
it('limits every client hover selector to hover-capable primary input', () => {
const files = packageStylesheets().filter(file => file.includes('/packages/client/'))
const clientRoots = packageFiles(name => name === 'package.json').flatMap((file) => {
const manifest = JSON.parse(readFileSync(file, 'utf8')) as { dsh?: { client?: object } }
return manifest.dsh?.client === undefined ? [] : [file.slice(0, -'package.json'.length)]
})
const files = packageStylesheets().filter(file => file.includes('/packages/client/') || clientRoots.some(root => file.startsWith(root)))
expect(files.length).toBeGreaterThan(0)
const failures = files.flatMap(file => unguardedHover(readFileSync(file, 'utf8')).map(selector => `${file}: ${selector}`))
expect(failures).toEqual([])
@@ -158,9 +158,7 @@
.root:hover .iconIdle {
opacity: 0;
}
}
@media (hover: hover) {
.root:hover .chevronHover {
opacity: 1;
}
@@ -186,9 +186,7 @@
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
@media (hover: hover) {
.table tbody tr[data-request-only='true']:hover {
background: transparent;
}
@@ -12,10 +12,9 @@
}
@media (hover: hover) {
.projectRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.projectRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.sessionRow.selected {
@@ -39,10 +38,9 @@
}
@media (hover: hover) {
.searchResultRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchResultRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.searchResultRow.selected {
@@ -148,10 +146,9 @@
/* Project leading slot: folder by default, expand arrow on row hover. */
.projectRow .chevron { display: none; }
@media (hover: hover) {
.projectRow:hover .chevron { display: inline-flex; }
}
@media (hover: hover) {
.projectRow:hover .folder { display: none; }
.projectRow:hover .chevron { display: inline-flex; }
.projectRow:hover .folder { display: none; }
}
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
@@ -270,11 +267,10 @@
}
@media (hover: hover) {
.projectRow:hover .rowActions,
.searchResultRow:hover .rowActions {
display: inline-flex;
}
.projectRow:hover .rowActions,
.searchResultRow:hover .rowActions {
display: inline-flex;
}
}
.projectRow.menuOpen .rowActions {
@@ -447,10 +443,9 @@
}
@media (hover: hover) {
.iconButton:hover {
color: var(--dsw-alias-label-primary);
}
.iconButton:hover {
color: var(--dsw-alias-label-primary);
}
}
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
@@ -39,10 +39,9 @@
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* View-options dropdown card: labels are short, so a content-sized card reads
@@ -200,17 +199,13 @@
}
@media (hover: hover) {
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
@media (hover: hover) {
.searchExpanded .searchButton:hover {
background: transparent;
}
.searchExpanded .searchButton:hover {
background: transparent;
}
}
.searchInput {
@@ -254,10 +249,9 @@
}
@media (hover: hover) {
.clearButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.clearButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Rail variant (own .rail class from the wide owner prop — the region never
@@ -304,10 +298,9 @@
}
@media (hover: hover) {
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* List seat: always mounted so the shell foot never moves. */
@@ -529,11 +522,10 @@
}
@media (hover: hover) {
.sessionOverflowButton:hover {
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.sessionOverflowButton:hover {
background: transparent;
color: var(--dsw-alias-label-secondary);
}
}
.empty {
@@ -16,11 +16,16 @@
cursor: pointer;
}
.trigger:hover,
.trigger:focus-visible {
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.trigger:hover {
color: var(--dsw-alias-label-primary);
}
}
/* Second sacrifice after the preset label: past this title-row width the
trigger keeps only its icon. TeamAction reads the collapsed state off this
rule to switch from hover-open to click-only. */
@@ -155,12 +160,18 @@
box-shadow: var(--dsw-elevation-stroke), inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
}
.member:not(:disabled):hover,
.member:not(:disabled):focus-visible {
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
box-shadow: var(--dsw-elevation-panel);
}
@media (hover: hover) {
.member:not(:disabled):hover {
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
box-shadow: var(--dsw-elevation-panel);
}
}
/* Seat height 1lh tracks the member name's first text line, so any dot
diameter centers on that line instead of the row's top edge. */
.memberDot {
@@ -194,17 +205,22 @@
text-overflow: ellipsis;
}
/* Model details stay behind a hover/focus reveal to keep the resting card to
its name and activity. */
/* Hover-capable cards reveal model details on hover or focus; touch cards
keep the details visible beside the name and activity. */
.memberModel {
display: none;
}
.member:hover .memberModel,
.member:focus-visible .memberModel {
display: inline;
}
@media (hover: hover) {
.member:hover .memberModel {
display: inline;
}
}
/* The member card is denser than the surfaces the standard capsule ships for,
so the current-conversation tag drops one step in size. The 999 shrink
factor collapses the tag (block display re-enables text-overflow) before
@@ -295,11 +311,16 @@
cursor: pointer;
}
.expandToggle:hover,
.expandToggle:focus-visible {
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.expandToggle:hover {
color: var(--dsw-alias-label-primary);
}
}
.expandToggle svg {
transition: transform 120ms ease;
}
@@ -324,3 +345,9 @@
padding: 9px;
font-size: 12px;
}
@media (hover: none) {
.memberModel {
display: inline;
}
}
@@ -85,11 +85,16 @@
cursor: pointer;
}
.button:hover,
.button:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.button:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.button:disabled {
cursor: default;
opacity: 0.6;
@@ -2,9 +2,11 @@
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 28px; margin-bottom: 4px; }
/* Offset the sm Button's 10px inline padding to align its label with the body. */
.toolbar > .viewToggle { margin-inline-start: -10px; color: var(--dsw-alias-label-tertiary); }
.toolbar > .viewToggle:hover:not(:disabled),
.toolbar > .viewToggle:active:not(:disabled),
.toolbar > .viewToggle:focus-visible:not(:disabled) { color: var(--dsw-alias-label-secondary); }
@media (hover: hover) {
.toolbar > .viewToggle:hover:not(:disabled) { color: var(--dsw-alias-label-secondary); }
}
.failure { color: var(--dsw-alias-label-primary); font-size: var(--dsh-content-font-size-secondary, 13px); }
.field { display: flex; flex-direction: column; gap: 8px; }
.field label { font-size: 13px; color: var(--dsw-alias-label-primary); }
@@ -13,4 +15,6 @@
.spinner { animation: cot-translation-spin 1s linear infinite; }
@keyframes cot-translation-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
@@ -2,7 +2,9 @@
.triggerAnchor { display: inline-flex; flex: none; }
.captureRow { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; min-height: 34px; }
.roundButton { width: 32px; height: 32px; padding: 0; border-radius: 50%; corner-shape: round; flex: none; background: var(--dsw-specific-selector); }
.roundButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid); }
@media (hover: hover) {
.roundButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid); }
}
.waveform { display: block; flex: 1; width: 0; height: 24px; min-width: 24px; color: var(--dsw-alias-label-secondary); }
.activityMessage { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.inlineAction { flex: none; }
@@ -48,5 +50,6 @@
.inputLevel > span[data-active='true'] { background: var(--dsw-alias-state-success-primary); }
@media (max-width: 480px) {
.deviceRow, .preferences label, .sourceChoice label { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.deviceError { grid-column: 1; }
}
@@ -39,11 +39,16 @@
user-select: none;
}
.resize:hover,
.resize:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.resize:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.page {
display: block;
flex: 1;
@@ -39,21 +39,33 @@
.typeColumn { width: 190px; }
.typeHeader { display: flex; align-items: center; gap: 6px; }
.typeFilterButton { display: inline-flex; border: none; border-radius: 3px; padding: 2px; background: transparent; color: inherit; cursor: pointer; }
.typeFilterButton:hover, .typeFilterButton[aria-pressed='true'] { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); }
.typeFilterButton[aria-pressed='true'] { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); }
@media (hover: hover) {
.typeFilterButton:hover { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); }
}
.typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow: auto; box-sizing: border-box; padding: 10px; color: var(--dsw-alias-label-primary); font-size: 11px; }
.typeFilterInput { width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; background: transparent; color: inherit; font: inherit; }
.typeFilterHelp { margin: 6px 0; color: var(--dsw-alias-label-secondary); }
.typeFilterOptions { max-height: 180px; overflow: auto; }
.typeFilterOptions button { display: block; width: 100%; padding: 4px 6px; border: none; background: transparent; color: inherit; font: inherit; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.typeFilterOptions button:hover, .typeFilterOptions button[aria-selected='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
.typeFilterOptions button[aria-selected='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
@media (hover: hover) {
.typeFilterOptions button:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
}
.typeFilterActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.typeFilterActions button { border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 3px 6px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.contextRow .record { color: var(--dsw-alias-label-tertiary); }
.locationColumn { width: 65px; }
.timeColumn { width: 100px; }
.table tr:hover, .selected { background: var(--dsw-alias-interactive-bg-hover); }
.selected { background: var(--dsw-alias-interactive-bg-hover); }
@media (hover: hover) {
.table tr:hover { background: var(--dsw-alias-interactive-bg-hover); }
}
.sticky { position: sticky; }
.table .sticky, .table .sticky:hover { background: var(--dsw-alias-bg-module-platform); }
.table .sticky { background: var(--dsw-alias-bg-module-platform); }
@media (hover: hover) {
.table .sticky:hover { background: var(--dsw-alias-bg-module-platform); }
}
.sticky.selected .record { color: var(--dsw-alias-state-business-primary); }
.identity { color: var(--dsw-alias-label-tertiary); }
.identityText { display: block; overflow: hidden; text-overflow: ellipsis; }
@@ -62,12 +74,19 @@
.preview { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; }
.details { display: flex; flex: 0 0 40%; flex-direction: column; min-height: 0; max-height: 80%; overflow: hidden; }
.detailsResize { flex: none; height: 5px; cursor: row-resize; touch-action: none; user-select: none; background: var(--dsw-alias-border-l2); }
.detailsResize:hover, .detailsResize:active, .detailsResize:focus-visible { background: var(--dsw-alias-state-business-primary); }
.detailsResize:active,
.detailsResize:focus-visible { background: var(--dsw-alias-state-business-primary); }
@media (hover: hover) {
.detailsResize:hover { background: var(--dsw-alias-state-business-primary); }
}
.details pre { flex: 1; min-height: 0; margin: 0; padding: 8px; font-size: inherit; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.modeSelector { border: none; background: var(--dsw-alias-bg-module-platform); color: inherit; font: inherit; padding: 2px 4px; }
.modeControls { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
.pickButton { display: inline-flex; align-items: center; justify-content: center; border-radius: 4px; padding: 3px; }
.pickButton:hover, .pickButton[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
.pickButton[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
@media (hover: hover) {
.pickButton:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); }
}
.pickNotice { flex: none; padding: 4px 8px; color: var(--dsw-alias-label-secondary); border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
.chatPick { position: fixed; z-index: 1201; pointer-events: none; background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 15%, transparent); outline: 1px solid var(--dsw-alias-state-business-primary); border-radius: 4px; }
.chatPickMask { position: fixed; z-index: 1200; pointer-events: auto; background: transparent; cursor: crosshair; touch-action: none; }
@@ -80,7 +99,9 @@
.objectValue { color: var(--dsw-alias-label-secondary); }
.objectMuted { color: var(--dsw-alias-label-tertiary); }
.objectReference, .objectBreadcrumbs button { border: none; background: transparent; padding: 0; color: var(--dsw-alias-state-business-primary); cursor: pointer; font: inherit; text-align: left; }
.objectReference:hover { text-decoration: underline; }
@media (hover: hover) {
.objectReference:hover { text-decoration: underline; }
}
.objectBreadcrumbs { position: sticky; top: -8px; z-index: 1; margin: -8px -8px 8px; padding: 6px 8px; overflow: auto; background: var(--dsw-alias-bg-module-platform); border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
.objectBreadcrumbs ol { display: flex; align-items: center; gap: 6px; list-style: none; margin: 0; padding: 0; }
.objectBreadcrumbs li { display: flex; align-items: center; gap: 6px; flex: none; }
@@ -177,9 +177,11 @@
font: var(--dsw-font-xs-13);
}
.sourceTab:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
@media (hover: hover) {
.sourceTab:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
}
.sourceTab:disabled {
@@ -270,14 +272,19 @@
transition: opacity 100ms ease;
}
.card:hover .inspectButton,
.inspectButton:focus-visible {
opacity: 1;
}
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
@media (hover: hover) {
.card:hover .inspectButton {
opacity: 1;
}
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
}
}
.visuallyHidden {
@@ -294,3 +301,9 @@
transition: none;
}
}
@media (hover: none) {
.inspectButton {
opacity: 1;
}
}
@@ -34,8 +34,10 @@
overflow: hidden;
}
.badge:hover {
background: var(--dsw-alias-interactive-bg-hover);
@media (hover: hover) {
.badge:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.badge[data-active] {
@@ -296,8 +298,10 @@
cursor: pointer;
}
.transitionActions button:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
@media (hover: hover) {
.transitionActions button:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.transitionActions button:disabled {
@@ -328,9 +332,11 @@
cursor: pointer;
}
.actionButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
@media (hover: hover) {
.actionButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
}
.actionButton:disabled {
@@ -89,13 +89,18 @@
opacity: 0;
}
.card:hover .inspect,
.inspect:focus-visible {
opacity: 1;
}
.inspect:hover {
background: var(--dsw-alias-interactive-bg-hover);
@media (hover: hover) {
.card:hover .inspect {
opacity: 1;
}
.inspect:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.message {
@@ -132,3 +137,9 @@
border: none;
border-radius: 0;
}
@media (hover: none) {
.inspect {
opacity: 1;
}
}