fix(web): preserve floating panel anchor alignment

This commit is contained in:
Yichen Jiang
2026-11-03 21:58:56 +08:00
parent 28fcf5116f
commit 0e8bfc1b9d
20 changed files with 398 additions and 44 deletions
+78
View File
@@ -0,0 +1,78 @@
/** Production styles keep oversized floating panels aligned with their triggers. */
import { readFile } from 'node:fs/promises'
import { chromium, webkit } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
const PANELS = [
{ file: 'client/ui-primitives/src/Menu.module.css', classes: 'list portal scrollable', align: 'start' },
{ file: 'client/ui-primitives/src/Menu.module.css', classes: 'list portal scrollable', align: 'end' },
{ file: 'client/ui-schedule/src/client/TaskMenu.module.css', classes: 'list portal scrollable', align: 'start' },
{ file: 'client/ui-schedule/src/client/TaskMenu.module.css', classes: 'list portal scrollable alignEnd', align: 'end' },
{ file: 'client/ui-schedule/src/client/PickerPopover.module.css', classes: 'panel', align: 'start' },
{ file: 'client/ui-schedule/src/client/ScheduleCatalogAction.module.css', classes: 'menu', align: 'start' },
{ file: 'client/ui-subagent/src/client/SubagentHeaderLineage.module.css', classes: 'menu', align: 'start' },
{ file: 'client/ui-approval/src/client/ApprovalPanel.module.css', classes: 'warningPopover', align: 'end' },
{ file: 'client/ui-jobs/src/client/JobListAction.module.css', classes: 'menu', align: 'start' },
{ file: 'client/ui-conversation/src/client/skeleton/ContextMeter.module.css', classes: 'panel', align: 'start' },
{ file: 'client/ui-chat/src/client/chat/stat-dialog.module.css', classes: 'panel', align: 'start' },
{ file: 'client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css', classes: 'panel', align: 'start' },
{ file: 'client/ui-plugin-manager/src/client/PluginManagerPage.module.css', classes: 'registry', align: 'end' },
{ file: 'experimental/session-inspector/src/client/views/inspector.module.css', classes: 'typeFilterPanel', align: 'start' },
] as const
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'retains horizontal anchors when tall panels exhaust both sides ($name)', async ({ engine }) => {
const browser = await engine.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ viewport: { width: 1400, height: 600 } })
for (const panel of PANELS) {
const css = await readFile(new URL(`../../../packages/${panel.file}`, import.meta.url), 'utf8')
// Only content volume is synthetic; placement and viewport limits come from each owner.
await page.setContent(`<style>${css}
#panel { display: flex; flex-direction: column; }
#scroller { overflow: auto; min-height: 0; }
#content { height: 2000px; width: 180px; flex: none; }
#trigger { position: absolute; left: 700px; top: 286px; width: 100px; height: 28px; anchor-name: --probe; }
</style><button id="trigger">Trigger</button>
<div id="panel" class="${panel.classes}" data-anchor-align="${panel.align}" style="position-anchor: --probe">
<div id="scroller"><div id="content">Long list</div></div>
</div>`)
const card = page.locator('#panel')
const horizontalError = () => card.evaluate((node, align) => {
const bounds = node.getBoundingClientRect()
const trigger = document.querySelector('#trigger')!.getBoundingClientRect()
return Math.abs(align === 'end' ? bounds.right - trigger.right : bounds.left - trigger.left)
}, panel.align)
await expect.poll(horizontalError, { message: `${engine.name()}: ${panel.file} (${panel.align})` }).toBeLessThan(1)
const bounds = await card.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(12)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(588)
expect(await page.locator('#scroller').evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true)
// Each opening mounts a new portal, without a previous native fallback choice.
await page.locator('#content').evaluate((node) => { node.style.height = '60px' })
await card.evaluate((node) => { node.replaceWith(node.cloneNode(true)) })
await expect.poll(() => card.evaluate(node => node.getBoundingClientRect().height)).toBeLessThan(120)
await expect.poll(horizontalError).toBeLessThan(1)
const short = await card.boundingBox()
expect(short!.y + short!.height <= 286 || short!.y >= 314).toBe(true)
const opensAbove = /(?:ApprovalPanel|ContextMeter|stat-dialog)/.test(panel.file)
await page.locator('#trigger').evaluate((node, top) => { node.style.top = `${top}px` }, opensAbove ? 24 : 548)
await card.evaluate((node) => { node.replaceWith(node.cloneNode(true)) })
await expect.poll(() => card.evaluate((node, above) => {
const rect = node.getBoundingClientRect()
return above ? rect.top >= 52 : rect.bottom <= 548
}, opensAbove), { message: `${engine.name()}: flipped ${panel.file}` }).toBe(true)
await page.setViewportSize({ width: 390, height: 600 })
await page.locator('#trigger').evaluate((node) => { node.style.left = '145px'; node.style.top = '286px' })
await page.locator('#content').evaluate((node) => { node.style.height = '2000px' })
await expect.poll(() => card.evaluate((node) => {
const rect = node.getBoundingClientRect()
return rect.left >= 12 && rect.right <= 378 && rect.top >= 12 && rect.bottom <= 588
}), { message: `${engine.name()}: constrained ${panel.file}` }).toBe(true)
await page.setViewportSize({ width: 1400, height: 600 })
}
})
+57
View File
@@ -1154,6 +1154,63 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps tall branch and changed-file menus aligned with their triggers', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-tall-menus')
await Promise.all(Array.from({ length: 45 }, (_, index) =>
git(project, 'update-ref', `refs/heads/worktree/long-menu-regression-${index}`, 'HEAD')))
const option = page.locator('[data-forge-worktree]')
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check()
const base = option.getByRole('button', { name: '基于 main', exact: true })
await base.click()
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: 'worktree/long-menu-regression-44', exact: true }).waitFor()
await expect.poll(async () => {
const trigger = await base.boundingBox()
const card = await menu.boundingBox()
return trigger === null || card === null ? Infinity : Math.abs(trigger.x + trigger.width - card.x - card.width)
}).toBeLessThan(1)
await page.keyboard.press('Escape')
expect(await base.getAttribute('aria-expanded')).toBe('false')
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).uncheck()
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await mkdir(join(project, 'src'))
await Promise.all(Array.from({ length: 60 }, (_, index) =>
writeFile(join(project, 'src', `long-file-list-${index}.ts`), `export const value = ${index}\n`)))
await git(project, 'add', 'src')
await git(project, 'commit', '--quiet', '-m', 'long file list')
const { settled } = await sendPrompt(page, scaffold)
await settled
await page.locator('[data-forge-header]').click()
const panel = page.locator('[data-forge-panel]')
await panel.getByRole('button', { name: '刷新状态', exact: true }).click()
await panel.getByRole('button', { name: /^查看本会话的变更:60 个文件/ }).click()
const changes = page.locator('[data-forge-changes="local"]')
const files = changes.getByRole('button', { name: '全部 60 个文件', exact: true })
await files.click()
await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).waitFor()
await expect.poll(async () => {
const trigger = await files.boundingBox()
const card = await menu.boundingBox()
return trigger === null || card === null ? Infinity : Math.abs(trigger.x - card.x)
}).toBeLessThan(1)
await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).fill('long-file-list-59')
expect(await menu.getByRole('menuitem', { name: 'src/long-file-list-59.ts', exact: true }).isVisible()).toBe(true)
await page.keyboard.press('Escape')
expect(await menu.count()).toBe(0)
await page.setViewportSize({ width: 390, height: 700 })
await files.click()
await menu.getByRole('textbox', { name: '筛选文件…', exact: true }).waitFor()
await expect.poll(() => menu.evaluate((node) => {
const rect = node.getBoundingClientRect()
return rect.left >= 12 && rect.right <= 378 && rect.top >= 12 && rect.bottom <= 688
})).toBe(true)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout')
await page.locator('[data-forge-current-branch]').waitFor()
+2 -2
View File
@@ -9,8 +9,8 @@
en: dc8c41fbf9869179
zh: e45976a585cb9c86
/web-ui-style-reference/component-rules:
en: 4ee4a6fd2917306a
zh: d7d68c140a698ab0
en: 8b301ae4862ee8e5
zh: eb65019fb1e26f30
/web-ui-style-reference/input-capabilities:
en: bf2e98bce68743e2
zh: fc58dcbb60bb944a
+2
View File
@@ -26,6 +26,8 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
- Existing and new dropdown, context, submenu, and selection menus use `Menu` or wrap custom content in `MenuSurface`. The material is the theme-owned `--dsw-menu-surface-fill` plus `--dsw-menu-backdrop-filter`; feature and platform CSS must not override either. The ui-theme menu spec rejects unwrapped menu/listbox renderers, material overrides, and token redefinitions. The schedule-owned `TaskMenu` and `ClockPicker` retain their existing containers and material as explicit exceptions ([owner](../packages/client/ui-schedule/README.md)).
- Modal backdrops, including settings and confirmation dialogs, retain their translucent dark mask without blur. The theme sets `--dsw-mask-blur` to `none`; Desktop-owned modal overlays also leave the parent page unfiltered.
- Floating panels preserve horizontal anchor alignment when neither vertical side fits. Try vertical window bounds before a fallback that also clears horizontal anchoring; every candidate reserves the window edge and frame margins.
- Menu background filtering stays on an isolated layer so nested menus and fixed overlays retain their positioning and backdrop. On macOS, `MenuSurface` adds an opaque backing behind page content only within each menu’s bounds, allowing Chromium to blur content over native vibrancy; CSS anchors follow placement and size, and unmounting removes the backing. Overlays without this backing use `--dsw-specific-menu`, whose theme-owned macOS fill is 94% opaque to keep underlying text from bleeding through. Other elevated surfaces using that fill pair it with the same filter on their own isolated background when they contain fixed overlays.
- Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance.
+2
View File
@@ -26,6 +26,8 @@
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
- 存量和新增的下拉菜单、上下文菜单、子菜单及选择菜单使用 `Menu`,自定义内容用 `MenuSurface` 包裹。材质由主题的 `--dsw-menu-surface-fill` 和 `--dsw-menu-backdrop-filter` 组成;功能及平台 CSS 不得覆盖两者。ui-theme 菜单检查拒绝未使用共享容器的 menu/listbox 渲染、材质覆盖和 token 重定义。计划任务自有的 `TaskMenu` 与 `ClockPicker` 是明确例外,保留现有容器和材质([所属包](../packages/client/ui-schedule/README.zh.md))。
- 设置、确认框等模态弹窗保留黑色半透明遮罩,不模糊背景。主题将 `--dsw-mask-blur` 设为 `none`;Desktop 自有模态浮层也不对父页面施加模糊。
- 浮层在上下两侧均放不下时,保留与锚点的横向对齐。先尝试窗口内的纵向范围,再尝试同时清除横向锚定的回退位置;每个候选位置均须保留窗口边距与顶栏间距。
- 菜单背景模糊绘制在独立图层上,保证嵌套菜单和 fixed 浮层的定位及背景采样。macOS 上,`MenuSurface` 仅在各菜单范围内、页面内容后方添加实色底层,使 Chromium 能在原生透明材质上模糊页面内容;CSS 锚点跟随位置及尺寸,卸载时移除底层。没有该底层的浮层使用 `--dsw-specific-menu`,主题在 macOS 上将其设为 94% 不透明度,防止下方文字透出。其他使用该填充的浮起表面也须配套同一模糊;包含 fixed 浮层时,将两者绘制在独立背景层上。
- 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。
@@ -88,7 +88,7 @@
align-self: end;
justify-self: end;
margin: 0;
position-try-fallbacks: --dsh-warning-wide-above, --dsh-warning-below, --dsh-warning-wide-below, --dsh-warning-window;
position-try-fallbacks: --dsh-warning-wide-above, --dsh-warning-below, --dsh-warning-wide-below, --dsh-warning-window, --dsh-warning-wide-window;
z-index: 1100;
box-sizing: border-box;
width: 420px;
@@ -122,6 +122,12 @@
}
@position-try --dsh-warning-window {
top: var(--dsh-warning-window-top);
bottom: 12px;
align-self: safe start;
}
@position-try --dsh-warning-wide-window {
top: var(--dsh-warning-window-top);
bottom: 12px;
right: 12px;
@@ -26,13 +26,17 @@
overflow-y: auto;
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
position-area: top span-right;
position-try-fallbacks: --dsh-stat-window, flip-block, --dsh-stat-viewport;
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
left: max(12px, anchor(left));
right: 12px;
position-try-fallbacks: --dsh-stat-window, --dsh-stat-opposite, --dsh-stat-wide-opposite, --dsh-stat-tall, --dsh-stat-viewport;
align-self: end;
justify-self: start;
height: fit-content;
min-height: 0;
margin: 8px 0;
margin: 0;
}
/* Dialog heading row: the section name left, the section's headline value
@@ -110,10 +114,36 @@
justify-self: safe end;
}
@position-try --dsh-stat-opposite {
top: max(12px, calc(anchor(bottom) + 8px));
bottom: 12px;
align-self: start;
}
@position-try --dsh-stat-wide-opposite {
top: max(12px, calc(anchor(bottom) + 8px));
bottom: 12px;
left: 12px;
right: 12px;
align-self: start;
justify-self: safe end;
}
@position-try --dsh-stat-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
margin: 0;
align-self: safe start;
justify-self: start;
}
@position-try --dsh-stat-viewport {
position-area: none;
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@@ -71,13 +71,17 @@
overflow-y: auto;
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
position-area: top span-right;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-context-window, --dsh-context-viewport;
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
left: max(12px, anchor(left));
right: 12px;
position-try-fallbacks: --dsh-context-window, --dsh-context-opposite, --dsh-context-wide-opposite, --dsh-context-tall, --dsh-context-viewport;
align-self: end;
justify-self: start;
height: fit-content;
min-height: 0;
margin: 8px 0;
margin: 0;
}
.header {
@@ -180,7 +184,33 @@
right: 12px;
margin: 0;
align-self: safe end;
justify-self: safe start;
justify-self: safe end;
}
@position-try --dsh-context-opposite {
top: max(12px, calc(anchor(bottom) + 8px));
bottom: 12px;
align-self: start;
}
@position-try --dsh-context-wide-opposite {
top: max(12px, calc(anchor(bottom) + 8px));
bottom: 12px;
left: 12px;
right: 12px;
align-self: start;
justify-self: safe end;
}
@position-try --dsh-context-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
margin: 0;
align-self: safe start;
justify-self: start;
}
@position-try --dsh-context-viewport {
@@ -188,5 +218,5 @@
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@@ -95,13 +95,17 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
position-area: bottom span-right;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-jobs-window, --dsh-jobs-viewport;
position-area: none;
top: max(12px, calc(anchor(bottom) + 5px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
position-try-fallbacks: --dsh-jobs-window, --dsh-jobs-opposite, --dsh-jobs-wide-opposite, --dsh-jobs-tall, --dsh-jobs-viewport;
align-self: start;
justify-self: start;
height: fit-content;
min-height: 0;
margin: 5px 0;
margin: 0;
}
.menuBody {
@@ -460,7 +464,33 @@
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@position-try --dsh-jobs-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 5px));
align-self: end;
}
@position-try --dsh-jobs-wide-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 5px));
left: 12px;
right: 12px;
align-self: end;
justify-self: safe end;
}
@position-try --dsh-jobs-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
margin: 0;
align-self: safe start;
justify-self: start;
}
@position-try --dsh-jobs-viewport {
@@ -468,5 +498,5 @@
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@@ -984,13 +984,17 @@
overflow-y: auto;
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
position-area: bottom span-left;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-registry-window, --dsh-registry-viewport;
position-area: none;
top: max(12px, calc(anchor(bottom) + 6px));
bottom: 12px;
left: 12px;
right: max(12px, anchor(right));
position-try-fallbacks: --dsh-registry-window, --dsh-registry-opposite, --dsh-registry-wide-opposite, --dsh-registry-tall, --dsh-registry-viewport;
align-self: start;
justify-self: end;
height: fit-content;
min-height: 0;
margin: 6px 0;
margin: 0;
}
.registryOption {
@@ -2261,7 +2265,33 @@ button.versionSummary:focus-visible {
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe end;
justify-self: safe start;
}
@position-try --dsh-registry-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 6px));
align-self: end;
}
@position-try --dsh-registry-wide-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 6px));
left: 12px;
right: 12px;
align-self: end;
justify-self: safe start;
}
@position-try --dsh-registry-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: 12px;
right: max(12px, anchor(right));
margin: 0;
align-self: safe start;
justify-self: end;
}
@position-try --dsh-registry-viewport {
@@ -30,8 +30,8 @@
en: 3fd7072b662eed0a
zh: 88c6c299c0a6f8cd
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
en: 6fbd9df222b41e09
zh: 3bdb4a6b9b6bff9e
en: a53935420b1a6c0c
zh: 5248f2088059e551
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: a39e67d79e171221
zh: 593e260c073336f9
+1 -1
View File
@@ -144,7 +144,7 @@ The atoms cannot read the application locale, so every piece of user-facing copy
Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted.
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Tall portal menus retain the trigger’s horizontal alignment while using the window’s vertical scrolling space; horizontal clamping applies only when the card cannot fit beside that edge. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
`Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur.
+1 -1
View File
@@ -144,7 +144,7 @@ kind: "package-library"
锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。较高的 portal 菜单使用窗口内的纵向滚动空间时,仍保持与触发元素的横向对齐;只有卡片在该侧放不下时才调整横向位置。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
`Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。
@@ -51,7 +51,7 @@
justify-self: start;
align-self: start;
margin: 0;
position-try-fallbacks: --dsh-menu-wide-below, --dsh-menu-above, --dsh-menu-wide-above, --dsh-menu-window;
position-try-fallbacks: --dsh-menu-wide-below, --dsh-menu-above, --dsh-menu-wide-above, --dsh-menu-window, --dsh-menu-wide-window;
width: max-content;
height: fit-content;
z-index: 1100;
@@ -60,14 +60,14 @@
top: var(--dsh-menu-window-top);
bottom: max(12px, calc(anchor(top) + 4px));
align-self: end;
position-try-fallbacks: --dsh-menu-wide-above, --dsh-menu-below, --dsh-menu-wide-below, --dsh-menu-window;
position-try-fallbacks: --dsh-menu-wide-above, --dsh-menu-below, --dsh-menu-wide-below, --dsh-menu-window, --dsh-menu-wide-window;
}
.portal[data-anchor-side='right'] {
top: max(var(--dsh-menu-window-top), anchor(top));
bottom: 12px;
left: max(12px, calc(anchor(right) + 4px));
right: 12px;
position-try-fallbacks: --dsh-menu-left, --dsh-menu-wide-below, --dsh-menu-window;
position-try-fallbacks: --dsh-menu-left, --dsh-menu-wide-below, --dsh-menu-window, --dsh-menu-wide-window;
}
.portal[data-anchor-align='end']:not([data-anchor-side='right']) {
left: 12px;
@@ -109,13 +109,20 @@
right: max(12px, calc(anchor(left) + 4px));
justify-self: end;
}
/* A tall card keeps its trigger alignment before horizontal clamping is needed. */
@position-try --dsh-menu-window {
top: var(--dsh-menu-window-top);
bottom: 12px;
align-self: safe start;
}
@position-try --dsh-menu-wide-window {
top: var(--dsh-menu-window-top);
left: 12px;
right: 12px;
bottom: 12px;
align-self: safe start;
justify-self: safe start;
justify-self: safe var(--dsh-menu-wide-align, end);
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
@@ -3,7 +3,7 @@
.panel {
position: fixed;
position-area: none;
position-try-fallbacks: --dsh-schedule-picker-above, --dsh-schedule-picker-end, --dsh-schedule-picker-above-end, --dsh-schedule-picker-window;
position-try-fallbacks: --dsh-schedule-picker-above, --dsh-schedule-picker-end, --dsh-schedule-picker-above-end, --dsh-schedule-picker-window, --dsh-schedule-picker-viewport;
top: max(12px, calc(anchor(bottom) + 4px));
bottom: 12px;
left: max(12px, anchor(left));
@@ -55,6 +55,13 @@
}
@position-try --dsh-schedule-picker-window {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
margin: 0;
align-self: safe end;
}
@position-try --dsh-schedule-picker-viewport {
position-area: none;
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
@@ -41,7 +41,7 @@
.menu {
position: fixed;
position-area: none;
position-try-fallbacks: --dsh-schedule-catalog-wide-below, --dsh-schedule-catalog-above, --dsh-schedule-catalog-wide-above, --dsh-schedule-catalog-window;
position-try-fallbacks: --dsh-schedule-catalog-wide-below, --dsh-schedule-catalog-above, --dsh-schedule-catalog-wide-above, --dsh-schedule-catalog-window, --dsh-schedule-catalog-viewport;
top: max(16px, calc(anchor(bottom) + 5px));
bottom: 16px;
left: max(16px, anchor(left));
@@ -236,6 +236,13 @@
}
@position-try --dsh-schedule-catalog-window {
top: max(16px, var(--dsh-frame-overlay-top, 16px));
bottom: 16px;
margin: 0;
align-self: safe end;
}
@position-try --dsh-schedule-catalog-viewport {
position-area: none;
inset: max(16px, var(--dsh-frame-overlay-top, 16px)) 16px 16px;
margin: 0;
@@ -39,7 +39,7 @@
.portal {
position: fixed;
position-area: none;
position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-end, --dsh-schedule-task-above-end, --dsh-schedule-task-window;
position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-end, --dsh-schedule-task-above-end, --dsh-schedule-task-window, --dsh-schedule-task-viewport;
top: max(12px, calc(anchor(bottom) + 4px));
bottom: 12px;
left: max(12px, anchor(left));
@@ -61,7 +61,7 @@
left: 12px;
right: max(12px, anchor(right));
justify-self: end;
position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-start, --dsh-schedule-task-above-start, --dsh-schedule-task-window;
position-try-fallbacks: --dsh-schedule-task-above, --dsh-schedule-task-start, --dsh-schedule-task-above-start, --dsh-schedule-task-window, --dsh-schedule-task-viewport;
}
/* Tall lists scroll inside the window's layout bounds. */
@@ -243,6 +243,13 @@
}
@position-try --dsh-schedule-task-window {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
margin: 0;
align-self: safe end;
}
@position-try --dsh-schedule-task-viewport {
position-area: none;
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
@@ -41,13 +41,17 @@
font-size: 13px;
line-height: 1.6;
max-width: calc(100vw - 24px);
position-area: bottom span-right;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-fonts-window, --dsh-fonts-viewport;
position-area: none;
top: max(12px, calc(anchor(bottom) + 8px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
position-try-fallbacks: --dsh-fonts-window, --dsh-fonts-opposite, --dsh-fonts-wide-opposite, --dsh-fonts-tall, --dsh-fonts-viewport;
align-self: start;
justify-self: start;
height: fit-content;
min-height: 0;
margin: 8px 0;
margin: 0;
}
.panelHeader {
@@ -94,7 +98,33 @@
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@position-try --dsh-fonts-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
align-self: end;
}
@position-try --dsh-fonts-wide-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 8px));
left: 12px;
right: 12px;
align-self: end;
justify-self: safe end;
}
@position-try --dsh-fonts-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
margin: 0;
align-self: safe start;
justify-self: start;
}
@position-try --dsh-fonts-viewport {
@@ -102,5 +132,5 @@
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@@ -530,7 +530,8 @@
}
@position-try --dsh-subagents-window {
inset: max(16px, var(--dsh-frame-overlay-top, 16px)) 16px 16px;
top: max(16px, var(--dsh-frame-overlay-top, 16px));
bottom: 16px;
align-self: safe start;
justify-self: safe start;
}
@@ -54,13 +54,17 @@
padding: 10px;
color: var(--dsw-alias-label-primary);
font-size: 11px;
position-area: bottom span-right;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline, --dsh-types-window, --dsh-types-viewport;
position-area: none;
top: max(12px, calc(anchor(bottom) + 4px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
position-try-fallbacks: --dsh-types-window, --dsh-types-opposite, --dsh-types-wide-opposite, --dsh-types-tall, --dsh-types-viewport;
align-self: start;
justify-self: start;
height: fit-content;
min-height: 0;
margin: 4px 0;
margin: 0;
}
.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); }
@@ -139,7 +143,33 @@
right: 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}
@position-try --dsh-types-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 4px));
align-self: end;
}
@position-try --dsh-types-wide-opposite {
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: max(12px, calc(anchor(top) + 4px));
left: 12px;
right: 12px;
align-self: end;
justify-self: safe end;
}
@position-try --dsh-types-tall {
position-area: none;
top: max(12px, var(--dsh-frame-overlay-top, 12px));
bottom: 12px;
left: max(12px, anchor(left));
right: 12px;
margin: 0;
align-self: safe start;
justify-self: start;
}
@position-try --dsh-types-viewport {
@@ -147,5 +177,5 @@
inset: max(12px, var(--dsh-frame-overlay-top, 12px)) 12px 12px;
margin: 0;
align-self: safe start;
justify-self: safe start;
justify-self: safe end;
}