diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml index 530553e7cc..2d723e4d15 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md -2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b -2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9 +2026-09-09-sidebar-and-preview-interaction-polish.md: ad99e37984916fab638670bb3fb47dc2ade2d0bf +2026-09-09-sidebar-and-preview-interaction-polish.zh.md: 037ab2d147a0afad35902dfe57551b148c39ed90 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md index df0e768aa1..ad99e37984 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md @@ -18,7 +18,7 @@ Five small interaction defects around the right Sidebar and the document preview **Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list. -**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background. +**The code preview separates its banner from scrolling source and drops the card fill.** The shared CodeBlock wraps its rendered source in a stable `data-code-block-content` node that defaults to `display: contents`, so existing consumers keep their layout. [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) materializes that node as a full-height inner scrollport below the banner; the document owner caches that node for position restoration, paging, and line navigation. The shared CodeBlock's fill is routed through `--dsl-code-block-background` (default unchanged, so chat keeps its gray card), while the preview sets it to `transparent` so code sits on the pane's own background. ## Alternatives considered @@ -28,8 +28,8 @@ Five small interaction defects around the right Sidebar and the document preview **A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see. -**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller. +**Keep code in the shared document scroller.** A child banner cannot cover its parent's native scrollbar. Giving the stable source wrapper both scroll axes keeps the scrollbar at the visible viewport edge below the adjacent banner, rather than at the end of a long code block. ## Consequences -`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules. +`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable keeps chat's default gray card while the preview uses the pane background, and `data-code-block-content` lets an owner materialize a dedicated source viewport without changing other CodeBlock layouts. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; document-preview specs cover inner scrolling and line navigation; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules. diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md index d3f8b00fd8..037ab2d147 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md @@ -18,7 +18,7 @@ Status: implemented **焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。 -**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上。 +**代码预览把复制条与滚动源码分开,并去掉卡片填充。**共享 CodeBlock 用稳定的 `data-code-block-content` 节点包裹渲染后的源码;该节点默认使用 `display: contents`,因此既有消费者保持原布局。[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 将该节点实体化为复制条下方占满剩余高度的内部滚动区;文档 owner 缓存这个节点,用于位置恢复、分页和行跳转。共享 CodeBlock 的填充通过 `--dsl-code-block-background` 设置(默认值不变,会话保持灰色卡片),预览将它设为 `transparent`,代码因此直接使用分栏背景。 ## Alternatives considered @@ -28,8 +28,8 @@ Status: implemented **菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。 -**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里。 +**让代码继续使用共享文档滚动区。**子级复制条无法覆盖父级的原生滚动条。让稳定的源码包装节点同时承载两个滚动轴,滚动条会停在复制条下方的可见视口边缘,而不是长代码块的末端。 ## Consequences -`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。 +`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量保留 Chat 默认的灰色卡片,Preview 则使用分栏背景;`data-code-block-content` 允许 owner 实体化专用源码视口,而不改变其它 CodeBlock 布局。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;文档预览规格覆盖内部滚动与行跳转;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 41af1afa6b..8715b98d3d 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -240,6 +240,18 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const iframe = preview.locator('[data-html-preview]') await iframe.waitFor({ timeout: 15_000 }) expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts') + expect(await iframe.evaluate((node) => { + const host = node.closest('[data-textpreview-body]') + if (!(host instanceof HTMLElement)) throw new Error('HTML preview body is unavailable') + const outer = host.getBoundingClientRect() + const frame = node.getBoundingClientRect() + return { + top: Math.round(frame.top - outer.top), + right: Math.round(outer.right - frame.right), + bottom: Math.round(outer.bottom - frame.bottom), + left: Math.round(frame.left - outer.left), + } + })).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }) const html = page.frameLocator('[data-html-preview]') await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK') @@ -379,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const highlightedLines = preview.locator('.shiki .line') await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES) const codeBlock = preview.locator('.md-code-block') + const codeScrollport = preview.locator('[data-code-block-content]') expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true') await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content)) .not.toMatch(/^(?:none|normal)$/u) @@ -397,33 +410,31 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const prefix = await highlightedLines.allTextContents() expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES)) await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) - await scrollForNextPage(body) + await scrollForNextPage(codeScrollport) await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length) const completed = await highlightedLines.allTextContents() expect(completed).toEqual(codeLines) await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0) - const scrollTop = await body.evaluate((node) => { + const scrollTop = await codeScrollport.evaluate((node) => { const target = Math.floor((node.scrollHeight - node.clientHeight) / 2) if (target <= 0) throw new Error('code fixture does not overflow the document body') node.scrollTop = target return target }) - await expect.poll(() => body.evaluate((node) => { - const banner = node.querySelector('.md-code-block')?.firstElementChild + await expect.poll(() => codeScrollport.evaluate((node) => { + const codeBlock = node.parentElement + const banner = codeBlock?.firstElementChild const firstLine = node.querySelector('.shiki .line') if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line') const bounds = node.getBoundingClientRect() const clipTop = bounds.top + node.clientTop const bannerBounds = banner.getBoundingClientRect() - const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1) return { scrollTop: node.scrollTop, - position: getComputedStyle(banner).position, - topGap: bannerBounds.top - clipTop, + scrollportBelowBanner: Math.abs(bannerBounds.bottom - bounds.top) < 1, firstLineAbove: firstLine.getBoundingClientRect().top < clipTop, - topCoveredByBanner: hit !== null && banner.contains(hit), } - })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true }) + })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true }) await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) await page.evaluate(() => navigator.clipboard.writeText('')) await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click() diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index d7e58f30ee..92fa2f6fde 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: 41100530b77f9776c768010b53ecbc6ad67b5902 -README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f +README.md: f41668ad2eb6c16e13deb5fcc554c8717811a468 +README.zh.md: f92828b99ef5678458614261304c05d382bc34f4 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 41100530b7..f41668ad2e 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -52,7 +52,7 @@ Everything host-specific arrives through props: `DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. -`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. +`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 237ce5f0ae..f92828b99e 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" `DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 -`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 +`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 972ec3b951..c86f6cac54 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -509,6 +509,7 @@ .dockScrim { position: absolute; inset: 0; + z-index: 10; pointer-events: none; /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2` is a lighter bluish step than the column's `bg-base`, which tinted the @@ -520,6 +521,7 @@ .dockHint { position: absolute; + z-index: 10; display: flex; box-sizing: border-box; padding: 8px; diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index a6e25ba39f..96a9b62ec1 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -69,6 +69,12 @@ font: inherit; } +/* Consumers may turn the stable content node into a viewport without changing + the default CodeBlock layout. */ +.content { + display: contents; +} + .block :where(pre) { font: var(--dsl-code-block-content-font); padding: 16px; diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 02cdbe683a..dd9bee4d5f 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -178,7 +178,6 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`, } as CSSProperties}>
- {/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
{lang ?? ''}
@@ -188,7 +187,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
- {body} +
{body}
) } diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt index 66dd41f69e..5b41c005f8 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt @@ -7,22 +7,23 @@