diff --git a/apps/web/tests/anchored-panels.e2e.ts b/apps/web/tests/anchored-panels.e2e.ts new file mode 100644 index 0000000000..5770d228c7 --- /dev/null +++ b/apps/web/tests/anchored-panels.e2e.ts @@ -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(` +
+
Long list
+
`) + 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 }) + } + }) diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 2c5487b1d1..31b32ac9af 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -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() diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index c5a55abd0d..c02bbea299 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -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 diff --git a/docs/web-styling.md b/docs/web-styling.md index faaea0ec0c..1b8a631f79 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -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. diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 7a0387c23a..4c56e73820 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -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 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。 diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index 02d6827c67..6661ee4555 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -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; diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css index de25ad89fe..2281494877 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -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; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index b669e90369..494b1997de 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -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; } diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index 1f44bb7eb8..6b72ce3241 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -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; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 0e7e89f3d1..1b6e2b58e3 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -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 { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fb324c1911..a73e6e2d85 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 46513d6f6f..46a29dfee6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d28553b3fb..f3d36cb06d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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))。 模态遮罩保留黑色半透明填充,不模糊背景。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 2aca0337a9..31fc8a1939 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -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). */ diff --git a/packages/client/ui-schedule/src/client/PickerPopover.module.css b/packages/client/ui-schedule/src/client/PickerPopover.module.css index 0d21c31eba..cc4d7eff19 100644 --- a/packages/client/ui-schedule/src/client/PickerPopover.module.css +++ b/packages/client/ui-schedule/src/client/PickerPopover.module.css @@ -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; diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index da64bb2091..18dec05af1 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -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; diff --git a/packages/client/ui-schedule/src/client/TaskMenu.module.css b/packages/client/ui-schedule/src/client/TaskMenu.module.css index f117fa4310..840ad3f460 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.module.css +++ b/packages/client/ui-schedule/src/client/TaskMenu.module.css @@ -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; diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css index 2ef0ab0b67..9c52e162ff 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -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; } diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 5370f93d85..d3f58ad409 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -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; } diff --git a/packages/experimental/session-inspector/src/client/views/inspector.module.css b/packages/experimental/session-inspector/src/client/views/inspector.module.css index 2cce7560f9..65b28b5372 100644 --- a/packages/experimental/session-inspector/src/client/views/inspector.module.css +++ b/packages/experimental/session-inspector/src/client/views/inspector.module.css @@ -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; }