diff --git a/src/features/dashboard/components/ProjectCard.test.tsx b/src/features/dashboard/components/ProjectCard.test.tsx index c4caa159..c77fd12d 100644 --- a/src/features/dashboard/components/ProjectCard.test.tsx +++ b/src/features/dashboard/components/ProjectCard.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' import { ProjectCard, type Project } from './ProjectCard' +import { ProjectCardSkeleton } from './ProjectCardSkeleton' const project: Project = { id: 42, @@ -66,3 +67,67 @@ describe('ProjectCard', () => { expect(onClick).not.toHaveBeenCalled() }) }) + +describe('ProjectCard Layout Parity', () => { + it('skeleton and real card share the same outer container structure', () => { + const { container: skeletonContainer } = renderWithTheme() + const skeletonElement = skeletonContainer.firstElementChild as HTMLElement + + const { container: cardContainer } = renderWithTheme() + const cardElement = cardContainer.firstElementChild as HTMLElement + + // Note: Visual regression testing is ideal here, but asserting shared layout classes + // is a good unit-test proxy to prevent layout shifts. + const sharedClasses = [ + 'w-full', + 'text-left', + 'backdrop-blur-[30px]', + 'rounded-[18px]', + 'border', + 'p-5', + ] + sharedClasses.forEach((cls) => { + expect(skeletonElement).toHaveClass(cls) + expect(cardElement).toHaveClass(cls) + }) + }) + + it('maintains layout bounds even with missing optional fields or long wrapping text', () => { + // Edge case: card with a long title/description wrapping to two lines + // and missing an optional field (e.g. no tags) vs skeleton's fixed placeholders. + const edgeCaseProject = { + ...project, + name: 'A very very long project name that will likely wrap into multiple lines if the container is narrow enough to force it', + description: + 'An extremely long description that takes up a lot of vertical space and pushes content down, which might cause layout shift if the skeleton does not account for typical wrapping bounds.', + tags: [], // missing optional field + } + + const { container: edgeCardContainer } = renderWithTheme( + + ) + const cardElement = edgeCardContainer.firstElementChild as HTMLElement + + const { container: skeletonContainer } = renderWithTheme() + const skeletonElement = skeletonContainer.firstElementChild as HTMLElement + + // Asserting class structure is maintained despite edge case content + const sharedClasses = [ + 'w-full', + 'text-left', + 'backdrop-blur-[30px]', + 'rounded-[18px]', + 'border', + 'p-5', + ] + sharedClasses.forEach((cls) => { + expect(cardElement).toHaveClass(cls) + expect(skeletonElement).toHaveClass(cls) + }) + + // Check that tags container is present but empty, verifying structural integrity + const tagContainer = edgeCardContainer.querySelector('.flex.flex-wrap.gap-1\\.5') + expect(tagContainer).toBeInTheDocument() + expect(tagContainer?.children).toHaveLength(0) + }) +}) diff --git a/src/features/dashboard/components/ProjectCardSkeleton.tsx b/src/features/dashboard/components/ProjectCardSkeleton.tsx index 9d57dfea..62b7d0e6 100644 --- a/src/features/dashboard/components/ProjectCardSkeleton.tsx +++ b/src/features/dashboard/components/ProjectCardSkeleton.tsx @@ -1,16 +1,18 @@ -import { useTheme } from '../../../shared/contexts/ThemeContext'; -import { SkeletonLoader } from '../../../shared/components/SkeletonLoader'; +import { useTheme } from '../../../shared/contexts/ThemeContext' +import { SkeletonLoader } from '../../../shared/components/SkeletonLoader' export function ProjectCardSkeleton() { - const { theme } = useTheme(); - const isDark = theme === 'dark'; + const { theme } = useTheme() + const isDark = theme === 'dark' return ( + // CONVENTION: This skeleton must closely mirror the outer structural shape and key + // layout classes of ProjectCard.tsx to prevent layout shifts when data loads. + // If you change the padding, border radius, or grid structure of ProjectCard, + // update this skeleton accordingly.
{/* Icon */} @@ -20,7 +22,7 @@ export function ProjectCardSkeleton() { {/* Title */} - + {/* Description */} @@ -54,19 +56,5 @@ export function ProjectCardSkeleton() {
- ); + ) } - - - - - - - - - - - - - -