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:
Yif
2026-08-10 23:59:53 +08:00
parent a8fae974c2
commit cac8e1c53d
8 changed files with 213 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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()
})

View File

@@ -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' })

View File

@@ -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()