refactor(motion): retime content entry, stop the double and one-frame animations (#1282)
The stagger is the standard content entry on every migrated page (convention 11), so its budget matters more than any single surface. It ran 500ms per item in 80ms steps, putting the 10th row at 1220ms against a 300ms UI budget, and it only defined delays for children 1-10: on any list longer than ten rows, children 11+ inherited delay 0 and arrived BEFORE the middle of the list. Now 300ms per item in 40ms steps with the delay capped at 360ms, so the tail lands at ~660ms instead of ~1220ms. The cap is on the delay, never on the animation: the `both` fill holds a child at opacity 0 until its delay elapses, so excluding rows 11+ from the animation would paint them while rows 1-10 were still invisible. Adds --ease-emphasized (the strong ease-out) rather than retiming --ease-out, which is unlayered in :root and therefore shadows Tailwind's own token: changing it would retime every ease-out utility in the app. Foldout rows in JournalEntryList, periodiseringar and TransactionInboxCard sit directly inside a staggered tbody, so expanding a verifikat fired the inherited slideUp (with an invisible pre-roll of up to 320ms) on top of the foldout's own transition. They opt out via data-no-stagger. Also: the confirmed match on Hem faded at full height and then vanished in one frame, jumping everything below it 52px; it now grid-collapses over 200ms with the gap inside the collapsing area. The assistant sheet slid nothing while the page panel animated 300ms to make room for it; it now arrives along the same edge on the same curve, gated to first mount so re-expanding a collapsed session stays instant. Chat history no longer replays 20 simultaneous 500ms page-entry slides on resume: only genuinely new messages animate. Payroll wizard segments transition their colour. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3d02a74147
commit
108f348c84
@@ -343,7 +343,7 @@ export default function AccrualSchedulesPage() {
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="hover:bg-transparent">
|
||||
<tr data-no-stagger className="hover:bg-transparent">
|
||||
<td colSpan={7} className="border-b border-border bg-muted/30 p-0">
|
||||
<RowFoldout>
|
||||
<div className="px-6 py-4">
|
||||
|
||||
+30
-12
@@ -91,6 +91,12 @@
|
||||
|
||||
/* Animation */
|
||||
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
/* Strong ease-out for content entry. The --ease-out above is easeOutQuad,
|
||||
the weak built-in shape; it is deliberately left alone because this :root
|
||||
block is unlayered and therefore shadows Tailwind's own `ease-out` token,
|
||||
so retiming it would retime every ease-out utility in the app. New entry
|
||||
animations should use this one. */
|
||||
--ease-emphasized: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-base: 300ms;
|
||||
@@ -374,20 +380,32 @@ body {
|
||||
@utility slide-out-to-right { --tw-exit-translate-x: 100%; }
|
||||
@utility slide-out-to-right-full { --tw-exit-translate-x: 100%; }
|
||||
|
||||
/* Staggered entrance animation */
|
||||
.stagger-enter > * {
|
||||
animation: slideUp var(--duration-slow) var(--ease-out) both;
|
||||
/* Staggered entrance animation (convention 11). This is the standard content
|
||||
entry on every migrated page, so its budget matters more than any single
|
||||
surface: 300ms per item on the emphasized curve, in 40ms steps.
|
||||
It used to be 500ms per item in 80ms steps, which put the 10th row at
|
||||
1220ms, and it only defined delays for children 1-10, so on any list longer
|
||||
than ten rows children 11+ inherited delay 0 and arrived BEFORE the middle
|
||||
of the list.
|
||||
The cap is on the delay, never on the animation: `both` holds a child at
|
||||
opacity 0 until its delay elapses, so excluding rows 11+ from the animation
|
||||
(nth-child(-n+10)) would paint them at full opacity while rows 1-10 were
|
||||
still invisible. Rows carrying data-no-stagger opt out entirely, for the
|
||||
case of a foldout <tr> that runs its own transition inside a staggered
|
||||
tbody and would otherwise animate twice. */
|
||||
.stagger-enter > *:not([data-no-stagger]) {
|
||||
animation: slideUp var(--duration-base) var(--ease-emphasized) both;
|
||||
}
|
||||
.stagger-enter > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-enter > *:nth-child(2) { animation-delay: 80ms; }
|
||||
.stagger-enter > *:nth-child(3) { animation-delay: 160ms; }
|
||||
.stagger-enter > *:nth-child(4) { animation-delay: 240ms; }
|
||||
.stagger-enter > *:nth-child(5) { animation-delay: 320ms; }
|
||||
.stagger-enter > *:nth-child(6) { animation-delay: 400ms; }
|
||||
.stagger-enter > *:nth-child(7) { animation-delay: 480ms; }
|
||||
.stagger-enter > *:nth-child(8) { animation-delay: 560ms; }
|
||||
.stagger-enter > *:nth-child(9) { animation-delay: 640ms; }
|
||||
.stagger-enter > *:nth-child(10) { animation-delay: 720ms; }
|
||||
.stagger-enter > *:nth-child(2) { animation-delay: 40ms; }
|
||||
.stagger-enter > *:nth-child(3) { animation-delay: 80ms; }
|
||||
.stagger-enter > *:nth-child(4) { animation-delay: 120ms; }
|
||||
.stagger-enter > *:nth-child(5) { animation-delay: 160ms; }
|
||||
.stagger-enter > *:nth-child(6) { animation-delay: 200ms; }
|
||||
.stagger-enter > *:nth-child(7) { animation-delay: 240ms; }
|
||||
.stagger-enter > *:nth-child(8) { animation-delay: 280ms; }
|
||||
.stagger-enter > *:nth-child(9) { animation-delay: 320ms; }
|
||||
.stagger-enter > *:nth-child(n+10) { animation-delay: 360ms; }
|
||||
|
||||
/* Utility animations */
|
||||
.animate-fade-in {
|
||||
|
||||
@@ -28,6 +28,13 @@ import type { AgentStatusEvent } from './agent-status'
|
||||
import { sendFeedback, type FeedbackSentiment } from './feedback-client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// New messages arrive one at a time, so they enter on the short bubble curve.
|
||||
// The whole loaded history must NOT: `.animate-slide-up` is the 500ms
|
||||
// once-per-navigation page-entry animation, so resuming a 20-message thread
|
||||
// used to fire 20 simultaneous 500ms slides.
|
||||
const MESSAGE_ENTER_CLASS =
|
||||
'animate-in fade-in-0 slide-in-from-bottom-2 duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'
|
||||
|
||||
// Markdown parser loads separately from the chat surface: react-markdown +
|
||||
// remark-gfm pull in the whole unified/remark tree.
|
||||
//
|
||||
@@ -198,6 +205,9 @@ export default function AgentChat({
|
||||
const firstTurnFiredRef = useRef(false)
|
||||
const conversationIdRef = useRef<string | null>(initialConversationId ?? null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? [])
|
||||
// How many messages were already on screen when this thread mounted. Anything
|
||||
// at or past this index is new and animates in; the resumed history does not.
|
||||
const historyBaselineRef = useRef((initialMessages ?? []).length)
|
||||
// Read by the announcement effect, which must not re-run on every token: a
|
||||
// `messages` dependency would fire it hundreds of times per turn. Written in
|
||||
// an effect rather than during render: React may replay a render, and a
|
||||
@@ -775,7 +785,7 @@ export default function AgentChat({
|
||||
{messages.length === 0 && streaming && <SkeletonBubble />}
|
||||
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className="animate-slide-up">
|
||||
<div key={i} className={i >= historyBaselineRef.current ? MESSAGE_ENTER_CLASS : undefined}>
|
||||
<MessageBubble
|
||||
message={m}
|
||||
streamingTail={streaming && i === messages.length - 1}
|
||||
|
||||
@@ -81,6 +81,13 @@ export default function AgentSheet({
|
||||
}: Props) {
|
||||
// Live conversation id from the active AgentChat (fresh sessions report it via
|
||||
// onConversationIdChange; resumed ones we set directly on select).
|
||||
// Drops the enter class once the slide has played, so re-expanding a
|
||||
// collapsed session is instant rather than sliding in again.
|
||||
const [entering, setEntering] = useState(true)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setEntering(false), 320)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
// 'chat' shows the conversation; 'list' shows the session picker.
|
||||
const [view, setView] = useState<'chat' | 'list'>('chat')
|
||||
@@ -227,7 +234,13 @@ export default function AgentSheet({
|
||||
// conversation state in AgentChat survives) while removing it from view
|
||||
// and layout entirely (no stray horizontal scroll from an off-screen box).
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-200 ease-out',
|
||||
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
|
||||
// Arrive along the same edge, on the same curve and duration, as the
|
||||
// page panel that animates its margin to make room (layout.tsx). Gated
|
||||
// on first mount only: the panel stays mounted while collapsed, and
|
||||
// display:none -> visible would otherwise replay the slide every time
|
||||
// the user re-expands the same session.
|
||||
entering && 'animate-in slide-in-from-right-full fade-in-0',
|
||||
collapsed && 'hidden',
|
||||
// Expanded grows the panel leftward over the page (still non-modal: the
|
||||
// page stays interactive); normal is the compact side sheet.
|
||||
|
||||
@@ -1231,7 +1231,7 @@ export default function JournalEntryList() {
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<tr data-no-stagger>
|
||||
<td colSpan={6} className="border-b border-border p-0">
|
||||
<RowFoldout>
|
||||
<div className="px-1 pb-6 pt-1 sm:pl-9 sm:pr-4">
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function AttGoraSection({
|
||||
next.delete(match.transaction_id)
|
||||
return next
|
||||
})
|
||||
}, 300)
|
||||
}, 200)
|
||||
void refetchCounts()
|
||||
} catch {
|
||||
toast({ title: t('suggested_failed_toast'), variant: 'destructive' })
|
||||
@@ -237,7 +237,7 @@ export default function AttGoraSection({
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('suggested_title')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{matches.map((match) => {
|
||||
const isLeaving = leavingIds.has(match.transaction_id)
|
||||
const isConfirming = confirmingId === match.transaction_id
|
||||
@@ -245,10 +245,12 @@ export default function AttGoraSection({
|
||||
<div
|
||||
key={match.transaction_id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded bg-secondary/40 px-3 py-2 transition-opacity duration-300',
|
||||
isLeaving && 'opacity-0',
|
||||
'grid transition-[grid-template-rows,opacity] duration-200 motion-reduce:transition-none',
|
||||
isLeaving ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr]',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden pb-1">
|
||||
<div className="flex items-center gap-3 rounded bg-secondary/40 px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">
|
||||
{match.transaction_description}
|
||||
@@ -294,6 +296,8 @@ export default function AttGoraSection({
|
||||
t('suggested_confirm')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -244,7 +244,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
|
||||
<div className="md:hidden space-y-3">
|
||||
<div className="flex gap-1">
|
||||
{steps.map(step => (
|
||||
<span key={step.key} className={`h-1 flex-1 rounded-full ${segClass(step.state)}`} />
|
||||
<span key={step.key} className={`h-1 flex-1 rounded-full transition-colors duration-150 ${segClass(step.state)}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -275,7 +275,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
|
||||
<ol className="flex gap-2">
|
||||
{steps.map(step => (
|
||||
<li key={step.key} className="flex-1 min-w-0 space-y-2">
|
||||
<span className={`block h-1 rounded-full ${segClass(step.state)}`} aria-hidden />
|
||||
<span className={`block h-1 rounded-full transition-colors duration-150 ${segClass(step.state)}`} aria-hidden />
|
||||
<p
|
||||
className={`text-[11px] truncate ${
|
||||
step.state === 'active'
|
||||
|
||||
@@ -453,7 +453,7 @@ export default function TransactionInboxCard({
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<tr data-no-stagger>
|
||||
<td colSpan={5} className="border-b border-border p-0">
|
||||
<RowFoldout>
|
||||
<div className="pb-6 pt-1">
|
||||
|
||||
Reference in New Issue
Block a user