瀏覽代碼

feat(client): hover swim morph for the hero fish

Yif 2 周之前
父節點
當前提交
5ba375fd88

+ 6 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.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-08-12-hero-fish-hover-swim-morph.md
+2026-08-12-hero-fish-hover-swim-morph.md: 2ae090f2e9640fc68091e918a644b4f793d1a8f4
+2026-08-12-hero-fish-hover-swim-morph.zh.md: eac0047a083d410b1f538954088a29ef721fe054

+ 27 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md

@@ -0,0 +1,27 @@
+# Agent Note: Hero fish hover swim morph
+
+Status: implemented
+
+English | [中文](2026-08-12-hero-fish-hover-swim-morph.zh.md)
+
+## Problem
+
+Hovering the New Session hero fish (`EmptyHero.tsx` in `dsh-client-ui-conversation`) played a one-shot rigid CSS sway of the whole svg. The user wanted the whale to visibly swim — the tail wagging and the mouth curve lifting — which requires deforming the path geometry itself. CSS transforms cannot bend a subset of a path's curves, and the logo ships as one `FISH_LOGO_PATH` string in `dsh-client-ui-primitives`.
+
+## Decision
+
+Real curve deformation via SMIL `<animate attributeName="d">` cycling `rest → tail-up → rest → tail-down → rest` on the same 1.6s period as the CSS sway, which becomes continuous (`infinite`) for as long as the pointer stays. The two morph targets are generated programmatically (`/tmp`-run script, not checked in): parse `FISH_LOGO_PATH`'s absolute M/C/L/Z commands, rotate the tail region about a pivot with smoothstep falloff weights, bend the mouth/fin swoosh vertically with weight-squared falloff from its body anchor (a smile lift, not a rigid swing — rigid rotation read as detached), and emit structure-identical command strings SMIL can interpolate. The baked path constants live next to the component with the generation parameters documented. SMIL cannot ride CSS media queries, so a `hovering` state gated by `matchMedia('(prefers-reduced-motion: reduce)')` mounts the morph, while the CSS sway sits under `@media (hover: hover) and (prefers-reduced-motion: no-preference)`.
+
+The morphing fish reaches the hero as the fallback of the `conversation.hero.brand.mark` slot; no shipped package occupies it — `dsh-client-ui-brand-official` fills only the sidebar slots, since a feature plugin may not value-import `HeroFish` across packages ([client cross-package rule](../process/2026-08-23-client-cross-package-value-dependencies.md)) and the fallback already is the official mark. `FISH_LOGO_PATH` and `FISH_LOGO_VIEWBOX` are exported from `dsh-client-ui-primitives` for consumers that compose their own svg around the same geometry.
+
+## Alternatives considered
+
+**Vector-tool path editing for the morphs.** No interactive tool in the loop; programmatic weighted deformation was chosen because it guarantees the identical command structure SMIL `d` interpolation requires and makes amplitudes reviewable numbers.
+
+**Blowhole spout on hover.** Shipped first as three rising beads (richer whale-blow fountain, bubble-cluster, and ballistic-arc shapes were built and rejected in review), then removed entirely at the user's request: hover keeps only the shape morph and sway.
+
+**Occupying the hero slot with the official mark.** The previous arrangement; rejected because the static occupant shadowed the animated fallback, and animating the occupant instead would need the forbidden cross-package value import.
+
+## Consequences
+
+The hover swim is decorative (`aria-hidden`) and reduced-motion-safe (static logo on hover). The sway CSS targets the stationary `.fishHitbox` wrapper, so a slot occupant would sway too; the body morph lives only in the fallback `HeroFish`. Coverage is the component suite (`skeleton.client.spec.tsx`) asserting render states; the keyless snapshot harness records transcripts, not browser animation, so visual verification stays manual. Regenerating the morph targets requires re-running the (uncommitted) deformation script against `FISH_LOGO_PATH`; if the logo geometry ever changes, the baked constants must be regenerated with it.

