From 3b187afd753093fda144e93c895144cda8476c9e Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 01:00:08 +0800 Subject: [PATCH 1/2] refactor(ui): extract shared kinetic copy The account panel owned a reusable line transition inside the feature. Move the existing renderer and styles into shared UI and keep account timing intact. Other product surfaces can now consume the same line-level motion boundary. --- .../features/account-panel/account-panel.css | 64 ----------------- frontend/src/features/account-panel/index.tsx | 34 +-------- frontend/src/shared/ui/index.ts | 2 + frontend/src/shared/ui/kinetic-copy.css | 71 +++++++++++++++++++ frontend/src/shared/ui/kinetic-copy.tsx | 33 +++++++++ 5 files changed, 108 insertions(+), 96 deletions(-) create mode 100644 frontend/src/shared/ui/kinetic-copy.css create mode 100644 frontend/src/shared/ui/kinetic-copy.tsx diff --git a/frontend/src/features/account-panel/account-panel.css b/frontend/src/features/account-panel/account-panel.css index d4a5c4bd..7b75a1f3 100644 --- a/frontend/src/features/account-panel/account-panel.css +++ b/frontend/src/features/account-panel/account-panel.css @@ -173,41 +173,6 @@ animation: auth-copy-enter 360ms 130ms var(--auth-ease-enter) both; } -.auth-copy-cycle { - display: grid; - place-items: center; -} - -.auth-copy-line { - display: block; - overflow: hidden; - min-height: 1.55em; -} - -.auth-copy-line-inner { - display: block; - opacity: 0; - letter-spacing: 0.075em; - transform: translate3d(0, 120%, 0); - animation: auth-copy-line-enter 620ms var(--auth-ease-enter) forwards; - animation-delay: calc(210ms + var(--auth-line-index) * 90ms); -} - -.auth-copy-cycle-resting .auth-copy-line-inner { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - animation: none; -} - -.auth-copy-cycle-exiting .auth-copy-line-inner { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - animation: auth-copy-line-exit 400ms cubic-bezier(0.55, 0, 1, 0.45) forwards; - animation-delay: calc((1 - var(--auth-line-index)) * 55ms); -} - .auth-register-fields { position: relative; z-index: 5; @@ -725,35 +690,6 @@ } } -@keyframes auth-copy-line-enter { - 0% { - opacity: 0; - letter-spacing: 0.075em; - transform: translate3d(0, 120%, 0); - } - 100% { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - } -} - -@keyframes auth-copy-line-exit { - 0% { - opacity: 1; - letter-spacing: -0.01em; - transform: translate3d(0, 0, 0); - } - 46% { - opacity: 1; - } - 100% { - opacity: 0; - letter-spacing: 0.045em; - transform: translate3d(0, -118%, 0); - } -} - @keyframes auth-form-exit { 0% { opacity: 1; diff --git a/frontend/src/features/account-panel/index.tsx b/frontend/src/features/account-panel/index.tsx index 212b7803..2f98fffb 100644 --- a/frontend/src/features/account-panel/index.tsx +++ b/frontend/src/features/account-panel/index.tsx @@ -27,6 +27,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { useAuthSession } from '@/features/auth-session' import { sanitizeInternalPath } from '@/shared/navigation' +import { KineticCopy, type KineticCopyPhase } from '@/shared/ui' import messengerPigeon from '@/assets/auth/illustrations/messenger-pigeon.webp' import './account-panel.css' @@ -34,7 +35,6 @@ import './account-panel.css' type AccountEntry = 'login' | 'register' type LoginMode = 'code' | 'password' type MotionDirection = 'forward' | 'backward' -type CopyPhase = 'entering' | 'resting' | 'exiting' const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const CODE_PATTERN = /^\d{6}$/ @@ -129,36 +129,6 @@ function KineticTitle({ id, text, emphasis }: { id: string; text: string; emphas ) } -function KineticCopy({ - lines, - copyKey, - phase, -}: { - lines: readonly [string, string] - copyKey: string - phase: CopyPhase -}) { - return ( - - ) -} - type AuthFieldProps = Omit, 'onChange'> & { label: string icon: Icon @@ -247,7 +217,7 @@ function AccountPanelDialog({ entry }: { entry: AccountEntry }) { const [registerStep, setRegisterStep] = useState(0) const [motionDirection, setMotionDirection] = useState('forward') const [copyIndex, setCopyIndex] = useState(0) - const [copyPhase, setCopyPhase] = useState('entering') + const [copyPhase, setCopyPhase] = useState('entering') const [isExiting, setIsExiting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index 8aeaf703..35f90abf 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1,3 +1,5 @@ +export { KineticCopy } from './kinetic-copy' +export type { KineticCopyPhase, KineticCopyProps } from './kinetic-copy' export { PageContainer } from './page-container' export type { PageContainerProps } from './page-container' export { Pagination } from './pagination' diff --git a/frontend/src/shared/ui/kinetic-copy.css b/frontend/src/shared/ui/kinetic-copy.css new file mode 100644 index 00000000..5ae2ea1e --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.css @@ -0,0 +1,71 @@ +.auth-copy-cycle { + display: grid; + place-items: center; +} + +.auth-copy-line { + display: block; + overflow: hidden; + min-height: 1.55em; +} + +.auth-copy-line-inner { + display: block; + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + animation: auth-copy-line-enter 620ms var(--auth-ease-enter) forwards; + animation-delay: calc(210ms + var(--auth-line-index) * 90ms); +} + +.auth-copy-cycle-resting .auth-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: none; +} + +.auth-copy-cycle-exiting .auth-copy-line-inner { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + animation: auth-copy-line-exit 400ms cubic-bezier(0.55, 0, 1, 0.45) forwards; + animation-delay: calc((1 - var(--auth-line-index)) * 55ms); +} + +@keyframes auth-copy-line-enter { + 0% { + opacity: 0; + letter-spacing: 0.075em; + transform: translate3d(0, 120%, 0); + } + 100% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } +} + +@keyframes auth-copy-line-exit { + 0% { + opacity: 1; + letter-spacing: -0.01em; + transform: translate3d(0, 0, 0); + } + 46% { + opacity: 1; + } + 100% { + opacity: 0; + letter-spacing: 0.045em; + transform: translate3d(0, -118%, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-copy-line-inner { + animation: none; + filter: none; + transform: none; + } +} diff --git a/frontend/src/shared/ui/kinetic-copy.tsx b/frontend/src/shared/ui/kinetic-copy.tsx new file mode 100644 index 00000000..85d23381 --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties } from 'react' + +import './kinetic-copy.css' + +export type KineticCopyPhase = 'entering' | 'resting' | 'exiting' + +export type KineticCopyProps = { + lines: readonly [string, string] + copyKey: string + phase: KineticCopyPhase +} + +export function KineticCopy({ lines, copyKey, phase }: KineticCopyProps) { + return ( + + ) +} From 124e1327cbbb1a5d87a8150fa0ae77be4198fc11 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 14 Aug 2026 01:00:08 +0800 Subject: [PATCH 2/2] test(ui): cover shared kinetic copy phases The extracted transition needs direct coverage outside the account panel. Assert both lines, phase classes, accessibility state, and timing indexes. The shared animation contract can now change without silent markup drift. --- frontend/src/shared/ui/kinetic-copy.test.tsx | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/src/shared/ui/kinetic-copy.test.tsx diff --git a/frontend/src/shared/ui/kinetic-copy.test.tsx b/frontend/src/shared/ui/kinetic-copy.test.tsx new file mode 100644 index 00000000..a1a1bc55 --- /dev/null +++ b/frontend/src/shared/ui/kinetic-copy.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { KineticCopy } from './kinetic-copy' + +afterEach(cleanup) + +describe('KineticCopy', () => { + it.each(['entering', 'resting', 'exiting'] as const)( + 'renders both copy lines in the %s phase', + (phase) => { + const { container } = render( + , + ) + + const cycle = container.querySelector('[data-copy-phase]') + const lineInners = container.querySelectorAll('.auth-copy-line-inner') + + expect(cycle?.dataset.copyPhase).toBe(phase) + expect(cycle?.className).toContain(`auth-copy-cycle-${phase}`) + expect(cycle?.getAttribute('aria-hidden')).toBe('true') + expect(screen.getByText('继续搭建,')).toBeTruthy() + expect(screen.getByText('属于你的角色世界。')).toBeTruthy() + expect(lineInners).toHaveLength(2) + expect(lineInners[0]?.style.getPropertyValue('--auth-line-index')).toBe('0') + expect(lineInners[1]?.style.getPropertyValue('--auth-line-index')).toBe('1') + }, + ) +})