Prechádzať zdrojové kódy

feat(dockkit): refine strip and chip presentation, close policy, and room reading

Chip capsules with scroll fades and live titles, drop-zone hint cards, the
canCloseTab control policy with the quiet lone chip and self-dismissing empty
menu, and a room reading that stays independent of hidden split controls.
Yif 2 týždňov pred
rodič
commit
838dbbe0d0
23 zmenil súbory, kde vykonal 948 pridanie a 223 odobranie
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml
  2. 25 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
  3. 25 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md
  4. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  5. 6 7
      packages/client/ui-dockkit/README.md
  6. 8 10
      packages/client/ui-dockkit/README.zh.md
  7. 1 0
      packages/client/ui-dockkit/package.json
  8. 14 8
      packages/client/ui-dockkit/src/components/DockSurface.tsx
  9. 28 21
      packages/client/ui-dockkit/src/components/FloatLayer.tsx
  10. 20 12
      packages/client/ui-dockkit/src/components/TabMenu.tsx
  11. 27 12
      packages/client/ui-dockkit/src/components/TabPanel.tsx
  12. 37 0
      packages/client/ui-dockkit/src/components/TabTitle.tsx
  13. 394 99
      packages/client/ui-dockkit/src/components/dockkit.module.css
  14. 21 1
      packages/client/ui-dockkit/src/components/measure.ts
  15. 3 3
      packages/client/ui-dockkit/src/components/render.ts
  16. 2 0
      packages/client/ui-dockkit/src/contract/adapter.ts
  17. 23 10
      packages/client/ui-dockkit/src/engine/geometry.ts
  18. 280 19
      packages/client/ui-dockkit/tests/components.client.spec.tsx
  19. 1 0
      packages/client/ui-dockkit/tests/fixtures.client.ts
  20. 13 13
      packages/client/ui-dockkit/tests/geometry.client.spec.ts
  21. 6 6
      packages/client/ui-dockkit/tests/measure.client.spec.ts
  22. 3 0
      packages/client/ui-dockkit/tsconfig.json
  23. 3 0
      pnpm-lock.yaml

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.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/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
+2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba
+2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep the room reading independent of hidden split controls
+
+Status: implemented
+
+English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md)
+
+## Problem
+
+The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in.
+
+## Decision
+
+When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before.
+
+## Alternatives considered
+
+**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split.
+
+**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize.
+
+**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading.
+
+## Consequences
+
+The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep the room reading independent of hidden split controls
+
+Status: implemented
+
+[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文
+
+## Problem
+
+dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。
+
+## Decision
+
+当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。
+
+## Alternatives considered
+
+**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。
+
+**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。
+
+**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。
+
+## Consequences
+
+空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。

+ 2 - 2
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: f4f76072bd16cd75bff74715f8f9437348ee036a
-README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199
+README.md: 396253d771196380d04da13cfc0843584b37fbc2
+README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 6 - 7
packages/client/ui-dockkit/README.md


+ 8 - 10
packages/client/ui-dockkit/README.zh.md

@@ -37,7 +37,7 @@ kind: "package-reference"
 - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。
 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
 
-**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
+**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `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——而浮出就是把拖动松手在停靠区之外。
 
 <a id="embedding-it"></a>
 ## 如何嵌入
@@ -47,12 +47,12 @@ kind: "package-reference"
 | 契约 | 承载内容 |
 |---|---|
 | `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 |
-| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
+| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
 | `DockIntents` | 每次手势落定的结果 |
 
-`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
+`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
 
-`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示。
+`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`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 条的某个位置而非末尾。
 
@@ -64,18 +64,16 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
 
 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
-- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
-- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动据为己有;tab 转而收缩、省略、然后被裁切。
-- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
+- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
+- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。
+- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。
 - **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。
-- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。
+- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致;悬停的分隔条改用 caption 文字色,读起来是把手而不是高亮。浮窗不画边框——菜单同款阴影(`--dsw-elevation-prominent`)已勾出它的轮廓——活动浮窗也不加重边框:它本就在最上层、投同样的阴影;围它一圈更深的边框读起来像缺陷。
 
 <a id="build-shape"></a>
 ## 构建形态
 
-静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
-
 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。
 
 <a id="model-experience"></a>

+ 1 - 0
packages/client/ui-dockkit/package.json

@@ -25,6 +25,7 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 14 - 8
packages/client/ui-dockkit/src/components/DockSurface.tsx

@@ -33,8 +33,8 @@ export interface DockSurfaceProps {
    * split control disabled with `labels.splitPaneNarrow` (see README).
    */
   readonly canSplit: boolean
-  /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */
-  readonly hideSplitAtCapacity?: boolean
+  /** Hide a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; defaults to false. */
+  readonly hideSplitWhenBlocked?: boolean
   /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */
   readonly dropZones?: 'edges' | 'horizontal'
   /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */
@@ -45,7 +45,13 @@ export interface DockSurfaceProps {
    * end controls where they are and the chips as the only shrinking part.
    */
   readonly canAddTab?: (paneId: PaneId) => boolean
-  /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */
+  /**
+   * Whether a tab draws its close control and its menu's close item. Called
+   * per rendered chip on every render; omit to keep every tab closable.
+   * `false` removes both routes without moving the chip: the close control
+   * paints over the title's end rather than beside it, so the chip is the same
+   * width either way. The menu still opens and carries the embedder's items.
+   */
   readonly canCloseTab?: (tabId: TabId) => boolean
   readonly intents: DockIntents
   readonly labels: DockLabels
@@ -97,7 +103,7 @@ const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, s
 /** Nothing measured yet: every pane fits until a reading says otherwise. */
 const NO_FITS: ReadonlyMap<PaneId, HalvesFit> = new Map()
 
-/** The default add-control policy: every pane offers one. */
+/** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */
 const ALWAYS = (): boolean => true
 
 /**
@@ -157,7 +163,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
 /** The split tree and the gestures over it. */
 export function DockSurface({
   state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
-  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false,
+  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
 }: DockSurfaceProps): ReactNode {
   const surface = useRef<HTMLDivElement | null>(null)
   const [preview, setPreview] = useState<Preview>(NO_PREVIEW)
@@ -178,10 +184,10 @@ export function DockSurface({
   // wider or narrower). A reading that changed nothing renders nothing.
   const remeasure = useCallback((): void => {
     withSurface((root) => {
-      const next = measurePaneFits(root)
+      const next = measurePaneFits(root, hideSplitWhenBlocked)
       setFits(current => sameFits(current, next) ? current : next)
     })
-  }, [withSurface])
+  }, [withSurface, hideSplitWhenBlocked])
   useLayoutEffect(() => { remeasure() })
   useEffect(() => { onRoom?.(fits) }, [fits, onRoom])
   useEffect(() => {
@@ -267,7 +273,7 @@ export function DockSurface({
       })
     },
     splitBlock,
-    hideSplitAtCapacity,
+    hideSplitWhenBlocked,
     canAddTab: canAddTab ?? ALWAYS,
     canCloseTab: canCloseTab ?? ALWAYS,
     dropTarget: preview.dropTarget,

+ 28 - 21
packages/client/ui-dockkit/src/components/FloatLayer.tsx

@@ -1,7 +1,8 @@
 /**
  * The floating layer: one overlay panel per floating pane, bottom-to-top in the
- * model's z order. A floating pane hosts exactly one tab and renders no tab
- * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip
+ * model's z order. A floating pane hosts exactly one tab; its header is the
+ * strip's row holding that tab's chip, never selectable or closable from the
+ * chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip
  * and corner report through their gesture instead: a press released in place is
  * a click and raises the panel; a drag records the move or resize, and that
  * operation raises the panel itself, so one gesture is one intent. Raising a
@@ -14,12 +15,15 @@
  */
 import { useState } from 'react'
 import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
+import clsx from 'clsx'
+import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
-import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
+import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts'
 import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
 import { movedRect, resizedRect } from '../engine/geometry.ts'
 import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
 import { useGesture } from './pointer.ts'
+import { TabTitle } from './TabTitle.tsx'
 import css from './dockkit.module.css'
 
 /** The layout whose `floats` this layer draws. */
@@ -28,8 +32,6 @@ export interface FloatLayerProps {
   readonly intents: DockIntents
   readonly labels: DockLabels
   readonly renderTab: TabRenderer
-  /** Whether a floating tab offers its close control; defaults to true. Called per tab on every render. */
-  readonly canCloseTab?: (tabId: TabId) => boolean
   /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */
   readonly renderTabTitle?: TabRenderer
 }
@@ -62,7 +64,7 @@ function raised(state: LayoutState, paneId: PaneId): boolean {
 }
 
 /** Every floating panel, in z order. */
-export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
+export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode {
   const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined)
   const begin = useGesture(() => { setPreview(undefined) })
 
@@ -114,22 +116,27 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
             onPointerDown={() => { raise(paneId) }}
           >
             <header
-              className={css.floatHeader}
+              className={clsx(css.tabStrip, css.floatHeader)}
               data-dockkit-float-grip={paneId}
               onPointerDown={(event) => { drag('move', paneId, event) }}
             >
-              <span className={css.floatTitle} data-dockkit-float-title>{renderTabTitle?.(tab) ?? tab.title}</span>
-              <button
-                type="button"
-                className={css.iconButton}
-                aria-label={labels.dockFloat}
-                data-dockkit-float-dock={paneId}
-                onPointerDown={(event) => { event.stopPropagation() }}
-                onClick={() => { intents.unfloatPane(paneId) }}
-              >
-                ⇤
-              </button>
-              {(canCloseTab?.(tab.id) ?? true) && (
+              <div className={clsx(css.tab, css.floatTitle)} data-dockkit-float-title>
+                <TabTitle>{renderTabTitle?.(tab) ?? tab.title}</TabTitle>
+              </div>
+              <div className={css.stripFill} />
+              <Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
+                <button
+                  type="button"
+                  className={css.iconButton}
+                  aria-label={labels.dockFloat}
+                  data-dockkit-float-dock={paneId}
+                  onPointerDown={(event) => { event.stopPropagation() }}
+                  onClick={() => { intents.unfloatPane(paneId) }}
+                >
+                  <IconPanelLeftOutline16 className={css.dockGlyph} />
+                </button>
+              </Tooltip>
+              <Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
                 <button
                   type="button"
                   className={css.iconButton}
@@ -138,9 +145,9 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
                   onPointerDown={(event) => { event.stopPropagation() }}
                   onClick={() => { intents.closeTab(tab.id) }}
                 >
-                  ✕
+                  <IconCloseOutline16 />
                 </button>
-              )}
+              </Tooltip>
             </header>
             <div className={css.floatBody}>{renderTab(tab)}</div>
             <div

+ 20 - 12
packages/client/ui-dockkit/src/components/TabMenu.tsx

@@ -2,7 +2,9 @@
  * The per-tab context menu, opened by a secondary press on the chip. It carries
  * the close gesture and whatever the embedder appends; the copy and float
  * gestures have no menu item — copying is an embedder API, floating is a drag
- * released clear of the surface. Presentational — it renders what its props
+ * released clear of the surface. A menu that would hold no item at all
+ * dismisses itself before the first paint, so a secondary press on a chip with
+ * nothing to offer shows nothing. Presentational — it renders what its props
  * supply and dismisses itself on outside presses.
  *
  * It renders in a portal, positioned against the control that opened it. The tab
@@ -12,7 +14,7 @@
  * portal's synthetic events through the strip, which is why the press guards
  * below remain necessary.
  */
-import { Children, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import { createPortal } from 'react-dom'
 import type { DockLabels } from '../contract/adapter.ts'
@@ -26,12 +28,11 @@ export interface TabMenuProps {
   readonly labels: DockLabels
   /** The control that opened the menu; the menu hangs below its left edge. */
   readonly anchor: HTMLElement
-  /** Whether to offer close; custom items remain available when false. */
-  readonly canCloseTab: boolean
-  readonly onClose: () => void
+  /** Close the tab; `undefined` removes the kit's item, leaving the extras only. */
+  readonly onClose: (() => void) | undefined
   /** Dismiss without acting. */
   readonly onDismiss: () => void
-  /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */
+  /** Embedder items, rendered after the kit's own; absent means none. */
   readonly extras: ReactNode
 }
 
@@ -49,18 +50,26 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties {
 }
 
 /** The actions menu body, anchored to the control that opened it. */
-export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
+export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
   const self = useRef<HTMLDivElement | null>(null)
   const [position, setPosition] = useState<CSSProperties | undefined>(undefined)
-  const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '')
 
   useLayoutEffect(() => {
+    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (self.current === null) return
+    // A menu with nothing to offer never shows: with the close withheld and no
+    // embedder item rendered, dismiss before the first paint instead of
+    // drawing an empty box.
+    if (self.current.querySelector('[role="menuitem"]') === null) {
+      onDismiss()
+      return
+    }
     setPosition(placeMenu(anchor, self.current))
-  }, [anchor, canCloseTab, hasItems])
+  }, [anchor, onDismiss])
 
   useEffect(() => {
     const menu = self.current
+    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (menu === null) return undefined
     // A press anywhere but inside the menu dismisses it; one with no element
     // target (dispatched to the window itself) counts as outside.
@@ -72,9 +81,8 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra
     // so the menu must be gone before that handler runs.
     window.addEventListener('pointerdown', onPointerDown, true)
     return () => { window.removeEventListener('pointerdown', onPointerDown, true) }
-  }, [onDismiss, hasItems])
+  }, [onDismiss])
 
-  if (!hasItems) return null
   return createPortal(
     <div
       className={css.menu}
@@ -91,7 +99,7 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra
       onPointerDown={(event) => { event.stopPropagation() }}
       onClick={(event) => { event.stopPropagation() }}
     >
-      {canCloseTab && (
+      {onClose !== undefined && (
         <button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
           {labels.closeTab}
         </button>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 27 - 12
packages/client/ui-dockkit/src/components/TabPanel.tsx


+ 37 - 0
packages/client/ui-dockkit/src/components/TabTitle.tsx

@@ -0,0 +1,37 @@
+/**
+ * A chip's title: one line, clipped at the chip's inset, never ellipsized.
+ * While the text is wider than its box the span carries
+ * `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the
+ * clipped edge in place of an ellipsis. Written to the DOM directly rather
+ * than through state: a reading changes nothing that renders, only how the
+ * stylesheet paints it. Re-read after every commit (the text may have
+ * changed) and whenever the span's box resizes (the chip shrank or grew).
+ */
+import { useLayoutEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import css from './dockkit.module.css'
+
+/** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */
+function markClipped(element: HTMLElement): void {
+  // Sub-pixel widths: the text counts as clipped past one whole pixel.
+  if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = ''
+  else delete element.dataset.dockkitTabClipped
+}
+
+/** The title span of a strip chip or a floating panel's header chip. */
+export function TabTitle({ children }: { readonly children: ReactNode }): ReactNode {
+  const span = useRef<HTMLSpanElement | null>(null)
+  useLayoutEffect(() => {
+    /* v8 ignore next -- the span is rendered unconditionally. */
+    if (span.current !== null) markClipped(span.current)
+  })
+  useLayoutEffect(() => {
+    const element = span.current
+    /* v8 ignore next -- the span is rendered unconditionally. */
+    if (element === null || typeof ResizeObserver === 'undefined') return undefined
+    const observer = new ResizeObserver(() => { markClipped(element) })
+    observer.observe(element)
+    return () => { observer.disconnect() }
+  }, [])
+  return <span ref={span} className={css.tabTitle} data-dockkit-tab-title>{children}</span>
+}

+ 394 - 99
packages/client/ui-dockkit/src/components/dockkit.module.css

@@ -7,10 +7,11 @@
  * Colours come from the embedder's token layer; the kit names no literal. Type
  * follows the embedder's content axis (`--dsh-content-font-size` and its
  * secondary step) so a surface reads at the same size as the page around it.
- * Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the
- * platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`),
- * not `--dsw-alias-brand-primary`, which this platform binds to its
- * near-black (light) or near-white (dark) foreground.
+ * Emphasis — the drop caret, the drop-zone hint — takes the platform's accent
+ * (`--dsw-alias-brand-primary-new-colorprimary-new-color`), not
+ * `--dsw-alias-brand-primary`, which this platform binds to its near-black
+ * (light) or near-white (dark) foreground. A hovered divider takes the
+ * caption label ink instead, reading as a handle rather than a highlight.
  */
 
 .split {
@@ -35,25 +36,87 @@
   min-height: 0;
 }
 
+/* The divider owns no layout room (`SPLIT_MINIMUMS.divider` mirrors the 0):
+   the halves abut, so a rule a pane draws across its own edge — a header's
+   hairline — runs unbroken past the seam. The visible rule is a 0.5px hairline
+   centred on the seam, matching the embedder's other borders, and `::after`
+   widens the pointer target to 8px by reaching 4px over each neighbour.
+   `z-index` keeps that overhang above the later sibling, which would otherwise
+   take the hit. */
 .divider {
   position: relative;
+  z-index: 1;
   flex: none;
-  background: var(--dsw-alias-border-l1);
   touch-action: none;
 }
 
+.divider::before,
+.divider::after {
+  content: '';
+  position: absolute;
+}
+
+.divider::before {
+  background: var(--dsw-alias-border-l4);
+}
+
 .splitRow > .divider {
-  width: 4px;
+  width: 0;
   cursor: col-resize;
 }
 
+.splitRow > .divider::before {
+  top: 0;
+  bottom: 0;
+  left: -0.25px;
+  width: 0.5px;
+}
+
+.splitRow > .divider::after {
+  top: 0;
+  bottom: 0;
+  left: -4px;
+  right: -4px;
+}
+
 .splitColumn > .divider {
-  height: 4px;
+  height: 0;
   cursor: row-resize;
 }
 
-.divider:hover {
-  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+.splitColumn > .divider::before {
+  left: 0;
+  right: 0;
+  top: -0.25px;
+  height: 0.5px;
+}
+
+.splitColumn > .divider::after {
+  left: 0;
+  right: 0;
+  top: -4px;
+  bottom: -4px;
+}
+
+/* Hover: a 1px grip fades in over the hairline, its caption ink deepest at
+   its middle and fading toward both ends. The grip is painted on the `::after`
+   hit target, centred on the seam, and crossfaded through opacity, because a
+   gradient cannot transition from the hairline's solid colour. */
+.divider::after {
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.divider:hover::after {
+  opacity: 1;
+}
+
+.splitRow > .divider::after {
+  background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat;
+}
+
+.splitColumn > .divider::after {
+  background: linear-gradient(to right, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 100% 1px no-repeat;
 }
 
 /* Both floors: a flex item's minimum is its content's, and a body's longest
@@ -74,17 +137,18 @@
   min-width: 0;
   min-height: 0;
   overflow: hidden;
-  border: 1px solid transparent;
 }
 
-.pane[data-dockkit-pane-active] {
-  border-color: var(--dsw-alias-border-l2);
-}
-
-/* One centre line for everything in the strip: every child — chip, add
-   control, split control, the embedder's chrome — is 24px tall, and the strip
-   centres them, so chip text and control glyphs never sit at different heights.
-   A child with another height would break that; keep them at 24px.
+/* A 38px row whose 28px content band sits at its bottom: chips fill the band,
+   so they end flush with the strip's bottom edge, and the 28px add control, the
+   28px split control, and the embedder's 28px chrome centre on the band's one
+   centre line, so chip text and control glyphs never sit at different heights.
+   The 10px above is the strip's own top margin, inside its box so the hit
+   area stays one element; the 10px at the start is the first chip's inset
+   from the edge, and the 6px at the end puts the last control's glyph 12px
+   from the edge. The strip draws no border: a body
+   that wants a rule under it draws its own. A floating panel's header is
+   this same row.
 
    The strip never clips: the chip box below is its one shrinking part, and
    every control after it is `flex: none`, so a narrow pane costs chips, never
@@ -94,27 +158,47 @@
   flex: none;
   gap: 4px;
   align-items: center;
-  height: 36px;
-  padding: 0 6px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
+  height: 28px;
+  padding: 10px 6px 0 10px;
   touch-action: none;
 }
 
-/* The chips. Shrinks to nothing before any control after it moves; what no
-   longer fits is clipped here. Deliberately not a scroller: a horizontal
-   scroll container claims a press-and-move as its own gesture and cancels the
-   pointer, which would abandon every tab drag in a narrow pane. Tabs shrink
-   and ellipsize first. */
+/* The chips. Shrinks to nothing before any control after it moves; chips keep
+   their 80px floor, so what no longer fits scrolls here on the wheel, with no
+   scrollbar drawn. `touch-action: none` keeps a touch press-and-move a tab drag
+   rather than a pan the scroller would claim and cancel the pointer for.
+   `data-dockkit-strip-scroll` names the hidden sides (`start`, `end`, or
+   both); each hidden side fades the chips out over 24px into whatever ground
+   the pane draws, so the row reads as continuing under the controls. */
 .stripTabs {
   display: flex;
   flex: 0 1 auto;
-  gap: 4px;
   align-items: center;
   min-width: 0;
-  overflow: hidden;
+  overflow-x: auto;
+  overflow-y: hidden;
+  /* The scroll to a newly active chip glides rather than jumps. */
+  scroll-behavior: smooth;
+  scrollbar-width: none;
   touch-action: none;
 }
 
+.stripTabs::-webkit-scrollbar {
+  display: none;
+}
+
+.stripTabs[data-dockkit-strip-scroll='end'] {
+  mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
+}
+
+.stripTabs[data-dockkit-strip-scroll='start'] {
+  mask-image: linear-gradient(to right, transparent, black 24px);
+}
+
+.stripTabs[data-dockkit-strip-scroll='start end'] {
+  mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
+}
+
 /* Takes the free space and gives it all back first: a zero basis shrinks
    nothing, so shortage lands on the chip box alone. */
 .stripFill {
@@ -122,37 +206,64 @@
   min-width: 0;
 }
 
-/* Embedder controls at the strip's end, set off from the kit's own split
-   control by a hairline so the two groups read as two groups. */
+/* Embedder controls at the strip's end, spaced from the kit's own split
+   control as they are from each other: the strip's 4px gap plus this 4px
+   margin equals the 8px between the controls. */
 .stripChrome {
   display: flex;
   flex: none;
-  gap: 2px;
+  gap: 8px;
   align-items: center;
-  height: 24px;
-  margin-left: 2px;
-  padding-left: 4px;
-  border-left: 0.5px solid var(--dsw-alias-border-l1);
+  height: 28px;
+  margin-left: 4px;
 }
 
-.caret {
+/* The slot between two chips: a 10px box with a hairline down its centre.
+   Targeted by a drag, the same box draws the caret instead, so the chips
+   around it never move. */
+.slot {
+  display: flex;
   flex: none;
-  align-self: center;
+  align-items: center;
+  justify-content: center;
+  width: 10px;
+  height: 28px;
+}
+
+.slot::before {
+  content: '';
+  width: 0.5px;
+  height: 14px;
+  background: var(--dsw-alias-border-l4);
+}
+
+/* The active chip is a filled capsule and needs no rule against it: the two
+   slots beside it go blank, so the row reads as the capsule between bare
+   chips. A caret in either slot still draws. */
+.tabActive + .slot:not(.slotCaret)::before,
+.slot:not(.slotCaret):has(+ .tabActive)::before {
+  background: transparent;
+}
+
+.slotCaret::before {
   width: 2px;
   height: 20px;
   background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
 }
 
+/* The chip: a 28px capsule whose title runs to the right inset. The close
+   control is not in the flow — it sits over the title's last 14px and shows
+   while the chip is active, hovered, or holds focus — so a chip is the same
+   width with and without it and nothing shifts on hover. */
 .tab {
   position: relative;
   display: flex;
   flex: 0 1 auto;
-  gap: 4px;
   align-items: center;
-  min-width: 44px;
+  min-width: 80px;
   max-width: 170px;
-  height: 24px;
-  padding: 0 5px 0 10px;
+  height: 28px;
+  padding: 0 10px;
   color: var(--dsw-alias-label-secondary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: 1;
@@ -163,35 +274,78 @@
   user-select: none;
 }
 
+/* A flex row with a 5px gap, so an embedder's leading glyph (`renderTabTitle`)
+   centres on the text's line rather than sitting on its baseline and keeps
+   one distance from it. The clip is horizontal in intent; the line box must
+   hold the descenders it would otherwise cut, hence a line-height above the
+   chip's own 1. No ellipsis: a title wider than its box (`TabTitle` sets
+   `data-dockkit-tab-clipped`) fades out over its last 16px instead. A mask on
+   the title rather than a painted gradient: the chip's fill is bare,
+   translucent hover, or the active tag colour, and a mask matches every one
+   without naming it. */
 .tabTitle {
+  display: flex;
+  flex: 1 1 auto;
+  gap: 5px;
+  align-items: center;
+  min-width: 0;
   overflow: hidden;
-  text-overflow: ellipsis;
+  line-height: 1.4;
+}
+
+.tabTitle[data-dockkit-tab-clipped] {
+  mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
+}
+
+/* While the close shows, the title is gone under the close's circle and fades
+   out over the 16px before it: the circle spans the chip's last 24px and the
+   title ends 10px inside the chip, so the title's last 14px are under it.
+   Later than the clipped mask, so a clipped chip showing its close fades
+   under the close. A floating panel's header chip has no close, so it keeps
+   the clipped mask alone. */
+.tab:not(.floatTitle):hover .tabTitle,
+.tab:not(.floatTitle):focus-within .tabTitle,
+.tabActive .tabTitle {
+  mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px));
 }
 
+/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in
+   the tertiary ink, so it sits back from the title beside it. */
 .tabClose {
+  position: absolute;
+  top: 4px;
+  right: 4px;
   display: flex;
-  flex: none;
   align-items: center;
   justify-content: center;
-  width: 16px;
-  height: 16px;
+  width: 20px;
+  height: 20px;
   padding: 0;
-  color: inherit;
+  color: var(--dsw-alias-label-tertiary);
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 50%;
-  corner-shape: round;
+  border-radius: 20px;
   cursor: pointer;
+  opacity: 0;
+}
+
+/* Shown while the chip is hovered or holds focus, and always on the active
+   chip: the tab in view is the one a reader closes next. */
+.tab:hover .tabClose,
+.tab:focus-within .tabClose,
+.tabActive .tabClose {
+  opacity: 1;
 }
 
+/* The chip's height and corner, so it reads as one more capsule in the row. */
 .addTab {
   display: flex;
   flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
+  width: 28px;
+  height: 28px;
   padding: 0;
   color: var(--dsw-alias-label-secondary);
   line-height: 1;
@@ -208,46 +362,72 @@
 
 .tabClose:hover {
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover-solid);
+  background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .tab:hover {
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
-/* The active chip is the filled capsule; the rest are bare text. */
+/* The active chip is the filled capsule, in the tag fill; the rest are bare text. */
 .tabActive {
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-active);
+  background: var(--dsw-alias-markdown-tag);
+}
+
+/* A pane's lone unclosable chip is a label, not a choice: no capsule, no
+   hover fill — the primary ink on the pane's own ground. Its title never
+   yields to a close control, so it keeps the clipped fade alone. */
+.tab.tabQuiet,
+.tab.tabQuiet:hover {
+  color: var(--dsw-alias-label-primary);
+  background: transparent;
+  cursor: default;
+}
+
+.tab.tabQuiet .tabTitle,
+.tab.tabQuiet:hover .tabTitle,
+.tab.tabQuiet:focus-within .tabTitle {
+  mask-image: none;
+}
+
+.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] {
+  mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
 }
 
 .tabDragging {
   opacity: 0.5;
 }
 
+/* The geometry of a message's action row button (ui-chat MessageIconActions):
+   a 28px circle around a 15px glyph. */
 .iconButton {
-  display: flex;
+  display: inline-flex;
   flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 4px;
+  border-radius: 28px;
   cursor: pointer;
 }
 
+.iconButton svg {
+  width: 15px;
+  height: 15px;
+}
+
 .iconButton:hover:not(:disabled) {
-  color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .iconButton:disabled {
-  color: var(--dsw-alias-label-tertiary);
+  opacity: 0.4;
   cursor: default;
 }
 
@@ -288,12 +468,14 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* No padding of its own: a tab's body reaches the pane's edges and the strip's
+   bottom edge, and keeps its own insets, so a header row it draws sits flush
+   under the strip. */
 .paneBody {
   position: relative;
   flex: 1 1 auto;
   min-width: 0;
   min-height: 0;
-  padding: 12px;
   overflow: auto;  /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
      inside it rebinds the thumb indirection in a complete pair — a base-surface
      thumb on an elevated ground reads as a smudge. */
@@ -303,14 +485,37 @@
 
 .empty {
   margin: 0;
+  padding: 12px;
   color: var(--dsw-alias-label-tertiary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
 }
 
+/* The drop hint is a scrim and, over it, one box pair per landing region.
+   The scrim (`.dockScrim`) covers the whole body with the translucent, blurred
+   ground, so the insets between and around the cards dim the body's content
+   instead of letting it show through raw. The outer box (`.dockHint`) is the
+   region a release fills — the whole body, or 40% (edge bands) or 50%
+   (horizontal halves) of it — and draws nothing; it is a padded frame that
+   keeps the card inside it 8px clear of the body's edges and, between two
+   horizontal halves, 4px clear of the seam, so neighbouring cards sit 8px
+   apart like the body's own insets. The card (`.dockHintCard`) is what the eye
+   lands on: a dashed frame with the zone's glyph and caption stacked at its
+   centre. A card that is not under the pointer stays as a quiet outline so the
+   reader sees where the other release would land. */
+.dockScrim {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 72%, transparent);
+  backdrop-filter: blur(6px);
+  animation: dockScrimIn 140ms ease-out;
+}
+
 .dockHint {
   position: absolute;
-  background: var(--dsw-alias-bg-multi-select);
-  border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+  display: flex;
+  box-sizing: border-box;
+  padding: 8px;
   pointer-events: none;
 }
 
@@ -332,15 +537,82 @@
   width: 40%;
 }
 
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'],
+[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'] {
+  width: 50%;
+  padding-right: 4px;
+}
+
 [data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] {
   width: 50%;
+  padding-left: 4px;
 }
 
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]),
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) {
-  background: transparent;
-  border-color: var(--dsw-alias-border-l2);
+.dockHintCard {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 8px;
+  align-items: center;
+  justify-content: center;
+  min-width: 0;
+  min-height: 0;
+  padding: 12px;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  border: 1.5px dashed var(--dsw-alias-border-l2);
+  border-radius: 12px;
+  transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out;
+  animation: dockHintIn 140ms ease-out;
+}
+
+/* The card under the pointer takes the accent: a tinted ground over the scrim,
+   an accent dashed frame, and secondary ink for the glyph and caption. */
+.dockHint[data-dockkit-drop-active] .dockHintCard {
+  color: var(--dsw-alias-label-secondary);
+  background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent);
+  border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+}
+
+.dockHintCard svg {
+  flex: none;
+  width: 20px;
+  height: 20px;
+}
+
+/* One line, clipped rather than wrapped: the card in a narrow half is still
+   wide enough for its glyph, and the caption fades under the frame. */
+.dockHintLabel {
+  max-width: 100%;
+  overflow: hidden;
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-weight: 500;
+  line-height: 1.4;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+@keyframes dockHintIn {
+  from {
+    opacity: 0;
+    transform: scale(0.98);
+  }
+
+  to {
+    opacity: 1;
+    transform: none;
+  }
+}
+
+/* The scrim fades in without the cards' scale: a full-body cover that shrinks
+   would expose an uncovered rim of raw content on entry. */
+@keyframes dockScrimIn {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
 }
 
 .dockHint[data-dockkit-dock-zone='top'] {
@@ -361,43 +633,50 @@
   position: fixed;
   display: flex;
   flex-direction: column;
-  background: var(--dsw-alias-bg-layer-1);
-  border: 0.5px solid var(--dsw-alias-border-l2);
-  border-radius: 8px;
-  box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop);
+  /* The body inside is unpadded and reaches the edges, so the frame clips it
+     to its own corners. */
+  overflow: hidden;
+  background: var(--dsw-alias-bg-layer-2);
+  border-radius: 20px;
+  /* The menu's shadow with the stroke rebound one step lighter than the
+     default (l2): the panel reads as the same kind of raised surface, and the
+     shadow's hairline outlines it, so the frame draws no border of its own. */
+  --dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
+  box-shadow: var(--dsw-elevation-prominent);
   pointer-events: auto;
 }
 
-/* The active panel keeps the same hairline: it is already on top of the z
-   order and casts the same shadow, and a heavier or darker frame read as a
-   defect. `data-dockkit-float-active` stays on the element for tests. */
+/* The active panel keeps the same frame: it is already on top of the z order
+   and casts the same shadow, and a heavier or darker frame read as a defect.
+   `data-dockkit-float-active` stays on the element for tests. */
 
+/* The panel's header is the pane strip's row (`.tabStrip` supplies the
+   metrics) and the whole of it is the move grip. */
 .floatHeader {
-  display: flex;
-  flex: none;
-  gap: 2px;
-  align-items: center;
-  height: 28px;
-  padding: 0 4px 0 10px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
   cursor: move;
-  touch-action: none;
   user-select: none;
 }
 
+/* The one tab, drawn as its chip would be in a strip but never selected,
+   hovered, or closable from here: the frame's controls do that. */
 .floatTitle {
-  flex: 1 1 auto;
-  overflow: hidden;
-  color: var(--dsw-alias-label-primary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  white-space: nowrap;
-  text-overflow: ellipsis;
+  flex: 0 1 auto;
+  cursor: inherit;
+}
+
+.tab.floatTitle:hover {
+  background: transparent;
+}
+
+/* Mirrored so the filled pane sits at the right, where the docked surface is. */
+.dockGlyph {
+  transform: scaleX(-1);
 }
 
+/* Unpadded like `.paneBody`: the same body draws the same insets in a float. */
 .floatBody {
   flex: 1 1 auto;
   min-height: 0;
-  padding: 10px;
   overflow: auto;  /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
      inside it rebinds the thumb indirection in a complete pair — a base-surface
      thumb on an elevated ground reads as a smudge. */
@@ -409,19 +688,35 @@
   position: absolute;
   right: 0;
   bottom: 0;
-  width: 14px;
-  height: 14px;
+  width: 20px;
+  height: 20px;
   cursor: nwse-resize;
   touch-action: none;
 }
 
+/* The grip is a quarter arc concentric with the frame's 20px corner (16px
+   radius at a 4px inset), so it reads as part of the frame. It shows only
+   while the pointer is over the panel — an idle panel keeps a clean corner —
+   and `:active` keeps it lit while a drag holds the pointer capture. */
 .floatResize::after {
-  position: absolute;
-  right: 3px;
-  bottom: 3px;
-  width: 6px;
-  height: 6px;
-  border-right: 2px solid var(--dsw-alias-label-tertiary);
-  border-bottom: 2px solid var(--dsw-alias-label-tertiary);
   content: '';
+  position: absolute;
+  right: 4px;
+  bottom: 4px;
+  width: 16px;
+  height: 16px;
+  border-right: 1.5px solid var(--dsw-alias-label-caption);
+  border-bottom: 1.5px solid var(--dsw-alias-label-caption);
+  border-bottom-right-radius: 16px;
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.float:hover .floatResize::after,
+.floatResize:active::after {
+  opacity: 1;
+}
+
+.floatResize:hover::after {
+  border-color: var(--dsw-alias-label-tertiary);
 }

+ 21 - 1
packages/client/ui-dockkit/src/components/measure.ts

@@ -63,12 +63,31 @@ function dividerSize(root: HTMLElement): number {
   return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider
 }
 
+/**
+ * The rendered split control's footprint in the strip's fixed part: its box
+ * plus the strip's own gap, both of which the strip sheds when the control
+ * hides. 0 while the control is hidden or unmeasured.
+ */
+function splitControlFootprint(pane: HTMLElement): number {
+  const control = pane.querySelector('[data-dockkit-split-button]')
+  if (control === null) return 0
+  const width = control.getBoundingClientRect().width
+  if (!(width > 0)) return 0
+  const strip = pane.querySelector('[data-dockkit-strip]')
+  /* v8 ignore next -- the control only renders inside a strip. */
+  return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap))
+}
+
 /**
  * Measure every docked pane under `root`.
  * @param root - the docked surface's element.
+ * @param splitHiddenWhenBlocked - whether the embedder hides blocked split
+ * controls (`hideSplitWhenBlocked`); the room rule then leaves the control's
+ * footprint out of each strip's fixed part, so the reading cannot flip with
+ * the control's visibility (see `PaneMeasure.splitControlWidth`).
  * @returns each pane's fit, keyed by pane id.
  */
-export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFit> {
+export function measurePaneFits(root: HTMLElement, splitHiddenWhenBlocked = false): ReadonlyMap<PaneId, HalvesFit> {
   const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body }
   const fits = new Map<PaneId, HalvesFit>()
   for (const [paneId, pane] of paneElements(root)) {
@@ -77,6 +96,7 @@ export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFi
       strip: rectOf(pane.querySelector('[data-dockkit-strip]')),
       chipsWidth: rectOf(pane.querySelector('[data-dockkit-strip-tabs]')).width,
       fillWidth: rectOf(pane.querySelector('[data-dockkit-strip-fill]')).width,
+      splitControlWidth: splitHiddenWhenBlocked ? splitControlFootprint(pane) : 0,
     }, minimums))
   }
   return fits

+ 3 - 3
packages/client/ui-dockkit/src/components/render.ts

@@ -23,11 +23,11 @@ export interface PaneCallbacks {
   readonly onDividerPressed: (splitId: SplitId, index: number, event: ReactPointerEvent<HTMLElement>) => void
   /** Why a pane cannot split right now, or `undefined` while it can. */
   readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined
-  /** Hide budget-blocked split controls without hiding width-blocked controls. */
-  readonly hideSplitAtCapacity?: boolean
+  /** Hide blocked split controls instead of rendering them disabled. */
+  readonly hideSplitWhenBlocked?: boolean
   /** Whether a pane's strip draws the add control. */
   readonly canAddTab: (paneId: PaneId) => boolean
-  /** Whether a tab's chip and menu offer close. */
+  /** Whether a tab draws its close control and its menu's close item. */
   readonly canCloseTab: (tabId: TabId) => boolean
   /** Live drop preview, or `undefined` while nothing is being dragged. */
   readonly dropTarget: DropTarget | undefined

+ 2 - 0
packages/client/ui-dockkit/src/contract/adapter.ts

@@ -31,6 +31,8 @@ export interface DockLabels {
   readonly dockFloat: string
   /** Close a floating panel. */
   readonly closeFloat: string
+  /** The drop hint's caption for each body zone a dragged tab can land on. */
+  readonly dropZone: Readonly<Record<DockZone, string>>
 }
 
 /**

+ 23 - 10
packages/client/ui-dockkit/src/engine/geometry.ts

@@ -71,6 +71,17 @@ export interface PaneMeasure {
   readonly chipsWidth: number
   /** Width of the fill: free space, not a control. */
   readonly fillWidth: number
+  /**
+   * Footprint of the rendered split control (its width plus the strip's gap)
+   * for embedders that hide blocked split controls: a half too narrow to
+   * split hides its own control, so the rule leaves the footprint out of the
+   * fixed part. Leaving it out is also what keeps the reading stable — the
+   * control hiding sheds the same footprint from the strip, and a reading
+   * that counted it would flip with the control's visibility and re-render
+   * forever. Absent or 0 keeps the control in the fixed part, for embedders
+   * that render a blocked control disabled.
+   */
+  readonly splitControlWidth?: number
 }
 
 /** Pixel minimums the room rule holds each half to. */
@@ -79,18 +90,19 @@ export interface SplitMinimums {
   readonly divider: number
   /** One chip at its minimum: the smallest strip that still names a tab. */
   readonly chip: number
-  /** The smallest body under a strip: one secondary text line inside the body's padding. */
+  /** The smallest body under a strip: one secondary text line inside 12px of the body's own insets. */
   readonly body: number
 }
 
 /**
  * The minimums where no computed style can be read, mirroring
- * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of
- * content plus 10px + 5px of padding (content-box), 59px; the body's 12px
- * padding above and below one 13px secondary line at 1.6 line-height is 45px,
- * held to 48px.
+ * `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its
+ * hairline is painted over the seam); `.tab` is 80px of content plus
+ * 10px + 10px of padding (content-box), 100px; 12px above and below one 13px
+ * secondary line at 1.6 line-height — the inset a body draws for itself, as
+ * `.empty` does — is 45px, held to 48px.
  */