+ 27 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:主页鲸鱼 hover 游动变形
+
+Status: implemented
+
+[English](2026-08-12-hero-fish-hover-swim-morph.md) | 中文
+
+## 问题
+
+hover New Session 主页的鲸鱼(`dsh-client-ui-conversation` 的 `EmptyHero.tsx`)原本只播放一次整个 svg 的刚性 CSS 摇摆。用户希望鲸鱼有真实的游动感——尾巴摆动、嘴巴曲线上扬,这要求对路径几何本身做变形。CSS transform 无法弯曲路径中的部分曲线,且 logo 以单一 `FISH_LOGO_PATH` 字符串存放在 `dsh-client-ui-primitives`。
+
+## 决定
+
+通过 SMIL `<animate attributeName="d">` 做真实曲线变形,按与 CSS 摇摆相同的 1.6s 周期循环 `静止 → 尾上摆 → 静止 → 尾下压 → 静止`;CSS 摇摆改为持续循环(`infinite`),指针停留多久就游多久。两个变形目标由程序生成(在 `/tmp` 运行的脚本,未入库):解析 `FISH_LOGO_PATH` 的绝对 M/C/L/Z 命令,尾部区域绕支点做带 smoothstep 衰减权重的旋转,嘴巴/鳍的内侧曲线以距身体锚点的权重平方做竖直弯曲(微笑式上扬,而非刚性摆动——刚性旋转看起来与身体脱节),并输出结构完全一致、SMIL 可插值的命令串。烘焙出的路径常量与组件放在一起,并在注释中记录生成参数。SMIL 无法响应 CSS 媒体查询,因此用经 `matchMedia('(prefers-reduced-motion: reduce)')` 判定的 `hovering` 状态控制变形挂载,CSS 摇摆则在 `@media (hover: hover) and (prefers-reduced-motion: no-preference)` 之下。
+
+变形鲸鱼以 `conversation.hero.brand.mark` slot 的 fallback 身份进入主页;没有任何发布包占据该 slot——`dsh-client-ui-brand-official` 只填充侧栏槽位,因为 feature 插件不得跨包 value-import `HeroFish`([client 跨包规则](../process/2026-08-23-client-cross-package-value-dependencies.zh.md)),而 fallback 本身就是官方标志。`FISH_LOGO_PATH` 与 `FISH_LOGO_VIEWBOX` 从 `dsh-client-ui-primitives` 导出,供围绕同一几何自行组装 svg 的消费方使用。
+
+## 考虑过的替代方案
+
+**用矢量工具编辑路径做变形。** 流程中没有可交互的工具;选择程序化加权变形,因为它保证 SMIL `d` 插值所要求的完全一致的命令结构,且振幅是可评审的数字。
+
+**hover 气孔喷水。** 最初以三颗上升水珠的形式上线(更复杂的鲸鱼喷泉、泡泡簇、弹道水花造型都做出来后在评审中被否),随后按用户要求整体移除:hover 只保留形状变形与摇摆。
+
+**让官方标志占据主页 slot。** 即先前的安排;否决,因为静态 occupant 会遮住动画 fallback,而给 occupant 加动画又需要被禁止的跨包 value import。
+
+## 影响
+
+hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由组件测试(`skeleton.client.spec.tsx`)断言渲染状态;keyless 快照体系记录的是对话转录而非浏览器动画,视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: b48c197299f1afbf53bf60962f544148f7f6defc
-module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5
+module-graph.md: 087abe4f43dd75d8b4e0390f2e78aeaf768048fc
+module-graph.zh.md: 2a1e8fcef511011e82821856dcd0e185952da3eb

+ 1 - 2
docs/module-graph.md

@@ -1443,7 +1443,6 @@ flowchart TD
   pkg_client_ui_approval --> pkg_llm
   pkg_client_ui_approval --> pkg_session
   pkg_client_ui_approval --> pkg_typert_protocol
-  pkg_client_ui_brand_official --> pkg_client_ui_conversation
   pkg_client_ui_brand_official --> pkg_client_ui_renderer
   pkg_client_ui_brand_official --> pkg_client_ui_sidebar
   pkg_client_ui_brand_official --> pkg_invariants
@@ -1929,7 +1928,7 @@ flowchart TD
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

+ 1 - 2
docs/module-graph.zh.md

@@ -1445,7 +1445,6 @@ flowchart TD
   pkg_client_ui_approval --> pkg_llm
   pkg_client_ui_approval --> pkg_session
   pkg_client_ui_approval --> pkg_typert_protocol
-  pkg_client_ui_brand_official --> pkg_client_ui_conversation
   pkg_client_ui_brand_official --> pkg_client_ui_renderer
   pkg_client_ui_brand_official --> pkg_client_ui_sidebar
   pkg_client_ui_brand_official --> pkg_invariants
@@ -1931,7 +1930,7 @@ flowchart TD
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

