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 new file mode 100644 index 0000000000..a6a4fb8c4a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 63205125fc5833f643217d38fabc9cb897ab1f9c +2026-09-09-sidebar-and-preview-interaction-polish.zh.md: bcfcbeb61cae71b45539393df20a9a6bfc00e3f0 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 new file mode 100644 index 0000000000..63205125fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Sidebar and preview interaction polish + +Status: implemented + +English | [中文](2026-09-09-sidebar-and-preview-interaction-polish.zh.md) + +## Problem + +Five small interaction defects around the right Sidebar and the document preview. Every session's surface was born with a seeded default page, so a collapsed column the user never opened already held a page, and the first open into a fresh surface showed the seed beside the content it opened. Page tabs deduplicated surface-wide: opening a page whose tab sat in the other pane pulled focus across panes instead of opening it where asked. Dragging a pane's sole tab onto its own edge did nothing, though the user plainly asked for a split. A dropdown over the HTML preview did not dismiss on a click inside the sandboxed iframe, because that pointerdown never reaches the parent document. And the code preview's copy banner and card background scrolled away under horizontal scrolling, while the code kept the chat card's gray fill instead of sitting on the pane's own background. + +## Decision + +**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; only the seeding moment moved. + +**Page uniqueness is pane-scoped.** The guide-only merge generalized to every page kind (`pageKind`/`panePage`): opening a page focuses an existing tab only inside the pane the open targets, and a page dragged, dropped, or docked into a pane already showing that kind's page merges into the pane's own. Resource tabs keep the kit's surface-wide reveal. + +**A sole tab splits its own pane when a factory backfills it.** `planDropTab` in [planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) takes an optional `TabFactory`; with one, the previously refused self-edge release splits, the factory's tab backfills the vacated pane before the move so the dragged tab ends focused. Without a factory the release still changes nothing. + +**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. + +## Alternatives considered + +**Keep seeding at surface creation.** A collapsed column held a page nobody asked for, and the seed took slot 0 of every fresh surface ahead of the first real open. + +**Keep surface-wide page dedupe.** Focus jumped to the other pane on an explicit "open here", the exact complaint that started the change. + +**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. + +## 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. 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 new file mode 100644 index 0000000000..bcfcbeb61c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 侧边栏与文件预览交互打磨 + +Status: implemented + +[English](2026-09-09-sidebar-and-preview-interaction-polish.md) | 中文 + +## Problem + +右侧边栏与文档预览周边的五个小交互缺陷。每个会话的停靠面出生时就播种默认页,于是用户从未打开过的折叠列里已经躺着一个页面,向全新停靠面的第一次打开会让种子页出现在所开内容旁边。页 tab 在整个停靠面范围去重:打开一个页时,若它的 tab 在另一格里,焦点会被拽到那一格,而不是在被要求的格里打开。把一个格的唯一 tab 拖到本格边缘什么都不发生,尽管用户明明要的是分栏。HTML 预览上方的下拉菜单在点击沙箱 iframe 内部时不消失,因为那次 pointerdown 根本到不了父文档。代码预览的复制条和卡片背景会被横向滚动甩开,而且代码保留了会话卡片的灰色填充,而不是坐在分栏自身的背景上。 + +## Decision + +**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;改变的只是播种时机。 + +**页唯一性以格为界。**只对引导页的合并规则推广到每种页 kind(`pageKind`/`panePage`):打开一个页只在这次打开的目标格内聚焦既有 tab;把页拖入、放入或收回到已展示该 kind 页的格会并入该格自己的 tab。资源 tab 保留套件的全停靠面聚焦。 + +**有工厂回填时,唯一 tab 可对本格分栏。**[planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) 的 `planDropTab` 接受可选 `TabFactory`:带工厂时,先前被拒绝的本格边缘释放会分栏,工厂的 tab 先于移动回填腾出的格,因此被拖的 tab 最终保持聚焦。不带工厂时该释放仍不改变任何东西。 + +**焦点进入 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`,代码于是坐在分栏自身的背景上。 + +## Alternatives considered + +**保留出生即播种。**折叠列里躺着没人要的页面,且种子占据每个新停靠面的 0 号位,排在第一次真实打开之前。 + +**保留全停靠面的页去重。**显式"在这里打开"时焦点跳到另一格——正是引发这次改动的抱怨。 + +**菜单用裸的 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 重述了这些规则。 diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f2fbcc914d..d7e58f30ee 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: 396253d771196380d04da13cfc0843584b37fbc2 -README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507 +README.md: 41100530b77f9776c768010b53ecbc6ad67b5902 +README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 396253d771..41100530b7 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -34,7 +34,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `applyOp(state, op)` returns the next state **and the operations that undo it**. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone. - Every operation carries the ids it creates, so `replay(initial, ops)` reproduces the same tree. The engine reads no clock and no random source. - `Sequencer` keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch. -- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. +- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index ec2d5077ef..237ce5f0ae 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" - `applyOp(state, op)` 返回下一状态**以及撤销它的操作**。逆操作在操作执行时捕获,因为到撤销时操作前的状态已经不存在了。 - 每个操作都携带它创建的 id,因此 `replay(initial, ops)` 能复现同一棵树。引擎不读时钟,也不读随机源。 - `Sequencer` 维护一条线性历史,每个意图一条记录:一次手势或命令产生的操作一起后退、一起前进,连续的纯焦点记录作为一步,后退后的新记录会丢弃前进分支。 -- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 +- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 diff --git a/packages/client/ui-dockkit/src/engine/planner.ts b/packages/client/ui-dockkit/src/engine/planner.ts index 1c61ab94c2..1657537144 100644 --- a/packages/client/ui-dockkit/src/engine/planner.ts +++ b/packages/client/ui-dockkit/src/engine/planner.ts @@ -255,13 +255,16 @@ export function planPlaceTab( /** * Resolve a tab release on a pane body: the centre moves the tab in, an edge * splits the pane and seats the tab in the new half. A pane's only tab released - * on that pane changes nothing in either zone: the split would empty the pane - * and seat the tab beside where it already was. + * on that pane's centre changes nothing; released on its edge it splits, and + * the factory's tab backfills the pane the drag would otherwise empty — without + * a factory that release also changes nothing, since the split would empty the + * pane and seat the tab beside where it already was. * @param state - current layout. * @param mint - id source for a pane an edge release creates. * @param tabId - the dragged tab. * @param targetPaneId - pane under the pointer. * @param zone - dock region the pointer released in. + * @param makeTab - builds the tab that backfills a pane its only tab splits away from. * @returns the operations, or none when the release changes nothing. */ export function planDropTab( @@ -270,6 +273,7 @@ export function planDropTab( tabId: TabId, targetPaneId: PaneId, zone: DockZone, + makeTab?: TabFactory, ): readonly LayoutOp[] { const source = findTabPane(state, tabId) const target = getPane(state, targetPaneId) @@ -281,20 +285,25 @@ export function planDropTab( return [tabInto(source, tabId, targetPaneId, target.tabs.length)] } - if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING + const vacates = source.id === targetPaneId && source.tabs.length === 1 + if (vacates && makeTab === undefined) return NOTHING if (!canSplit(state)) return NOTHING const newPaneId = mint('pane') - return [ - { - type: 'split', - paneId: targetPaneId, - axis: split.axis, - direction: split.direction, - newPaneId, - newSplitId: mint('split'), - }, - tabInto(source, tabId, newPaneId, 0), - ] + const ops: LayoutOp[] = [{ + type: 'split', + paneId: targetPaneId, + axis: split.axis, + direction: split.direction, + newPaneId, + newSplitId: mint('split'), + }] + // The backfill seats before the move so the moved tab ends focused, as any + // other drop leaves it. + if (vacates && makeTab !== undefined) { + ops.push({ type: 'openTab', paneId: targetPaneId, tab: makeTab(mint('tab')), index: source.tabs.length }) + } + ops.push(tabInto(source, tabId, newPaneId, 0)) + return ops } /** diff --git a/packages/client/ui-dockkit/tests/planner.client.spec.ts b/packages/client/ui-dockkit/tests/planner.client.spec.ts index 16d7fcb339..fa483964dc 100644 --- a/packages/client/ui-dockkit/tests/planner.client.spec.ts +++ b/packages/client/ui-dockkit/tests/planner.client.spec.ts @@ -133,7 +133,7 @@ describe('planPlaceTab', () => { }) describe('planDropTab on the tab\'s own pane', () => { - it('plans nothing for a pane\'s only tab released on any of its own edges', () => { + it('plans nothing for a pane\'s only tab released on any of its own edges without a factory', () => { const { state, minter } = seededState() const mint = minter.next const tabId = getPane(state, state.rootId).tabs[0] @@ -143,12 +143,33 @@ describe('planDropTab on the tab\'s own pane', () => { } }) - it('splits the pane when it keeps another tab', () => { + it('splits on a sole tab\'s own edge when a factory backfills the pane it vacates', () => { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const tabId = getPane(state, paneId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + const ops = planDropTab(state, mint, tabId, paneId, 'right', seedTab) + expect(ops.map(op => op.type)).toEqual(['split', 'openTab', 'moveTab']) + const split = applyAll(state, ops) + const [home, destination] = dockPaneIds(split) + if (home === undefined || destination === undefined) throw new Error('expected two panes') + expect(getPane(split, home).tabs).toHaveLength(1) + expect(getPane(split, home).tabs[0]).not.toBe(tabId) + expect(getPane(split, destination).tabs).toEqual([tabId]) + // The backfill seats first, so the moved tab ends focused. + expect(split.activePaneId).toBe(destination) + }) + + it('splits without a backfill when the pane keeps another tab, factory or not', () => { const { state, minter } = seededState() const mint = minter.next const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const two = applyAll(state, opened.ops) - const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right') + const target = getPane(two, two.rootId).id + expect(planDropTab(two, mint, opened.tabId, target, 'right') + .map(op => op.type)).toEqual(['split', 'moveTab']) + const ops = planDropTab(two, mint, opened.tabId, target, 'right', seedTab) expect(ops.map(op => op.type)).toEqual(['split', 'moveTab']) const split = applyAll(two, ops) expect(dockPaneIds(split)).toHaveLength(2) diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 3130086777..e33be86fbc 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -54,7 +54,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.selectedId - row shown as selected. * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). - * @param props.onClose - invoked on outside click or Escape. + * @param props.onClose - invoked on outside click, Escape, or a window blur + * that moved focus into an iframe (the only signal a pointerdown inside a + * cross-origin iframe leaves). * @param props.align - list alignment against the anchor (default 'start'). * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @param props.portal - render the list into document.body, fixed-positioned @@ -174,11 +176,20 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + // A pointerdown inside a cross-origin iframe (a sandboxed HTML preview) + // never reaches this document; the focus move it causes blurs the window + // instead. Only that case closes: an app or tab switch leaves the + // document's focus where it was, so activeElement is not an iframe. + const onWindowBlur = () => { + if (document.activeElement instanceof HTMLIFrameElement) onClose() + } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onWindowBlur) return () => { document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onWindowBlur) } }, [open, onClose]) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 5f726e8474..a6e25ba39f 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -8,11 +8,12 @@ with the markdown 0.875 ladder without shrinking every other xs-13 use. */ --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family); --dsl-code-block-content-font: var(--dsw-font-markdown-code-block); + --dsl-code-block-background: var(--dsw-alias-markdown-code-block); position: relative; margin: 16px 0; color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-markdown-code-block); + background: var(--dsl-code-block-background); border-radius: var(--dsl-code-block-border-radius); } @@ -75,7 +76,7 @@ overflow-x: auto; white-space: pre-wrap; word-break: break-all; - background: var(--dsw-alias-markdown-code-block); + background: var(--dsl-code-block-background); /* Bottom radii live on
: overflow:hidden on .block would kill the
      sticky banner, and this opaque fill otherwise squares off the wrapper. */
   border-bottom-left-radius: var(--dsl-code-block-border-radius);
@@ -84,7 +85,7 @@
 
 /* Shiki inlines its theme background var; route it to the repo token. */
 .block :where(pre.shiki) {
-  background: var(--dsw-alias-markdown-code-block) !important;
+  background: var(--dsl-code-block-background) !important;
 }
 
 .block :where(pre) code {
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index fff6a00a45..02cdbe683a 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -178,7 +178,8 @@ 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 ?? ''}