diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml index 47ca707476..554c1976d5 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.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 .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 diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md index 2ae090f2e9..c485f94e24 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md index eac0047a08..46fa50732b 100644 --- a/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md @@ -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 几何将来变化,烘焙常量必须随之重新生成。 diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 6cbf63a20b..90c8a32140 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -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 (
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - + { + 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: , + fallback: , })} - + {t('hero.headline')} {t('hero.preview')} diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index f07152b3d2..889a140fe6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -41,7 +41,6 @@ .headlineText { grid-row: 1; grid-column: 2; - white-space: pre; } .previewBadge { diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index d40e0d1ed1..ed8a222e4f 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/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(() => null) const view = render() - 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() })