-export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 }
+export const SPLIT_MINIMUMS: SplitMinimums = { divider: 0, chip: 100, body: 48 }
 
 /** Whether a pane's two halves after an equal split would each still work. */
 export interface HalvesFit {
@@ -102,9 +114,10 @@ export interface HalvesFit {
 
 /**
  * The room rule. After an equal split each half must hold what cannot shrink:
- * horizontally the strip's fixed part — its width minus the chip box and the
- * fill, which is the padding, the gaps, and every control that pane draws —
- * plus one chip at its minimum; vertically the strip plus a minimum body. The
+ * horizontally the strip's fixed part — its width minus the chip box, the
+ * fill, and `splitControlWidth`, which is the padding, the gaps, and every
+ * control a half would still draw — plus one chip at its minimum; vertically
+ * the strip plus a minimum body. The
  * borders are what the pane's box exceeds the strip's by. An unmeasured pane
  * (no layout, as under jsdom) fits: the rule only blocks on a positive reading.
  * @param measure - the pane's rectangles.
@@ -115,7 +128,7 @@ export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_
   const { pane, strip } = measure
   if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true }
   const borders = Math.max(0, pane.width - strip.width)
-  const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth)
+  const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0))
   const halfWidth = (pane.width - minimums.divider) / 2 - borders
   const halfHeight = (pane.height - minimums.divider) / 2 - borders
   return {

+ 280 - 19
packages/client/ui-dockkit/tests/components.client.spec.tsx

@@ -260,14 +260,30 @@ describe('DockSurface', () => {
     expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
   })
 
-  it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => {
+  it('names the enabled split control through the shared tooltip, not a native title', () => {
+    vi.useFakeTimers()
+    try {
+      renderSurface(seededController(), spyIntents())
+      const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
+      expect(button.hasAttribute('title')).toBe(false)
+      fireEvent.mouseEnter(button)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
+      fireEvent.mouseLeave(button)
+      expect(screen.queryByRole('tooltip')).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('hides blocked split controls when opted in and restores them when capacity returns', () => {
     const controller = seededController()
     controller.splitPane()
     const state = controller.getSnapshot().state
-    layOut(dockPaneIds(state), 420)
+    layOut(dockPaneIds(state), 520)
     const intents = spyIntents()
     const props: DockSurfaceProps = {
-      state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
+      state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
     }
     const view = render(<DockSurface {...props} />)
     expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
@@ -285,15 +301,15 @@ describe('DockSurface', () => {
   // jsdom lays nothing out, so the room rule reads the rectangles this spec
   // hands it: two panes, one wide enough for two halves and one not. The
   // strip's fixed part is 104px in both (the chrome pane's controls), the chip
-  // minimum falls back to the stylesheet's 59px.
-  it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => {
+  // minimum falls back to the stylesheet's 100px.
+  it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => {
     const controller = seededController()
     controller.setExpanded(true)
     controller.splitPane()
     const snapshot = controller.getSnapshot()
     const [wide, narrow] = dockPaneIds(snapshot.state)
     if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
-    const widths: Record<string, number> = { [wide]: 420, [narrow]: 208 }
+    const widths: Record<string, number> = { [wide]: 520, [narrow]: 208 }
     vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
       const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
       if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
@@ -306,7 +322,7 @@ describe('DockSurface', () => {
       <DockSurface
         state={snapshot.state}
         canSplit
-        hideSplitAtCapacity={hideSplitAtCapacity}
+        hideSplitWhenBlocked={hideSplitWhenBlocked}
         intents={controller}
         labels={TEST_LABELS}
         renderTab={tab => <p>{tab.contentId}</p>}
@@ -315,10 +331,14 @@ describe('DockSurface', () => {
     const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
     const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
     expect(wideButton?.hasAttribute('disabled')).toBe(false)
-    expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane)
-    expect(narrowButton?.hasAttribute('disabled')).toBe(true)
-    expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
-    expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
+    expect(wideButton?.hasAttribute('title')).toBe(false)
+    if (hideSplitWhenBlocked) {
+      expect(narrowButton).toBeNull()
+    } else {
+      expect(narrowButton?.hasAttribute('disabled')).toBe(true)
+      expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
+      expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
+    }
   })
 
   it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
@@ -342,7 +362,7 @@ describe('DockSurface', () => {
     controller.splitPane()
     const snapshot = controller.getSnapshot()
     const panes = dockPaneIds(snapshot.state)
-    let paneWidth = 420
+    let paneWidth = 520
     vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
       if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
       if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
@@ -379,6 +399,194 @@ describe('DockSurface', () => {
     expect(observer.disconnect).toHaveBeenCalledTimes(1)
   })
 
+  // The room reading must not flip with the hidden control's own footprint:
+  // hiding the width-blocked control widens the fill by the control plus the
+  // strip's gap, and a reading that counted the control would fit again, show
+  // it, and re-render forever (React's update-depth limit, which crashed the
+  // surface). The rule leaves the footprint out, so the fill the fake hands
+  // back here depends on whether the control is in the DOM — exactly the
+  // feedback the fix breaks.
+  it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static latest: FakeResizeObserver | undefined
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.latest = this
+      }
+
+      /** What the platform does when the observed element's size changes. */
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    const controller = seededController()
+    controller.setExpanded(true)
+    const snapshot = controller.getSnapshot()
+    // In the band where only the control's 28px footprint decides the fit:
+    // half 188px against 76px of other fixed controls plus the 100px chip.
+    let paneWidth = 380
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
+      if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
+      if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
+      if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28)
+      if (this.hasAttribute('data-dockkit-strip-fill')) {
+        const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104
+        return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24)
+      }
+      return box(0, 0, 0, 0)
+    })
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        hideSplitWhenBlocked
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+      />,
+    )
+    // Settled with the control shown: the discounted reading fits either way.
+    expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull()
+
+    // Narrowed under the discounted minimum: hidden, and the reading without
+    // the control settles hidden.
+    const observer = FakeResizeObserver.latest
+    if (observer === undefined) throw new Error('expected the surface to observe its own size')
+    paneWidth = 300
+    act(() => { observer.fire() })
+    expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
+  })
+
+  it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
+    let scrollLeft = 0
+    const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
+      [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
+    const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs')
+    Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } })
+    Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } })
+    Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } })
+    try {
+      const controller = seededController()
+      renderSurface(controller, spyIntents())
+      const box = document.querySelector<HTMLElement>('[data-dockkit-strip-tabs]')
+      if (box === null) throw new Error('expected the chip box')
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end')
+      scrollLeft = 100
+      fireEvent.scroll(box)
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end')
+      scrollLeft = 200
+      fireEvent.scroll(box)
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start')
+    } finally {
+      for (const [name, descriptor] of descriptors) {
+        if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
+        else Object.defineProperty(Element.prototype, name, descriptor)
+      }
+    }
+  })
+
+  it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => {
+    let scrollLeft = 0
+    const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
+    Object.defineProperty(Element.prototype, 'scrollLeft', {
+      configurable: true,
+      get() { return scrollLeft },
+      set(value: number) { scrollLeft = value },
+    })
+    // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says.
+    let openedTab: TabId | undefined
+    let openedAt = 250
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28)
+      if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28)
+      if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28)
+      return box(0, 0, 0, 0)
+    })
+    const surface = (controller: DockController) => (
+      <DockSurface
+        state={controller.getSnapshot().state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+      />
+    )
+    try {
+      const controller = seededController()
+      const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId
+      if (seededTab === undefined) throw new Error('expected the seeded tab')
+      const { rerender } = render(surface(controller))
+      // The seeded chip is in view: nothing moves.
+      expect(scrollLeft).toBe(0)
+      openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+      rerender(surface(controller))
+      // Past the right edge by 140, plus the 24px fade.
+      expect(scrollLeft).toBe(164)
+      // Selected again once it lies 30 past the left edge: back by 30 plus the fade.
+      openedAt = -30
+      controller.focusTab(seededTab)
+      rerender(surface(controller))
+      controller.focusTab(openedTab)
+      rerender(surface(controller))
+      expect(scrollLeft).toBe(164 - 30 - 24)
+    } finally {
+      if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
+      else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
+    }
+  })
+
+  it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static readonly all: FakeResizeObserver[] = []
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.all.push(this)
+      }
+
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    let clientWidth = 200
+    const descriptors = ['scrollWidth', 'clientWidth'].map(name =>
+      [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
+    const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title')
+    Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } })
+    Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } })
+    try {
+      const controller = seededController()
+      const { unmount } = renderSurface(controller, spyIntents())
+      const span = document.querySelector<HTMLElement>('[data-dockkit-tab-title]')
+      if (span === null) throw new Error('expected a chip title')
+      const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span))
+      if (observer === undefined) throw new Error('expected the title to observe its own size')
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
+      // The chip narrowed under the text.
+      clientWidth = 80
+      act(() => { observer.fire() })
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true)
+      clientWidth = 200
+      act(() => { observer.fire() })
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
+      unmount()
+      expect(observer.disconnect).toHaveBeenCalledTimes(1)
+    } finally {
+      for (const [name, descriptor] of descriptors) {
+        if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
+        else Object.defineProperty(Element.prototype, name, descriptor)
+      }
+      vi.unstubAllGlobals()
+    }
+  })
+
   it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
     const controller = seededController()
     const intents = spyIntents()
