fix(client): unify overlay clearance and preserve the Windows caption
This commit is contained in:
1 parent
1aea67a755
commit
3adf561295
21 files changed
+130
-66
No files matched your search
@@ -13,7 +13,7 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 600 }, locale: ZH_BROWSER_LOCALE })
|
||||
const page = await browser.newPage({ viewport: { width: 520, height: 600 }, locale: ZH_BROWSER_LOCALE })
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
@@ -21,14 +21,22 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
document.documentElement.setAttribute('data-windows-titlebar', '')
|
||||
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
|
||||
})
|
||||
const reopenSidebar = page.getByRole('button', { name: '打开侧边栏', exact: true })
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(margin)
|
||||
expect((await settings.boundingBox())?.height).toBe(600 - 2 * margin)
|
||||
expect((await settingsMask.boundingBox())?.y).toBe(fullscreen ? 0 : 40)
|
||||
expect((await settings.locator('..').boundingBox())?.height).toBe(600)
|
||||
}
|
||||
expect(await page.evaluate(() => document.elementFromPoint(300, 20)?.getAttribute('role'))).toBe('presentation')
|
||||
await page.mouse.click(300, 20)
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
await settings.getByRole('button', { name: '工作区内修改', exact: true }).click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor()
|
||||
@@ -39,21 +47,46 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
await menu.waitFor({ state: 'hidden' })
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
await settings.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
await page.locator('[data-plugin-panel]').getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const plugin = page.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await plugin.locator('button[aria-expanded="false"]').first().click()
|
||||
await plugin.getByRole('button', { name: '插件安装引导和示例', exact: true }).click()
|
||||
await expect.poll(() => plugin.locator('button[aria-expanded="true"]').count()).toBe(1)
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
await expect.poll(async () => (await plugin.boundingBox())?.y).toBe(margin)
|
||||
expect((await plugin.boundingBox())?.height).toBe(600 - 2 * margin)
|
||||
expect((await plugin.locator('..').locator(':scope > [aria-hidden="true"]').boundingBox())?.y).toBe(fullscreen ? 0 : 40)
|
||||
expect(await plugin.getByRole('button', { name: '关闭', exact: true }).isVisible()).toBe(true)
|
||||
expect(await plugin.getByRole('button', { name: '安装', exact: true }).isVisible()).toBe(true)
|
||||
}
|
||||
await plugin.getByRole('button', { name: /^安装源/ }).click()
|
||||
const registry = page.locator('[data-install-registry]')
|
||||
await registry.waitFor()
|
||||
const registryRect = await registry.boundingBox()
|
||||
expect(registryRect!.y).toBeGreaterThanOrEqual(60)
|
||||
expect(registryRect!.y + registryRect!.height).toBeLessThanOrEqual(588)
|
||||
await page.keyboard.press('Escape')
|
||||
await registry.waitFor({ state: 'hidden' })
|
||||
expect(await plugin.isVisible()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await plugin.waitFor({ state: 'hidden' })
|
||||
for (const platform of ['darwin', 'browser']) {
|
||||
await page.evaluate((value) => {
|
||||
document.documentElement.removeAttribute('data-windows-titlebar')
|
||||
document.documentElement.removeAttribute('data-fullscreen')
|
||||
if (value === 'darwin') document.documentElement.setAttribute('data-platform', value)
|
||||
else document.documentElement.removeAttribute('data-platform')
|
||||
}, platform)
|
||||
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
|
||||
await openSettings(page, 'zh')
|
||||
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 24)
|
||||
expect((await settingsMask.boundingBox())?.y).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await settings.waitFor({ state: 'hidden' })
|
||||
}
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await browser.close() }
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-layout/use-this-package:
|
||||
en: 7d135dd3dbd7ff96
|
||||
zh: 43ac4d9c5a12f741
|
||||
en: 66f419ab23119ba6
|
||||
zh: ae0bba69aff6eead
|
||||
/deepseek-ai-dsh-client-ui-layout/use-this-package/window-chrome-seat:
|
||||
en: e55c831b460b40e4
|
||||
zh: 8670e583bec8c50b
|
||||
en: 7eac53b38d0777fe
|
||||
zh: 7f9422dff671ba50
|
||||
/deepseek-ai-dsh-client-ui-layout/use-this-package/theme-presentation:
|
||||
en: 1e1d3361dac7d8b0
|
||||
zh: b6b3d210d65738f0
|
||||
|
||||
@@ -31,12 +31,15 @@ The root slot composes the sidebar, main content, and right column. The sidebar
|
||||
|
||||
Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
|
||||
|
||||
<a id="window-chrome-seat"></a>
|
||||
### Window-chrome seat
|
||||
|
||||
On macOS desktop (`html[data-platform='darwin']`, set only by the desktop preload) a collapsed sidebar hides its column entirely instead of keeping the rail, and the frame mounts the single root-scoped `shell.leading` seat at its top-left — beside the hiddenInset traffic lights, over every main panel; ui-sidebar occupies it with the reopen and New Session controls. While the seat is mounted the frame publishes `--dsh-frame-leading-clearance`, the inline band the window chrome occupies measured from the frame's left edge; a main panel whose content reaches the top-left corner pads by it so nothing lands under the lights or the controls. The frame also always publishes `--dsh-frame-top-clearance` (48px) on the root element, the constant step below the window's top strip; entry pages in the main panel (plugin manager and similar, not the conversation) pad their top by it, and overlay primitives (portal menus, bottom-anchored overlays, the settings panel) keep it as their top viewport margin — the root-element home lets overlays portalled to `document.body` read it too. The frame declares no darwin drag of its own: every chrome row marks itself `data-window-drag`, ui-web base.css turns that mark into the one darwin drag rule, and the row's own box is therefore the window's draggable geometry — a row's blank runs drag while its controls stay clickable. The frame's remaining drag rule is the Windows caption row, which belongs to that platform.
|
||||
On macOS desktop (`html[data-platform='darwin']`, set only by the desktop preload) a collapsed sidebar hides its column entirely instead of keeping the rail, and the frame mounts the single root-scoped `shell.leading` seat at its top-left — beside the hiddenInset traffic lights, over every main panel; ui-sidebar occupies it with the reopen and New Session controls. While the seat is mounted the frame publishes `--dsh-frame-leading-clearance`, the inline band the window chrome occupies measured from the frame's left edge; a main panel whose content reaches the top-left corner pads by it so nothing lands under the lights or the controls. The frame also always publishes `--dsh-frame-top-clearance` (48px) on the root element, the constant step below the window's top strip; entry pages in the main panel (plugin manager and similar, not the conversation) pad their top by it. The frame declares no darwin drag of its own: every chrome row marks itself `data-window-drag`, ui-web base.css turns that mark into the one darwin drag rule, and the row's own box is therefore the window's draggable geometry — a row's blank runs drag while its controls stay clickable. The frame's remaining drag rule is the Windows caption row, which belongs to that platform.
|
||||
|
||||
Windows Electron's `data-windows-titlebar` marker reserves the caption height above all columns and removes the collapsed sidebar rail. Only the content area's top-left corner has a 16px radius; the other corners and the internal divider remain square. The frame publishes `--dsh-windows-content-radius` and `--dsh-windows-sidebar-width` for ui-sidebar-right's fullscreen corner and sidebar clearance. Ordinary Web documents do not receive the marker; macOS retains its separate layout.
|
||||
|
||||
The root publishes `--dsh-frame-top-clearance` for the reserved strip (48px on macOS, the caption height on Windows), and `--dsh-frame-overlay-top` adds 20px for dialogs and menus. Native fullscreen reduces the overlay inset to 20px; dialogs retain their own 24px minimum. `--dsh-frame-chrome-top` excludes the Windows caption from modal mask painting and becomes zero in fullscreen. The full-viewport modal layer still blocks background clicks. Plain browser documents publish none of these values.
|
||||
|
||||
### Theme presentation
|
||||
|
||||
The presenter consumes resolved theme snapshots and projects them onto the document: `html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens and `--dsh-content-font-size` as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background. Disposing the presenter removes its metadata node with its other global writes.
|
||||
|
||||
@@ -31,12 +31,15 @@ kind: "package-reference"
|
||||
|
||||
全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
|
||||
|
||||
<a id="window-chrome-seat"></a>
|
||||
### 窗口 chrome 座
|
||||
|
||||
在 macOS 桌面(`html[data-platform='darwin']`,仅由桌面 preload 设置)下,收起的侧边栏整列隐藏而非保留控制栏,框架在左上角挂载 root 作用域的单一 `shell.leading` 座——位于 hiddenInset 红绿灯旁,覆盖所有主面板;ui-sidebar 以重新打开与 New Session 控件占据该座。座挂载期间框架发布 `--dsh-frame-leading-clearance`:窗口 chrome 占据的行内带宽,自框架左边缘起量;内容抵达左上角的主面板以它做内边距,避免落在红绿灯或控件之下。框架还始终在根元素上发布 `--dsh-frame-top-clearance`(48px):窗口顶带之下的固定下沉量;主面板中的入口型页面(插件管理器等类似页面,非对话)以它做顶部内边距,浮层原语(portal 菜单、底部锚定浮层、设置面板)以它做视口顶部安全边距——放在根元素上让 portal 到 document.body 的浮层也能读到。框架自己不声明任何 darwin drag:每个 chrome 行给自身打上 `data-window-drag`,ui-web base.css 把该标记变成唯一那条 darwin drag 规则,于是行自己的盒子就是窗口的可拖几何——行的空白段可拖,控件保持可点。框架仅剩的那条 drag 规则属于 Windows 标题栏行。
|
||||
在 macOS 桌面(`html[data-platform='darwin']`,仅由桌面 preload 设置)下,收起的侧边栏整列隐藏而非保留控制栏,框架在左上角挂载 root 作用域的单一 `shell.leading` 座——位于 hiddenInset 红绿灯旁,覆盖所有主面板;ui-sidebar 以重新打开与 New Session 控件占据该座。座挂载期间框架发布 `--dsh-frame-leading-clearance`:窗口 chrome 占据的行内带宽,自框架左边缘起量;内容抵达左上角的主面板以它做内边距,避免落在红绿灯或控件之下。框架还始终在根元素上发布 `--dsh-frame-top-clearance`(48px):窗口顶带之下的固定下沉量;主面板中的入口型页面(插件管理器等类似页面,非对话)以它做顶部内边距。框架自己不声明任何 darwin drag:每个 chrome 行给自身打上 `data-window-drag`,ui-web base.css 把该标记变成唯一那条 darwin drag 规则,于是行自己的盒子就是窗口的可拖几何——行的空白段可拖,控件保持可点。框架仅剩的那条 drag 规则属于 Windows 标题栏行。
|
||||
|
||||
Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。框架发布 `--dsh-windows-content-radius` 和 `--dsh-windows-sidebar-width`,供 ui-sidebar-right 的全屏圆角及侧栏避让使用。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
|
||||
|
||||
根元素通过 `--dsh-frame-top-clearance` 发布顶栏占用量(macOS 为 48px,Windows 为标题栏高度),通过 `--dsh-frame-overlay-top` 为弹窗和菜单再留出 20px。原生全屏时浮层起点降为 20px;弹窗仍保留自身 24px 的最小边距。`--dsh-frame-chrome-top` 让模态遮罩不绘制 Windows 顶栏区域,全屏时归零。覆盖整个视口的模态层仍阻挡背景点击。普通浏览器文档不发布这些值。
|
||||
|
||||
### 主题呈现
|
||||
|
||||
呈现器消费解析后的主题快照,并投影到 document:`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,把主题的别名 token 与 `--dsh-content-font-size` 设为 body 上的内联变量,并持有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新。对呈现器执行 dispose(资源释放)时,它会连同其他全局写入一起移除自己的元数据节点。
|
||||
|
||||
@@ -87,13 +87,31 @@
|
||||
toggle, shell.leading controls), the same in both sidebar states. Entry
|
||||
pages in the main panel (plugin manager, settings-like pages — not the
|
||||
conversation, which owns its header) pad their top by it so their leading
|
||||
content clears the strip, and overlay primitives keep it as their top
|
||||
viewport margin. Declared on the root element rather than the frame so
|
||||
content clears the strip, and overlay primitives add their own safe inset.
|
||||
Declared on the root element rather than the frame so
|
||||
overlays portalled to document.body inherit it too. */
|
||||
:global(html[data-platform='darwin']) {
|
||||
--dsh-frame-top-clearance: 48px;
|
||||
}
|
||||
|
||||
/* In-frame Windows content already clears the caption through .frame padding. */
|
||||
:global(html[data-windows-titlebar]) {
|
||||
--dsh-frame-top-clearance: var(--dsh-windows-titlebar-height);
|
||||
--dsh-frame-chrome-top: var(--dsh-windows-titlebar-height);
|
||||
}
|
||||
|
||||
/* Portalled overlays share a viewport inset without adding space to entry pages. */
|
||||
:global(html[data-platform='darwin']),
|
||||
:global(html[data-windows-titlebar]) {
|
||||
--dsh-frame-overlay-top: calc(var(--dsh-frame-top-clearance) + 20px);
|
||||
}
|
||||
|
||||
:global(html[data-platform='darwin'][data-fullscreen]),
|
||||
:global(html[data-windows-titlebar][data-fullscreen]) {
|
||||
--dsh-frame-overlay-top: 20px;
|
||||
--dsh-frame-chrome-top: 0px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .sidebarCol {
|
||||
/* A blue wash over the top of the translucent tint, gone by a third of the
|
||||
column; a faint blue-grey-violet rises through the bottom third; and a
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
|
||||
en: 5dfe842e57aad44e
|
||||
zh: af68b7b66f75f027
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: d6d33ad4bfedf06f
|
||||
zh: e131294a2d40af28
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: f78bd63b167a7574
|
||||
zh: 472eb7791f080ac2
|
||||
en: b4cd33720a6cf6b8
|
||||
zh: 8db75bf633789c6d
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: d79e20306593c4bf
|
||||
zh: 5c0f1100e2cd6fcc
|
||||
|
||||
@@ -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` keeps at least 20px below the Windows caption; native fullscreen restores its 24px viewport margin. `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. `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.
|
||||
|
||||
@@ -82,7 +82,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 ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). 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; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `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. A `Tooltip` nested in the anchor hides that preview while its own hover or focus label is visible; releasing, disabling, or unmounting the nested tooltip restores a still-open preview. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. RereLine 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 ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). 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. A `Tooltip` nested in the anchor hides that preview while its own hover or focus label is visible; releasing, disabling, or unmounting the nested tooltip restores a still-open preview. `Toast` uses the owner's `holdMs` for botLine 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. 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.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 与 Windows 顶栏至少间隔 20px;原生全屏时恢复 24px 的视口边距。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
`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 监听。
|
||||
|
||||
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
||||
|
||||
@@ -82,7 +82,7 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 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` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按Line truncated
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 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;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前Line truncated
|
||||
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@
|
||||
|
||||
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
|
||||
* (the portal MARGIN in Menu.tsx; the top side widens to the frame's published
|
||||
* top clearance, the macOS window strip) and taller content scrolls inside
|
||||
* overlay inset) and taller content scrolls inside
|
||||
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
|
||||
* this class — the overflow clip would crop the side card, so they rely on
|
||||
* staying short. */
|
||||
.scrollable {
|
||||
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-top-clearance, 12px)));
|
||||
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
|
||||
}
|
||||
|
||||
.viewport {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card.
|
||||
24px of air on every side; the top/bottom widen to the frame's published
|
||||
top clearance (the macOS window strip) so a tall card stays clear of it.
|
||||
overlay inset so a tall card stays clear of it.
|
||||
Cards size against this padding box: consumers cap growth with
|
||||
`max-height: 100%`, never their own viewport calc. */
|
||||
.root {
|
||||
@@ -11,17 +11,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: max(24px, var(--dsh-frame-top-clearance, 24px)) 24px;
|
||||
padding: max(24px, var(--dsh-frame-overlay-top, 24px)) 24px;
|
||||
}
|
||||
|
||||
:global(html[data-windows-titlebar]:not([data-fullscreen])) .root {
|
||||
padding-block: calc(var(--dsh-windows-titlebar-height) + 20px);
|
||||
}
|
||||
|
||||
/* The mask dims the page; dark theme raises its opacity. */
|
||||
/* Only paint excludes the native caption; the root still blocks background clicks. */
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset: var(--dsh-frame-chrome-top, 0px) 0 0;
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
/**
|
||||
* Overlay clearance from the window's top strip. On macOS desktop the frame
|
||||
* publishes `--dsh-frame-top-clearance` on the root element — the constant
|
||||
* step below the traffic-light strip, where clicks drag the window instead of
|
||||
* the overlay. JS-clamped overlays keep at least that much air above them.
|
||||
* Elsewhere the property is absent and the caller's own margin applies.
|
||||
*/
|
||||
/** Shared viewport inset for overlays, derived from the desktop frame's reserved top strip. */
|
||||
|
||||
/**
|
||||
* Resolve the top margin an overlay keeps from the viewport edge.
|
||||
* @param min - the overlay's own viewport margin in px, used as the floor.
|
||||
* @returns the larger of `min` and the frame's published top clearance.
|
||||
* @returns the larger of `min` and the frame clearance plus 20px; fullscreen keeps only the 20px gap.
|
||||
*/
|
||||
export function overlayTopMargin(min: number): number {
|
||||
const clearance = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--dsh-frame-top-clearance'),
|
||||
)
|
||||
return Number.isNaN(clearance) ? min : Math.max(min, clearance)
|
||||
const root = document.documentElement
|
||||
const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue('--dsh-frame-top-clearance'))
|
||||
if (Number.isNaN(clearance)) return min
|
||||
return Math.max(min, (root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { overlayTopMargin } from './overlay-top-margin.ts'
|
||||
|
||||
/**
|
||||
* Safe distance kept between the overlay and the viewport top edge (mirrors
|
||||
* the Menu portal margin); the frame's published top clearance widens it.
|
||||
* the Menu portal margin); the frame's overlay inset widens it.
|
||||
*/
|
||||
const MARGIN = 12
|
||||
|
||||
@@ -20,8 +20,8 @@ const MARGIN = 12
|
||||
* @param cap - design max-height in px (the clamp never exceeds it).
|
||||
* @param signal - re-measure trigger: pass the overlay's render state so anchor
|
||||
* moves (composer growth) re-fit; resize/scroll re-fit while mounted.
|
||||
* @param margin - viewport top margin floor in px; the frame's published top
|
||||
* clearance widens it. Callers under fixed chrome (the conversation header)
|
||||
* @param margin - viewport top margin floor in px; the frame's overlay
|
||||
* inset widens it. Callers under fixed chrome (the conversation header)
|
||||
* raise it past their chrome's height.
|
||||
* @returns the max-height to apply inline, in px.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
|
||||
import { overlayTopMargin } from './overlay-top-margin.ts'
|
||||
|
||||
/** Inputs for {@link useAnchoredPosition}. */
|
||||
export interface AnchoredPositionOptions {
|
||||
@@ -26,7 +27,7 @@ export interface AnchoredPositionOptions {
|
||||
align?: 'start' | 'end'
|
||||
/** Distance kept between the anchor edge named by `side` and the panel. */
|
||||
gap: number
|
||||
/** Distance kept between the panel and each viewport edge. */
|
||||
/** Distance kept between the panel and each viewport edge; the frame's overlay inset widens the top margin. */
|
||||
margin: number
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
|
||||
let left = align === 'end' ? rect.right - width : rect.left
|
||||
let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
|
||||
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
|
||||
if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
|
||||
if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin)
|
||||
/* v8 ignore stop */
|
||||
setPosition({ left, top })
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ describe('HoverCard', () => {
|
||||
it('keeps previews below the desktop frame clearance', () => {
|
||||
const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance')
|
||||
const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance')
|
||||
document.documentElement.style.setProperty('--dsh-frame-top-clearance', '120px')
|
||||
document.documentElement.style.setProperty('--dsh-frame-top-clearance', '100px')
|
||||
try {
|
||||
const { wrapper } = mount({ variant: 'preview' })
|
||||
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 })
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('useAnchoredMaxHeight', () => {
|
||||
document.documentElement.style.setProperty('--dsh-frame-top-clearance', '48px')
|
||||
stubBottom(300)
|
||||
const { getByTestId } = render(<Probe cap={400} />)
|
||||
expect(getByTestId('probe').dataset.maxHeight).toBe('252')
|
||||
expect(getByTestId('probe').dataset.maxHeight).toBe('232')
|
||||
})
|
||||
|
||||
it('keeps a caller-raised margin over a published smaller clearance', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* `ResizeObserver` does not exist.
|
||||
*/
|
||||
import { useRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { useAnchoredPosition } from '../src/useAnchoredPosition.ts'
|
||||
|
||||
@@ -64,6 +64,26 @@ function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) {
|
||||
}
|
||||
|
||||
describe('useAnchoredPosition', () => {
|
||||
it.each([{ fullscreen: false, top: 60 }, { fullscreen: true, top: 20 }])('keeps an anchored panel at $top with fullscreen=$fullscreen', ({ fullscreen, top }) => {
|
||||
const root = document.documentElement
|
||||
const previous = root.getAttribute('style')
|
||||
const priorFullscreen = root.getAttribute('data-fullscreen')
|
||||
onTestFinished(() => {
|
||||
if (previous === null) root.removeAttribute('style')
|
||||
else root.setAttribute('style', previous)
|
||||
if (priorFullscreen === null) root.removeAttribute('data-fullscreen')
|
||||
else root.setAttribute('data-fullscreen', priorFullscreen)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
root.style.setProperty('--dsh-frame-top-clearance', '40px')
|
||||
root.toggleAttribute('data-fullscreen', fullscreen)
|
||||
vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, 200, 10))
|
||||
vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
|
||||
vi.stubGlobal('innerHeight', 600)
|
||||
const { getByTestId } = render(<Host open />)
|
||||
expect(getByTestId('panel').style.top).toBe(`${top}px`)
|
||||
})
|
||||
|
||||
it('lines the panel up with the anchor\'s right edge when aligned to the end', () => {
|
||||
// jsdom lays nothing out: the anchor's rect and the panel's width are stated, and the placement read back.
|
||||
const rect = { left: 100, right: 300, top: 10, bottom: 30, width: 200, height: 20, x: 100, y: 10, toJSON: () => ({}) } as DOMRect
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
|
||||
* (the hook's margin) and taller content scrolls inside .viewport. */
|
||||
.scrollable {
|
||||
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-top-clearance, 12px)));
|
||||
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
|
||||
}
|
||||
|
||||
.viewport {
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
en: cd94b4a8dae32670
|
||||
zh: 333b8603e77f92d7
|
||||
/deepseek-ai-dsh-client-ui-settings-general/table-of-contents:
|
||||
en: aeb9c4bfdb19bc56
|
||||
zh: 45a29bea90cff239
|
||||
en: 2843d46e95311bce
|
||||
zh: ec11bdeb5d3ce818
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
|
||||
en: 618f4c32a428d87e
|
||||
zh: 06d9aa318a002da3
|
||||
en: 3be9bf992c10bc6b
|
||||
zh: b270083651e5dc43
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
|
||||
en: 8b8a7c6287bb303b
|
||||
zh: 19535112531aedd5
|
||||
|
||||
@@ -22,14 +22,14 @@ Use this package to give the dsh web client a Settings panel, connection-recover
|
||||
|
||||
-----
|
||||
|
||||
The Settings panel uses a shared 760 × 500 layout, bounded by the viewport. Longer sections scroll inside the content column; the Account entry uses the account icon. The panel portals beside `#root` rather than inside it, so a macOS window drag region a chrome row declares later in document order cannot swallow its controls.
|
||||
The Settings panel uses a shared 800 × 800 layout, bounded by the viewport. Longer sections scroll inside the content column; the Account entry uses the account icon. The panel portals beside `#root` rather than inside it, so a macOS window drag region a chrome row declares later in document order cannot swallow its controls.
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows the shared ongoing loader beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
|
||||
|
||||
On Windows, the Settings panel keeps at least 20px below the caption; native fullscreen restores the 24px viewport margin.
|
||||
Settings uses the [frame’s shared overlay inset](../ui-layout/README.md#window-chrome-seat) and keeps at least 24px of viewport margin. Its mask leaves the Windows caption unpainted; the full-viewport layer still blocks background clicks.
|
||||
|
||||
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@ kind: "package-reference"
|
||||
|
||||
-----
|
||||
|
||||
设置面板采用各页面共享的 760 × 500 布局,并受视口大小约束。较长的页面在内容栏内部滚动;账号入口使用账号图标。面板挂载在 `#root` 之外而不是其内部,这样在文档序上更靠后的 chrome 行所声明的 macOS 窗口拖拽区不会吞掉它的控件。
|
||||
设置面板采用各页面共享的 800 × 800 布局,并受视口大小约束。较长的页面在内容栏内部滚动;账号入口使用账号图标。面板挂载在 `#root` 之外而不是其内部,这样在文档序上更靠后的 chrome 行所声明的 macOS 窗口拖拽区不会吞掉它的控件。
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示共享 ongoing loading 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
|
||||
|
||||
在 Windows 中,设置面板与顶栏至少间隔 20px;原生全屏时恢复 24px 的视口边距。
|
||||
设置面板使用[框架共享的浮层起点](../ui-layout/README.zh.md#window-chrome-seat),视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。
|
||||
|
||||
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
|
||||
|
||||
|
||||
@@ -67,9 +67,10 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* The full-viewport overlay blocks background clicks above the painted mask too. */
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset: var(--dsh-frame-chrome-top, 0px) 0 0;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
}
|
||||
@@ -79,15 +80,14 @@
|
||||
every section, taken from the viewport rather than the content, because a
|
||||
content-sized panel would resize under the pointer on every nav click.
|
||||
The viewport height leaves 24px of air above and below, widened to the
|
||||
frame's published top clearance (the macOS window strip, where clicks drag
|
||||
the window) on both sides, so the panel sits centered and clear of the
|
||||
strip. */
|
||||
frame's published overlay inset on both sides, so the panel sits centered
|
||||
and clear of the strip. */
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 800px;
|
||||
height: min(800px, calc(100vh - 2 * max(24px, var(--dsh-frame-top-clearance, 24px))));
|
||||
height: min(800px, calc(100vh - 2 * max(24px, var(--dsh-frame-overlay-top, 24px))));
|
||||
max-width: calc(100vw - 48px);
|
||||
border-radius: var(--dsw-radius-panel);
|
||||
overflow: hidden;
|
||||
@@ -102,10 +102,6 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
:global(html[data-windows-titlebar]:not([data-fullscreen])) .panel {
|
||||
height: min(800px, calc(100vh - 2 * (var(--dsh-windows-titlebar-height) + 20px)));
|
||||
}
|
||||
|
||||
.panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user