deepseek-harness/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md
Yif ececf8c170 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.
2026-08-28 19:29:31 +08:00

3.4 KiB

Agent Note: Hero fish hover swim morph

Status: implemented

English | 中文

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) 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. 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 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.