@@ -414,6 +622,59 @@ describe('DockSurface', () => {
     expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
   })
 
+  it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const snapshot = controller.getSnapshot()
+    const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0]
+    if (seedTabId === undefined) throw new Error('expected seeded tab')
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        canCloseTab={tabId => tabId !== seedTabId}
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        renderTabMenuItems={(_, dismiss) => <button type="button" role="menuitem" onClick={dismiss}>embedder item</button>}
+      />,
+    )
+    const [seedChip, fileChip] = screen.getAllByRole('tab')
+    if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips')
+    expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull()
+    expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull()
+
+    // The menu still opens on the withheld chip: the embedder's items remain reachable.
+    fireEvent.contextMenu(seedChip)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item'])
+    fireEvent.pointerDown(document.body)
+
+    fireEvent.contextMenu(fileChip)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item'])
+  })
+
+  it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => {
+    const controller = seededController()
+    render(
+      <DockSurface
+        state={controller.getSnapshot().state}
+        canSplit
+        canCloseTab={() => false}
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        renderTabMenuItems={() => undefined}
+      />,
+    )
+    const [chip] = screen.getAllByRole('tab')
+    if (chip === undefined) throw new Error('expected the seeded chip')
+    // The lone unclosable chip draws quiet: no capsule, no hover fill.
+    expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true')
+    // The menu would hold nothing, so it dismisses itself before painting.
+    fireEvent.contextMenu(chip)
+    expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
+  })
+
   it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
     const controller = seededController()
     controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
