Skip to content

Commit d69a33c

Browse files
feat(ui): add parallax background effect for card preview
1 parent 5790627 commit d69a33c

3 files changed

Lines changed: 316 additions & 20 deletions

File tree

app/components/navbar.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,15 @@ export default function Navbar() {
168168
className="hidden sm:inline-flex h-10 w-10 items-center justify-center rounded-xl border border-white/15 bg-white/5 text-white transition hover:bg-white/10"
169169
aria-label="Toggle theme"
170170
>
171-
{isDark ? <Sun size={18} /> : <Moon size={18} />}
171+
{mounted ? (
172+
isDark ? (
173+
<Sun size={18} />
174+
) : (
175+
<Moon size={18} />
176+
)
177+
) : (
178+
<span className="w-[18px] h-[18px]" />
179+
)}
172180
</button>
173181
<button
174182
type="button"
@@ -206,8 +214,16 @@ export default function Navbar() {
206214
className="inline-flex w-full items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2 text-sm font-medium text-white/90 transition hover:border-white/45 hover:bg-white/10"
207215
aria-label="Toggle theme"
208216
>
209-
{isDark ? <Sun size={18} /> : <Moon size={18} />}
210-
{isDark ? 'Light Mode' : 'Dark Mode'}
217+
{mounted ? (
218+
isDark ? (
219+
<Sun size={18} />
220+
) : (
221+
<Moon size={18} />
222+
)
223+
) : (
224+
<span className="w-[18px] h-[18px]" />
225+
)}
226+
{mounted ? (isDark ? 'Light Mode' : 'Dark Mode') : 'Theme'}
211227
</button>
212228
</li>
213229
</ul>

components/InteractiveViewer.test.tsx

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
import React from 'react';
22
import { render, screen, fireEvent } from '@testing-library/react';
3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, beforeEach, vi } from 'vitest';
44
import InteractiveViewer from './InteractiveViewer';
55

6+
// getBoundingClientRect is not implemented in jsdom — mock it so mouse-position
7+
// tests can assert normalized values without relying on a real layout engine.
8+
const mockContainerRect: DOMRect = {
9+
left: 0,
10+
top: 0,
11+
right: 600,
12+
bottom: 400,
13+
width: 600,
14+
height: 400,
15+
x: 0,
16+
y: 0,
17+
toJSON: () => ({}),
18+
};
19+
20+
beforeEach(() => {
21+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(mockContainerRect);
22+
});
23+
624
describe('InteractiveViewer', () => {
25+
// ── Existing behaviour ────────────────────────────────────────────────────
26+
727
it('renders children correctly', () => {
828
render(
929
<InteractiveViewer>
@@ -24,9 +44,9 @@ describe('InteractiveViewer', () => {
2444
// Focus and press 'w' to pan up
2545
fireEvent.keyDown(viewerContainer, { key: 'w' });
2646

27-
// Check if the transform style was updated on the inner div
28-
const innerDiv = viewerContainer.firstChild as HTMLElement;
29-
expect(innerDiv.style.transform).toContain('translate(0px, 30px) scale(1)');
47+
// The content div is the second child (after the parallax layer)
48+
const contentDiv = viewerContainer.children[1] as HTMLElement;
49+
expect(contentDiv.style.transform).toContain('translate(0px, 30px) scale(1)');
3050
});
3151

3252
it('handles keyboard navigation for zooming', () => {
@@ -40,9 +60,8 @@ describe('InteractiveViewer', () => {
4060
// Focus and press '+' to zoom in
4161
fireEvent.keyDown(viewerContainer, { key: '+' });
4262

43-
// Check if the transform style was updated on the inner div
44-
const innerDiv = viewerContainer.firstChild as HTMLElement;
45-
expect(innerDiv.style.transform).toContain('scale(1.1)');
63+
const contentDiv = viewerContainer.children[1] as HTMLElement;
64+
expect(contentDiv.style.transform).toContain('scale(1.1)');
4665
});
4766

4867
it('ignores key presses if an input element is focused', () => {
@@ -55,11 +74,107 @@ describe('InteractiveViewer', () => {
5574
const input = screen.getByTestId('input');
5675
input.focus();
5776

77+
// The viewer container is grandparent: input → content div → viewer
5878
const viewerContainer = input.parentElement?.parentElement as HTMLElement;
5979
fireEvent.keyDown(viewerContainer, { key: 'w' });
6080

61-
const innerDiv = viewerContainer.firstChild as HTMLElement;
62-
// Should not have panned
63-
expect(innerDiv.style.transform).toContain('translate(0px, 0px) scale(1)');
81+
const contentDiv = viewerContainer.children[1] as HTMLElement;
82+
// Should not have panned since an input had focus
83+
expect(contentDiv.style.transform).toContain('translate(0px, 0px) scale(1)');
84+
});
85+
86+
// ── Parallax background layer ─────────────────────────────────────────────
87+
88+
it('renders the parallax background layer behind the card content', () => {
89+
render(
90+
<InteractiveViewer>
91+
<div>Content</div>
92+
</InteractiveViewer>
93+
);
94+
// The parallax layer is always present in the DOM (opacity transitions handle visibility)
95+
expect(screen.getByTestId('parallax-bg-layer')).toBeDefined();
96+
});
97+
98+
it('renders the cursor glow element inside the parallax layer', () => {
99+
render(
100+
<InteractiveViewer>
101+
<div>Content</div>
102+
</InteractiveViewer>
103+
);
104+
const glow = screen.getByTestId('parallax-cursor-glow');
105+
expect(glow).toBeDefined();
106+
});
107+
108+
it('shows the cursor glow at full opacity when the pointer enters the container', () => {
109+
const { container } = render(
110+
<InteractiveViewer>
111+
<div>Content</div>
112+
</InteractiveViewer>
113+
);
114+
const viewerContainer = container.firstChild as HTMLElement;
115+
const glow = screen.getByTestId('parallax-cursor-glow');
116+
117+
// Before hover: glow opacity should be 0 (faded out)
118+
expect(glow.style.opacity).toBe('0');
119+
120+
// Simulate pointer entering the container
121+
fireEvent.pointerEnter(viewerContainer);
122+
123+
// After hover: glow should become visible (opacity 1)
124+
expect(glow.style.opacity).toBe('1');
125+
});
126+
127+
it('hides the cursor glow when the pointer leaves the container', () => {
128+
const { container } = render(
129+
<InteractiveViewer>
130+
<div>Content</div>
131+
</InteractiveViewer>
132+
);
133+
const viewerContainer = container.firstChild as HTMLElement;
134+
const glow = screen.getByTestId('parallax-cursor-glow');
135+
136+
fireEvent.pointerEnter(viewerContainer);
137+
expect(glow.style.opacity).toBe('1');
138+
139+
fireEvent.pointerLeave(viewerContainer);
140+
// Glow should fade back out on leave
141+
expect(glow.style.opacity).toBe('0');
142+
});
143+
144+
it('updates the cursor glow position on pointer move', () => {
145+
const { container } = render(
146+
<InteractiveViewer>
147+
<div>Content</div>
148+
</InteractiveViewer>
149+
);
150+
const viewerContainer = container.firstChild as HTMLElement;
151+
const glow = screen.getByTestId('parallax-cursor-glow');
152+
153+
// Move pointer to top-left quadrant of the mocked 600×400 container
154+
fireEvent.pointerMove(viewerContainer, { clientX: 150, clientY: 100 });
155+
156+
// Normalized: x = 150/600 = 0.25, y = 100/400 = 0.25
157+
// Glow `left` should be "25%" and `top` should be "25%"
158+
expect(glow.style.left).toBe('25%');
159+
expect(glow.style.top).toBe('25%');
160+
});
161+
162+
it('resets glow position to center (50%) when the pointer leaves', () => {
163+
const { container } = render(
164+
<InteractiveViewer>
165+
<div>Content</div>
166+
</InteractiveViewer>
167+
);
168+
const viewerContainer = container.firstChild as HTMLElement;
169+
const glow = screen.getByTestId('parallax-cursor-glow');
170+
171+
// Move pointer to an off-center position
172+
fireEvent.pointerMove(viewerContainer, { clientX: 60, clientY: 80 });
173+
expect(glow.style.left).not.toBe('50%');
174+
175+
// Leave the container — position resets to center so it fades from center
176+
fireEvent.pointerLeave(viewerContainer);
177+
expect(glow.style.left).toBe('50%');
178+
expect(glow.style.top).toBe('50%');
64179
});
65180
});

0 commit comments

Comments
 (0)