fix(web): address hero fish hover review feedback

Move hover enter/leave from the svg element to the stationary fishHitbox
span so the CSS sway and SMIL morph share the same trigger surface and the
animation cannot flicker when sway displaces the svg from the pointer.
Gate the morph with (hover: hover) to match the CSS and prevent tap-sticky
loops on touch devices.

Remove residual headline changes unrelated to this PR: white-space: pre on
.headlineText, data-testid="hero-headline", and corresponding getByTestId
test assertions — these belong to #2397.

Fix Agent Note coverage claim (was "asserting render states", now accurately
"slot contract") and compress blowhole paragraph to current-state fact.
This commit is contained in:
Yif
2026-08-28 19:29:31 +08:00
parent 5e610e1a74
commit ececf8c170
6 changed files with 25 additions and 24 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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
2026-08-12-hero-fish-hover-swim-morph.md: c485f94e244040d7e72838065f977e698391c00d
2026-08-12-hero-fish-hover-swim-morph.zh.md: 46fa50732bf98aa5e3afb503e84f8e23001499b2
@@ -18,10 +18,10 @@ The morphing fish reaches the hero as the fallback of the `conversation.hero.bra
**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.
**Blowhole spout on hover.** Removed at the user's request; hover keeps only 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.
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 `skeleton.client.spec.tsx` suite asserting slot contract (name, owner props, fallback existence); the keyless snapshot harness records transcripts, not browser animation, so visual verification of the morph 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.
@@ -18,10 +18,10 @@ hover New Session 主页的鲸鱼(`dsh-client-ui-conversation` 的 `EmptyHero.
**用矢量工具编辑路径做变形。** 流程中没有可交互的工具;选择程序化加权变形,因为它保证 SMIL `d` 插值所要求的完全一致的命令结构,且振幅是可评审的数字。
**hover 气孔喷水。** 最初以三颗上升水珠的形式上线(更复杂的鲸鱼喷泉、泡泡簇、弹道水花造型都做出来后在评审中被否),随后按用户要求整体移除hover 只保留形状变形与摇摆。
**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 几何将来变化,烘焙常量必须随之重新生成。
hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由 `skeleton.client.spec.tsx` 断言 slot 合约(名称、owner props、fallback 存在性);keyless 快照体系记录对话转录而非浏览器动画,变形的视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。
@@ -126,13 +126,12 @@ const HERO_SWIM_DOWN_PATH =
* itself morphs — SMIL interpolates `d` through the tail-up and tail-down
* targets on the same 1.6s period, so the tail wags and the fin flutters in
* real curve deformation. Decorative — hidden from the accessibility tree;
* reduced motion keeps the static filled logo on hover.
* reduced motion keeps the static filled logo on hover (sampled at
* mouseenter; a mid-hover preference change takes effect on the next enter).
* @param props.hovering - driven by the hitbox parent's pointer state.
* @returns the fish svg element.
*/
function HeroFish() {
// Hover echo for the SMIL body morph: SMIL cannot ride CSS media queries,
// so the reduced-motion check gates the state instead of the style.
const [hovering, setHovering] = useState(false)
function HeroFish({ hovering }: { hovering: boolean }) {
return (
<svg
className={css.fish}
@@ -141,12 +140,6 @@ function HeroFish() {
viewBox={`0 0 ${FISH_LOGO_VIEWBOX.width} ${FISH_LOGO_VIEWBOX.height}`}
fill="none"
aria-hidden="true"
onMouseEnter={() => {
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) setHovering(true)
}}
onMouseLeave={() => {
setHovering(false)
}}
>
<path d={FISH_LOGO_PATH} fill="currentColor">
{hovering && (
@@ -172,17 +165,26 @@ function HeroFish() {
* @returns the centered hero element tree.
*/
export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
const [hovering, setHovering] = useState(false)
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<span className={css.fishHitbox}>
<span
className={css.fishHitbox}
onMouseEnter={() => {
if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
setHovering(true)
}
}}
onMouseLeave={() => { setHovering(false) }}
>
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
fallback: <HeroFish />,
fallback: <HeroFish hovering={hovering} />,
})}
</span>
<span className={css.headlineText} data-testid="hero-headline">
<span className={css.headlineText}>
{t('hero.headline')}
</span>
<span className={css.previewBadge}>{t('hero.preview')}</span>
@@ -41,7 +41,6 @@
.headlineText {
grid-row: 1;
grid-column: 2;
white-space: pre;
}
.previewBadge {
@@ -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.getByTestId('hero-headline').textContent).toBe('Into the Unknown')
expect(view.getByText('Into the Unknown')).toBeTruthy()
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.getByTestId('hero-headline').textContent).toBe('探索未至之境')
expect(b.view.getByText('探索未至之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -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.getByTestId('hero-headline').textContent).toBe('探索未至之境')
expect(b.view.getByText('探索未至之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})