@@ -648,13 +909,13 @@ describe('tab drags', () => {
   })
 
   it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
-    const { intents, second, fileTabId, chip } = twoPanes()
-    drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false)
+    const { intents, second, fileTabId, chip } = twoPanes(520)
+    drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false)
     const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
     expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
-    fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
     expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
-    fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
     expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
     expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
   })
@@ -764,10 +1025,10 @@ describe('tab drags', () => {
 
 describe('horizontal workbench drops', () => {
   it.each([
-    { x: 450, y: 300, zone: 'left' },
-    { x: 810, y: 590, zone: 'right' },
+    { x: 550, y: 300, zone: 'left' },
+    { x: 1010, y: 590, zone: 'right' },
   ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
-    const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' })
+    const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' })
     drag(chip(fileTabId), FILE_CHIP, [x, y], false)
     const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
     expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])

+ 1 - 0
packages/client/ui-dockkit/tests/fixtures.client.ts

@@ -44,6 +44,7 @@ export const TEST_LABELS: DockLabels = {
   addTab: 'new tab',
   dockFloat: 'dock',
   closeFloat: 'close panel',
+  dropZone: { center: 'move here', left: 'split left', right: 'split right', top: 'split top', bottom: 'split bottom' },
 }
 
 /** Brand a literal a spec spells out: an id the kit would have minted. */

+ 13 - 13
packages/client/ui-dockkit/tests/geometry.client.spec.ts

@@ -118,26 +118,26 @@ describe('halvesFit — the room rule', () => {
   })
 
   it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => {
-    // 420px: halves of 206px inside the borders, against 104 + 59.
-    expect(halvesFit(measure(420, 600, 104)).row).toBe(true)
-    // 208px: halves of 100px, short of 163.
+    // 520px: halves of 258px inside the borders, against 104 + 100.
+    expect(halvesFit(measure(520, 600, 104)).row).toBe(true)
+    // 208px: halves of 102px, short of 204.
     expect(halvesFit(measure(208, 600, 104)).row).toBe(false)
-    // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334.
-    expect(halvesFit(measure(334, 600, 104)).row).toBe(true)
-    expect(halvesFit(measure(333, 600, 104)).row).toBe(false)
-    // A strip with fewer controls needs less.
-    expect(halvesFit(measure(208, 600, 44)).row).toBe(false)
-    expect(halvesFit(measure(214, 600, 44)).row).toBe(true)
+    // The boundary is inclusive: 2 * (204 + 2 borders) = 412; the divider takes no room.
+    expect(halvesFit(measure(412, 600, 104)).row).toBe(true)
+    expect(halvesFit(measure(411, 600, 104)).row).toBe(false)
+    // A strip with fewer controls needs less: 2 * (144 + 2) = 292.
+    expect(halvesFit(measure(291, 600, 44)).row).toBe(false)
+    expect(halvesFit(measure(292, 600, 44)).row).toBe(true)
   })
 
   it('needs each half to hold the strip plus a minimum body for a column split', () => {
-    // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84.
-    expect(halvesFit(measure(420, 176, 104)).column).toBe(true)
-    expect(halvesFit(measure(420, 175, 104)).column).toBe(false)
+    // Halves of h / 2 - 2 against 36 + 48 = 84.
+    expect(halvesFit(measure(420, 172, 104)).column).toBe(true)
+    expect(halvesFit(measure(420, 171, 104)).column).toBe(false)
   })
 
   it('takes the minimums it is given, and the stylesheet\'s by default', () => {
-    expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 })
+    expect(SPLIT_MINIMUMS).toEqual({ divider: 0, chip: 100, body: 48 })
     expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false)
     expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true)
   })

