fix(client): finish the preset intro inside one shared reveal window
A fixed 60ms per-character tick made a Latin preset name run three times longer than its CJK counterpart. The stagger is now capped by a 200ms shared window (min(40, 200/(n-1))), the icon lands in 150ms with the characters starting the moment it does, and the whole timeline is pinned by component tests alongside the store acknowledgement and the empty custom group.
This commit is contained in:
@@ -36,11 +36,13 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the
|
||||
name's characters fade up on a stagger (delays set inline per character).
|
||||
All chars occupy their width from the start, so nothing reflows mid-run. */
|
||||
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
|
||||
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
|
||||
then the name's characters fade up on a stagger (delays set inline per
|
||||
character). All chars occupy their width from the start, so nothing
|
||||
reflows mid-run. */
|
||||
.introIcon {
|
||||
animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes seat-icon-in {
|
||||
|
||||
@@ -36,13 +36,27 @@ export interface AgentPresetSeatInjected {
|
||||
introduced: () => void
|
||||
}
|
||||
|
||||
/* Introduce timeline: the icon eases in first; the name's characters start
|
||||
fading up once the icon has mostly landed, one every stagger tick, each
|
||||
taking the fade duration to settle. The cue clears after the last one. */
|
||||
const INTRO_TEXT_DELAY_MS = 300
|
||||
const INTRO_CHAR_STAGGER_MS = 60
|
||||
/* Introduce timeline: the icon eases in first (the CSS animation shares this
|
||||
duration); the name's characters start fading up the moment it lands, each
|
||||
taking the fade duration to settle. The cue clears after the last one. The
|
||||
stagger is capped twice: per tick for short CJK names, and by one shared
|
||||
reveal window so a long Latin name finishes in the same time as its CJK
|
||||
counterpart instead of dragging the run out per character. */
|
||||
const INTRO_TEXT_DELAY_MS = 150
|
||||
const INTRO_CHAR_STAGGER_MS = 40
|
||||
const INTRO_TEXT_REVEAL_MS = 200
|
||||
const INTRO_CHAR_FADE_MS = 400
|
||||
|
||||
/**
|
||||
* Per-character start offset for the introduce reveal.
|
||||
* @param count - character count of the shown preset name.
|
||||
* @returns milliseconds between successive character starts.
|
||||
*/
|
||||
function introStaggerMs(count: number): number {
|
||||
if (count <= 1) return 0
|
||||
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.hero.agentPreset'>
|
||||
@@ -83,7 +97,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
const done = window.setTimeout(() => {
|
||||
setIntroducing(false)
|
||||
introduced()
|
||||
}, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS)
|
||||
}, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS)
|
||||
return () => { window.clearTimeout(done) }
|
||||
}, [state.introduce, ready, label, introduced])
|
||||
|
||||
@@ -93,14 +107,16 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
const characters = Array.from(label)
|
||||
const stagger = introStaggerMs(characters.length)
|
||||
const shownLabel = introducing
|
||||
? (
|
||||
<span className={css.introText}>
|
||||
{Array.from(label).map((character, index) => (
|
||||
{characters.map((character, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={css.introChar}
|
||||
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * INTRO_CHAR_STAGGER_MS}ms` }}
|
||||
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
|
||||
@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(section.startCreatorDraft).toBeDefined()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
expect(workspaces.starts).toHaveLength(1)
|
||||
|
||||
// A cross-screen stage carries the introduce cue; the chip acknowledges
|
||||
// it once, and a repeat acknowledgement leaves the snapshot untouched.
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
|
||||
seat.introduced()
|
||||
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
|
||||
expect(acknowledged.introduce).toBe(false)
|
||||
seat.introduced()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
|
||||
conversation()
|
||||
})
|
||||
|
||||
|
||||
@@ -277,6 +277,94 @@ describe('the new-session chip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the chip introduce cue', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Character spans carry inline animation delays; nothing else does. */
|
||||
function delayedChars(): HTMLElement[] {
|
||||
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
|
||||
}
|
||||
|
||||
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Eleven characters split the 200ms window into 20ms steps, where the
|
||||
// fixed 40ms tick would have doubled the run for a Latin name.
|
||||
const chars = delayedChars()
|
||||
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
|
||||
expect(chars[0]!.style.animationDelay).toBe('150ms')
|
||||
expect(chars[1]!.style.animationDelay).toBe('170ms')
|
||||
expect(chars[10]!.style.animationDelay).toBe('350ms')
|
||||
|
||||
// 150 delay + 200 window + 400 fade: acknowledged only once the last
|
||||
// character has settled, and the label is plain text again after.
|
||||
act(() => { vi.advanceTimersByTime(749) })
|
||||
expect(actions.introduced).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the per-tick cap for a short CJK name', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Four characters fit under the window, so the 40ms tick applies as-is.
|
||||
const chars = delayedChars()
|
||||
expect(chars).toHaveLength(4)
|
||||
expect(chars[1]!.style.animationDelay).toBe('190ms')
|
||||
expect(chars[3]!.style.animationDelay).toBe('270ms')
|
||||
})
|
||||
|
||||
it('starts a one-character name with no stagger at all', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'C' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
|
||||
act(() => { vi.advanceTimersByTime(550) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the run under reduced motion and acknowledges at once', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
const actions = renderSeat({ introduce: true })
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('acknowledges an empty staged name without arming a run', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
@@ -253,6 +253,20 @@ describe('the preset list', () => {
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
|
||||
renderSection({
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
|
||||
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
|
||||
],
|
||||
})
|
||||
|
||||
// No member yet, but the place where one's own preset will appear stays.
|
||||
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
|
||||
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
|
||||
renderSection()
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user