Merge pull request #1274 from BradGroux/fix/intentional-motion-contract-1259

fix: constrain shared interface motion
This commit is contained in:
Brad Groux 2026-08-30 03:27:02 -05:00 • committed by GitHub
commit 676f2d98f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 66 additions and 44 deletions

View file

@ -12,12 +12,33 @@ vi.mock('@/components/dashboard/Dashboard', () => ({
const motionSources = [
'components/activity/ActivityFeed.tsx',
'components/board/BoardSidebar.tsx',
'components/board/KanbanColumn.tsx',
'components/board/MultiAgentPanel.tsx',
'components/chat/FloatingChat.tsx',
'components/dashboard/Dashboard.tsx',
'components/dashboard/DashboardPage.tsx',
'components/dashboard/DashboardSection.tsx',
'components/dashboard/HourlyActivityChart.tsx',
'components/dashboard/StatusTimeline.tsx',
'components/dashboard/WhereTimeWent.tsx',
'components/layout/ActivitySidebar.tsx',
'components/task/MultiAgentSelector.tsx',
'components/task/TaskCard.tsx',
'components/templates/TemplatesPage.tsx',
'components/ui/badge.tsx',
'components/ui/button.tsx',
'components/ui/switch.tsx',
'components/ui/tabs.tsx',
];
const stableStatusSources = [
'components/board/BoardSidebar.tsx',
'components/board/MultiAgentPanel.tsx',
'components/chat/FloatingChat.tsx',
'components/task/AgentPanel.tsx',
'components/task/TaskCard.tsx',
'components/task/TimeTrackingSection.tsx',
];
const storage = new Map<string, string>();
@ -53,6 +74,23 @@ describe('dashboard motion contract', () => {
}
});
it('keeps persistent live and unread status treatments stable', () => {
for (const file of stableStatusSources) {
const source = readFileSync(resolve(process.cwd(), 'src', file), 'utf8');
expect(source, file).not.toContain('animate-ping');
expect(source, file).not.toContain('animate-pulse');
expect(source, file).not.toMatch(/hover:(?:scale|translate)/);
}
const taskCard = readFileSync(
resolve(process.cwd(), 'src/components/task/TaskCard.tsx'),
'utf8'
);
expect(taskCard).toContain('motion-safe:rotate-2');
expect(taskCard).toContain('motion-safe:scale-105');
});
it('expands and collapses immediately with explicit accessible state', async () => {
const user = userEvent.setup();
renderWithProviders(<DashboardSection />);

View file

@ -174,7 +174,7 @@ function AgentStatusPanel({ onTaskClick }: { onTaskClick?: (taskId: string) => v
{/* Status header — large icon + state */}
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ring-2 transition-all"
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ring-2 transition-colors"
style={{ backgroundColor: config.bgColor, ['--tw-ring-color' as string]: config.color }}
>
<Icon className="w-5 h-5 transition-colors" style={{ color: config.color }} />
@ -186,7 +186,7 @@ function AgentStatusPanel({ onTaskClick }: { onTaskClick?: (taskId: string) => v
>
{state !== 'idle' && (
<span
className="inline-block w-2 h-2 rounded-full animate-pulse"
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: config.color }}
/>
)}
@ -226,7 +226,7 @@ function AgentStatusPanel({ onTaskClick }: { onTaskClick?: (taskId: string) => v
onClick={() => agent.taskId && onTaskClick?.(agent.taskId)}
>
<span
className="inline-block w-2 h-2 rounded-full mt-1 shrink-0 animate-pulse"
className="inline-block w-2 h-2 rounded-full mt-1 shrink-0"
style={{ backgroundColor: agentState.color }}
/>
<div className="min-w-0 flex-1">

View file

@ -75,7 +75,7 @@ export function KanbanColumn({
aria-labelledby={`column-heading-${id}`}
aria-roledescription="kanban column"
className={cn(
'flex flex-col rounded-lg bg-muted/50 border-t-2 transition-all',
'flex flex-col rounded-lg bg-muted/50 border-t-2 transition-colors',
columnColors[id] ?? 'border-t-primary',
dragEnabled && isOver && 'ring-2 ring-primary/50 bg-muted/70'
)}

View file

@ -60,7 +60,7 @@ function AgentCard({
return (
<div
className="rounded-md border transition-all"
className="rounded-md border transition-colors"
style={{ borderColor: `${style.color}30`, backgroundColor: style.bg }}
>
{/* Header */}
@ -69,10 +69,7 @@ function AgentCard({
onClick={() => setExpanded(!expanded)}
>
{/* Status dot */}
<span
className={`w-2 h-2 rounded-full shrink-0 ${isActive ? 'animate-pulse' : ''}`}
style={{ backgroundColor: style.color }}
/>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: style.color }} />
{/* Name + model */}
<div className="flex-1 min-w-0">

View file

@ -58,16 +58,15 @@ export function FloatingChat() {
className={cn(
'floating-chat-trigger z-40 h-14 w-14 rounded-full shadow-lg',
'bg-primary hover:bg-primary/90 text-primary-foreground',
'transition-all duration-200 hover:scale-105',
'transition-colors duration-150',
open && 'hidden'
)}
aria-label="Open chat"
>
<MessageSquare className="h-6 w-6" />
{hasUnread && (
<span className="absolute -top-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-4 w-4 bg-emerald-500" />
<span className="absolute -top-1 -right-1 flex h-4 w-4" aria-hidden="true">
<span className="inline-flex h-4 w-4 rounded-full bg-emerald-500" />
</span>
)}
</ActionIcon>

View file

@ -169,19 +169,10 @@ function DailySummaryCard() {
{/* Mini progress bar */}
{total > 0 && (
<div className="mt-2 h-1.5 rounded-full overflow-hidden flex bg-muted">
<div
className="bg-green-500 transition-all"
style={{ width: `${(summary.activeMs / total) * 100}%` }}
/>
<div
className="bg-gray-400 transition-all"
style={{ width: `${(summary.idleMs / total) * 100}%` }}
/>
<div className="bg-green-500" style={{ width: `${(summary.activeMs / total) * 100}%` }} />
<div className="bg-gray-400" style={{ width: `${(summary.idleMs / total) * 100}%` }} />
{summary.errorMs > 0 && (
<div
className="bg-red-500 transition-all"
style={{ width: `${(summary.errorMs / total) * 100}%` }}
/>
<div className="bg-red-500" style={{ width: `${(summary.errorMs / total) * 100}%` }} />
)}
</div>
)}

View file

@ -283,7 +283,7 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) {
)}
{isAgentRunning && (
<Text component="span" size="xs" c="green" className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="h-2 w-2 rounded-full bg-green-500" />
Running
</Text>
)}

View file

@ -67,8 +67,7 @@ export function MultiAgentSelector({
const removeAgent = (agentId: string) => {
const newAgents = agents.filter((a) => a !== agentId);
// If we removed the primary, promote next
const primary =
primaryAgent === agentId ? newAgents[0] || undefined : primaryAgent;
const primary = primaryAgent === agentId ? newAgents[0] || undefined : primaryAgent;
onChange(newAgents, primary);
};
@ -97,7 +96,7 @@ export function MultiAgentSelector({
return (
<span
key={agent}
className={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full cursor-pointer transition-all ${
className={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full cursor-pointer transition-colors ${
isPrimary ? 'ring-1' : ''
}`}
style={{
@ -110,9 +109,7 @@ export function MultiAgentSelector({
>
<Bot className="w-3 h-3" />
{agent}
{isPrimary && (
<span className="text-[8px] font-bold opacity-60">★</span>
)}
{isPrimary && <span className="text-[8px] font-bold opacity-60">★</span>}
<button
className="ml-0.5 hover:bg-white/20 rounded-full p-0.5 transition-colors"
onClick={(e) => {
@ -128,7 +125,7 @@ export function MultiAgentSelector({
{/* Add button */}
<button
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-muted-foreground px-2 py-0.5 rounded-full border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 transition-all"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-muted-foreground px-2 py-0.5 rounded-full border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 transition-colors"
onClick={() => setShowDropdown(!showDropdown)}
>
<Plus className="w-3 h-3" />

View file

@ -368,10 +368,10 @@ export const TaskCard = memo(function TaskCard({
'group bg-card border border-border rounded-md',
dragEnabled ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer',
isCompact ? 'p-2' : 'p-3',
'hover:border-muted-foreground/50 hover:bg-card/80 transition-all',
'hover:border-muted-foreground/50 hover:bg-card/80 transition-colors',
'border-l-2',
typeColor,
isDragging && 'opacity-50 shadow-lg rotate-2 scale-105',
isDragging && 'opacity-50 shadow-lg motion-safe:rotate-2 motion-safe:scale-105',
isCurrentlyDragging && 'opacity-50',
isSelected && 'ring-2 ring-primary border-primary',
isAgentRunning &&
@ -423,7 +423,7 @@ export const TaskCard = memo(function TaskCard({
</div>
}
>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 flex items-center gap-1 animate-pulse">
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 flex items-center gap-1">
<span className="sr-only">
Agent {agentNames[task.attempt?.agent || ''] || task.attempt?.agent} is actively
running on this task
@ -666,7 +666,7 @@ export const TaskCard = memo(function TaskCard({
)}
>
{task.timeTracking?.isRunning ? (
<Timer className="h-3 w-3 animate-pulse" />
<Timer className="h-3 w-3" />
) : (
<Clock className="h-3 w-3" />
)}

View file

@ -223,7 +223,7 @@ export function TimeTrackingSection({ task }: TimeTrackingSectionProps) {
{isRunning && activeEntry && (
<Group gap="xs">
<Timer className="h-4 w-4 text-green-500 animate-pulse" />
<Timer className="h-4 w-4 text-green-500" />
<RunningTimer startTime={activeEntry.startTime} />
</Group>
)}
@ -319,7 +319,7 @@ export function TimeTrackingSection({ task }: TimeTrackingSectionProps) {
<Box className="min-w-0 flex-1">
<Group gap="xs">
{isActive ? (
<Timer className="h-3 w-3 text-green-500 animate-pulse flex-shrink-0" />
<Timer className="h-3 w-3 text-green-500 flex-shrink-0" />
) : (
<Clock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}

View file

@ -180,7 +180,7 @@ export function TemplatesPage({ onBack }: TemplatesPageProps) {
<div
key={template.id}
className={cn(
'p-4 cursor-pointer transition-all border rounded-lg hover:border-primary hover:shadow-md',
'p-4 cursor-pointer transition-colors border rounded-lg hover:border-primary hover:bg-muted/30',
selectedTemplate?.id === template.id && 'border-primary bg-primary/5'
)}
onClick={() => setSelectedTemplate(template)}

View file

@ -6,7 +6,7 @@ import { SlotRoot } from '@/components/ui/slot';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {

View file

@ -11,7 +11,7 @@ import { SlotRoot } from '@/components/ui/slot';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {

View file

@ -30,7 +30,7 @@ function Switch({ className, size = 'default', onCheckedChange, onChange, ...pro
classNames={{
root: 'inline-flex',
track: cn(
'cursor-pointer border-transparent transition-all focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50'
'cursor-pointer border-transparent transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50'
),
}}
{...props}

View file

@ -70,7 +70,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof Mantin
<MantineTabs.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-colors group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',