+ 2 - 2
packages/client/ui-brand-official/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-brand-official/README.md
-README.md: 03712e826fb415dd6fb3125c06865a2402342948
-README.zh.md: 94e8a30bb11abd468a59cc80b6745d5d929a1e23
+README.md: 6b613db7c302e77f4bbcd93468e712beb5cf2a61
+README.zh.md: a01f659dcd76b98b94607c764988b5295e761b76

+ 5 - 5
packages/client/ui-brand-official/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Official DeepSeek Harness brand occupants for the sidebar and conversation hero, active only in official builds; for users and maintainers choosing or replacing brand presentation."
+description: "Official DeepSeek Harness brand occupants for the sidebar, active only in official builds; for users and maintainers choosing or replacing brand presentation."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package fills the browser brand slots — `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. Choose it when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests.
+This package fills the sidebar brand slots — `sidebar.brand.mark` and `sidebar.brand.name` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. The conversation hero slot (`conversation.hero.brand.mark`) stays unoccupied in every build: its declaring package renders the animated hero fish (hover swim morph) as the fallback, and the official brand is that fish. Choose this package when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests.
 
 ## Table of Contents
 
@@ -29,11 +29,11 @@ Mount this plugin in the browser roster of a deployment whose identity is DeepSe
 
 ### Choosing the profile
 
-`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar and the mark in the conversation hero; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The plugin still loads and validates in both cases; only the registration is profile-gated.
+`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The conversation hero shows the animated hero fish from `dsh-client-ui-conversation` regardless of profile, because that fallback is already the official mark. The plugin still loads and validates in both cases; only the registration is profile-gated.
 
 ### Replacing the brand
 
-A deployment with its own identity leaves this package out and composes another package that occupies the same three slots. Occupying a slot is the only composition route; there is no brand configuration surface here.
+A deployment with its own identity leaves this package out and composes another package that occupies the sidebar slots — and the hero slot, which this package leaves on its fallback. Occupying a slot is the only composition route; there is no brand configuration surface here.
 
 -----
 
@@ -43,7 +43,7 @@ A deployment with its own identity leaves this package out and composes another
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The three occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar and conversation declarations, so the set works whether this row activates before or after the declarers, withdraws all three occupants when either declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
+The two occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar declaration, so the set works whether this row activates before or after the declarer, withdraws both occupants when the declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
 
 </details>
 

+ 5 - 5
packages/client/ui-brand-official/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "面向侧栏与会话首屏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。"
+description: "面向侧栏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包向浏览器品牌槽位——`sidebar.brand.mark`、`sidebar.brand.name` 与 `conversation.hero.brand.mark`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。当部署身份就是 DeepSeek 自身时选择它;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。
+本包向侧栏品牌槽位——`sidebar.brand.mark` 与 `sidebar.brand.name`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。会话首屏槽位(`conversation.hero.brand.mark`)在所有构建中都保持无填充:其声明包以动画首屏鱼(悬停游动形变)作为回退渲染,而官方品牌正是这条鱼。当部署身份就是 DeepSeek 自身时选择本包;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。
 
 ## 目录
 
@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 选择 profile
 
-`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称、在会话首屏显示标志;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。
+`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。会话首屏无论 profile 如何都显示来自 `dsh-client-ui-conversation` 的动画首屏鱼,因为这个回退本身就是官方标志。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。
 
 ### 替换品牌
 
-自有身份的部署不组合本包,而是组合另一个占据相同三个槽位的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。
+自有身份的部署不组合本包,而是组合另一个占据侧栏槽位——以及本包留给回退的首屏槽位——的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。
 
 -----
 
@@ -43,7 +43,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏与会话声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;任一声明消失时全部三个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。
+个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;声明消失时两个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。
 
 </details>
 

+ 1 - 4
packages/client/ui-brand-official/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
-  "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots",
+  "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -32,7 +32,6 @@
   "dsh": {
     "client": {
       "inject": [
-        "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-sidebar"
       ],
@@ -45,14 +44,12 @@
   },
   "license": "MIT",
   "peerDependencies": {
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",

+ 2 - 5
packages/client/ui-brand-official/src/client/Brand.tsx

@@ -1,16 +1,13 @@
 import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 
-type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps
-
 /**
  * Render the official mark with the presentation requested by its host surface.
  * @param props - Host-supplied mark presentation.
  * @returns the official whale mark.
  */
-export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) {
-  return <FishLogo size={size} className={className} />
+export function OfficialBrandMark({ size }: SidebarBrandMarkOwnerProps) {
+  return <FishLogo size={size} />
 }
 
 /**

+ 7 - 8
packages/client/ui-brand-official/src/client/index.ts

@@ -1,6 +1,5 @@
 /** Official DeepSeek Harness occupants for the generic browser-brand slots. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
@@ -9,16 +8,16 @@ import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
 export const inject = ['slots']
 
 /**
- * Fill every shipped brand slot as one declaration-aware registration set.
+ * Fill the sidebar brand slots as one declaration-aware registration set. The
+ * conversation hero stays on its declaring package's animated fish fallback,
+ * so the official build registers nothing there.
  * @param ctx - Client root context.
  */
 export function apply(ctx: ClientContext): void {
   if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return
   ctx.slots.inject('sidebar.brand.mark', () =>
-    ctx.slots.inject('sidebar.brand.name', () =>
-      ctx.slots.inject('conversation.hero.brand.mark', function* () {
-        yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark)
-        yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName)
-        yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark)
-      })))
+    ctx.slots.inject('sidebar.brand.name', function* () {
+      yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark)
+      yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName)
+    }))
 }

+ 11 - 4
packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx

@@ -14,16 +14,17 @@ afterEach(() => {
 const HOLES = [
   'sidebar.brand.mark',
   'sidebar.brand.name',
-  'conversation.hero.brand.mark',
 ] as const
 
+const HERO_HOLE = 'conversation.hero.brand.mark'
+
 async function bench(declare = true) {
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const slots = ctx.get('slots') as SlotRegistry
   const declareHoles = () => slots.register({
     name: 'root',
-    children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
+    children: Object.fromEntries([...HOLES, HERO_HOLE].map(name => [name, { kind: 'single', scope: 'root' }])),
   } as never, () => null)
   const disposeHoles = declare ? declareHoles() : undefined
   return { ctx, slots, declareHoles, disposeHoles }
@@ -65,14 +66,20 @@ describe('official browser-brand plugin', () => {
     for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
   })
 
+  it('leaves the conversation hero on its declaring fallback even in official builds', async () => {
+    vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official')
+    const subject = await bench()
+    await subject.ctx.plugin({ inject: [...inject], apply }).await()
+    expect(subject.slots.entries(HERO_HOLE)).toHaveLength(0)
+  })
+
   it('renders the official name independently from both requested mark sizes', () => {
     const name = render(<OfficialBrandName />)
     expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24')
     name.unmount()
 
-    const mark = render(<OfficialBrandMark size={34} className="hero-mark" />)
+    const mark = render(<OfficialBrandMark size={34} />)
     expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34')
-    expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark')
     mark.rerender(<OfficialBrandMark size={24} />)
     expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24')
   })

+ 0 - 3
packages/client/ui-brand-official/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../ui-renderer"
     },
-    {
-      "path": "../ui-conversation"
-    },
     {
       "path": "../ui-primitives"
     },

File diff suppressed because it is too large
+ 13 - 2
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx


+ 19 - 8
packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css

@@ -41,6 +41,7 @@
 .headlineText {
   grid-row: 1;
   grid-column: 2;
+  white-space: pre;
 }
 
 .previewBadge {
@@ -61,7 +62,8 @@
   white-space: nowrap;
 }
 
-/* Keep hover detection on a stationary box while the mark moves within it. */
+/* Keep hover detection on a stationary box while the mark moves within it;
+   hover rules target the hitbox so a slot-occupant mark swims too. */
 .fishHitbox {
   grid-row: 1;
   grid-column: 1;
@@ -70,29 +72,38 @@
   justify-content: center;
 }
 
-/* Keep the hero mark in the same primary ink as its headline. */
+/* The swim morph's curves poke slightly past the viewBox edges, so clipping
+   would slice the tail mid-wag. The hero mark stays in the same primary ink
+   as its headline. */
 .fish {
-  color: var(--dsw-alias-label-primary);
+  display: block;
+  overflow: visible;
   transform-origin: 50% 60%;
+  color: var(--dsw-alias-label-primary);
 }
 
+/* Hover swim: a slow continuous sway about the body center — the head
+   (left) lifts as the whale rises, then settles back with a small
+   counter-tilt, like treading water. Negative rotation = head up (the head
+   is the left half; origin 50% 60%). */
 @keyframes hero-fish-swim {
-  0%, 100% {
-    transform: translate(0, 0) rotate(0deg);
+  0%,
+  100% {
+    transform: none;
   }
 
   35% {
-    transform: translate(-1px, -1px) rotate(-5deg);
+    transform: rotate(-4deg) translate(-0.4px, -0.9px);
   }
 
   70% {
-    transform: translate(1px, 0) rotate(3deg);
+    transform: rotate(1.6deg) translate(0.3px, 0.2px);
   }
 }
 
 @media (hover: hover) and (prefers-reduced-motion: no-preference) {
   .fishHitbox:hover .fish {
-    animation: hero-fish-swim var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+    animation: hero-fish-swim 1.6s ease-in-out infinite;
   }
 }
 

+ 5 - 5
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -311,7 +311,7 @@ describe('Hero chrome', () => {
   it('renders the English preview badge through the hero locale seat', () => {
     const renderSlot = vi.fn<HeroShellProps['renderSlot']>(() => null)
     const view = render(<HeroShell t={makeTranslate(en, commonEn)} renderSlot={renderSlot} />)
-    expect(view.getByText('Into the Unknown')).toBeTruthy()
+    expect(view.getByTestId('hero-headline').textContent).toBe('Into the Unknown')
     expect(view.getByText('Preview')).toBeTruthy()
     expect(renderSlot).toHaveBeenCalledOnce()
     expect(renderSlot.mock.calls[0]?.[0]).toBe('conversation.hero.brand.mark')
@@ -439,7 +439,7 @@ describe('ConversationRoot resident composer', () => {
     const header = b.view.container.querySelector('header')
     expect(host).not.toBeNull()
     expect(header?.getAttribute('aria-hidden')).toBe('true')
-    expect(b.view.getByText('探索未至之境')).toBeTruthy()
+    expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境')
     expect(b.view.getByText('预览版')).toBeTruthy()
     expect(b.view.queryByTestId('view-chat')).toBeNull()
     // The same machine-backed textarea is live in the hero, and the
@@ -480,7 +480,7 @@ describe('ConversationRoot resident composer', () => {
     const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' }))
     const root = b.view.container.querySelector('[data-phase]')
     expect(root?.getAttribute('data-phase')).toBe('settling')
-    expect(b.view.queryByText('探索未至之境')).toBeNull()
+    expect(b.view.queryByTestId('hero-headline')).toBeNull()
   })
 
   it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -505,7 +505,7 @@ describe('ConversationRoot resident composer', () => {
     // blank the column for the history round-trip.
     const root = b.view.container.querySelector('[data-phase]')
     expect(root?.getAttribute('data-phase')).toBe('hero')
-    expect(b.view.getByText('探索未至之境')).toBeTruthy()
+    expect(b.view.getByTestId('hero-headline').textContent).toBe('探索未至之境')
     expect(b.view.getByRole('textbox')).toBeTruthy()
   })
 
@@ -523,7 +523,7 @@ describe('ConversationRoot resident composer', () => {
     expect(b.wiring.snapshot.draft).toBe('kept across flip')
     expect(b.store.store.getSnapshot().draft).toBe('kept across flip')
     expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
-    expect(b.view.queryByText('探索未至之境')).toBeNull()
+    expect(b.view.queryByTestId('hero-headline')).toBeNull()
     expect(b.view.getByTestId('view-chat')).toBeTruthy()
   })
 

File diff suppressed because it is too large
+ 4 - 0
packages/client/ui-primitives/src/FishLogo.tsx


+ 1 - 1
packages/client/ui-primitives/src/index.ts

@@ -22,7 +22,7 @@ export { OnboardingSurface } from './OnboardingSurface.tsx'
 export { RiskConfirmation } from './RiskConfirmation.tsx'
 export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
 export { ConnectionBanner } from './ConnectionBanner.tsx'
-export { FishLogo } from './FishLogo.tsx'
+export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export type { BrandWordmarkProps } from './BrandWordmark.tsx'
 export { ReferenceIcon } from './ReferenceIcon.tsx'

+ 2 - 4
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -588,10 +588,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
-    occupants: [
-      'client-ui-brand-official OfficialBrandMark',
-    ],
-    replaceRisk: 'shadows-shipped-ui',
+    occupants: [],
+    replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
   },

+ 0 - 3
pnpm-lock.yaml

@@ -1947,9 +1947,6 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-client-ui-conversation':
-        specifier: workspace:^
-        version: link:../ui-conversation
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives

Some files were not shown because too many files changed in this diff