diff --git a/app/(dashboard)/bookkeeping/periodiseringar/page.tsx b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx index 4ca38264..3b09436f 100644 --- a/app/(dashboard)/bookkeeping/periodiseringar/page.tsx +++ b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx @@ -343,7 +343,7 @@ export default function AccrualSchedulesPage() { {isOpen && ( - +
diff --git a/app/globals.css b/app/globals.css index 433eabfc..748234ba 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 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 { diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 6a1742cd..4bd95a4c 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -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(initialConversationId ?? null) const [messages, setMessages] = useState(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 && } {messages.map((m, i) => ( -
+
= historyBaselineRef.current ? MESSAGE_ENTER_CLASS : undefined}> { + const t = setTimeout(() => setEntering(false), 320) + return () => clearTimeout(t) + }, []) const [conversationId, setConversationId] = useState(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. diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 18a7138b..bbfd192c 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -1231,7 +1231,7 @@ export default function JournalEntryList() { {isExpanded && ( - +
diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 574c7b07..6cb328fa 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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({

{t('suggested_title')}

-
+
{matches.map((match) => { const isLeaving = leavingIds.has(match.transaction_id) const isConfirming = confirmingId === match.transaction_id @@ -245,10 +245,12 @@ export default function AttGoraSection({
+
+

{match.transaction_description} @@ -294,6 +296,8 @@ export default function AttGoraSection({ t('suggested_confirm') )} +

+
) })} diff --git a/components/salary/run/RunProgressBar.tsx b/components/salary/run/RunProgressBar.tsx index c5e5183b..04bda922 100644 --- a/components/salary/run/RunProgressBar.tsx +++ b/components/salary/run/RunProgressBar.tsx @@ -244,7 +244,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
{steps.map(step => ( - + ))}
@@ -275,7 +275,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
    {steps.map(step => (
  1. - +

    {expanded && ( - +