+ 6 - 6
packages/client/ui-dockkit/tests/measure.client.spec.ts

@@ -57,21 +57,21 @@ describe('measurePaneFits', () => {
     const root = surface()
     const bare = document.createElement('section')
     bare.dataset.dockkitPane = 'bare'
-    root.append(pane('wide', 420), bare)
+    root.append(pane('wide', 520), bare)
     expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare'])
     const fits = measurePaneFits(root)
     expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true })
     expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true })
   })
 
-  // 308px: halves of 150px inside the borders, against 104px of controls plus one chip.
+  // 308px: halves of 152px inside the borders, against 104px of controls plus one chip.
   it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => {
     const root = surface()
     const narrow = pane('p', 308)
     root.append(narrow)
-    // No chip rendered: the stylesheet's 59px, so 163 > 150.
+    // No chip rendered: the stylesheet's 100px, so 204 > 152.
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
-    // 44px of content plus 4px + 4px of padding is 52: 156 > 150.
+    // 44px of content plus 4px + 4px of padding is 52: 156 > 152.
     const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' })
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
     // The same declaration as a border box is the whole footprint: 148 fits.
@@ -84,10 +84,10 @@ describe('measurePaneFits', () => {
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
   })
 
-  // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one.
+  // 416px: halves of 204px against 204 with the stylesheet's zero divider, 202 with an 8px one.
   it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => {
     const root = surface()
-    root.append(pane('p', 336))
+    root.append(pane('p', 416))
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
     const divider = document.createElement('div')
     divider.dataset.dockkitDivider = 's:0'

+ 3 - 0
packages/client/ui-dockkit/tsconfig.json

@@ -10,6 +10,9 @@
   "references": [
     {
       "path": "../../util/brand"
+    },
+    {
+      "path": "../ui-primitives"
     }
   ]
 }

+ 3 - 0
pnpm-lock.yaml

@@ -2816,6 +2816,9 @@ importers:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
       '@testing-library/react':
         specifier: ^16.1.0
         version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov