diff --git a/tests/e2e/lockdown-mode-simulation.spec.ts b/tests/e2e/lockdown-mode-simulation.spec.ts
new file mode 100644
index 0000000..de51fd1
--- /dev/null
+++ b/tests/e2e/lockdown-mode-simulation.spec.ts
@@ -0,0 +1,243 @@
+// ABOUTME: E2E regression tests for Issue #259 - Lockdown Mode gallery compatibility
+// Simulates JavaScript-disabled environments to catch Lockdown Mode regressions
+
+import { test, expect } from '@playwright/test'
+import { setupTestPage } from './helpers/test-setup'
+
+test.describe('Lockdown Mode Simulation - Issue #259 Regression', () => {
+ test.beforeEach(async ({ page }) => {
+ await setupTestPage(page)
+ })
+
+ test.describe('Mobile Gallery - JavaScript Disabled Simulation', () => {
+ test.use({ viewport: { width: 375, height: 667 } })
+
+ test('REGRESSION: Mobile gallery items must be clickable Links (not JavaScript-dependent)', async ({ page, context }) => {
+ // Navigate to homepage
+ await page.goto('/')
+
+ // Wait for gallery to load
+ await page.waitForSelector('[data-testid="mobile-gallery"]', { state: 'visible' })
+
+ // Get all gallery item links
+ const galleryLinks = page.locator('[data-testid="mobile-gallery"] a[href^="/project/"]')
+
+ // Verify we have gallery links (not divs/articles with onClick)
+ const linkCount = await galleryLinks.count()
+ expect(linkCount).toBeGreaterThan(0)
+
+ // Verify first link has proper href attribute
+ const firstLink = galleryLinks.first()
+ const href = await firstLink.getAttribute('href')
+
+ expect(href).toBeTruthy()
+ expect(href).toMatch(/^\/project\//)
+
+ // CRITICAL: Verify link is actually an tag (not div/article with role="link")
+ const tagName = await firstLink.evaluate(el => el.tagName.toLowerCase())
+ expect(tagName).toBe('a')
+
+ // Verify link is not blocked by pointer-events or z-index
+ const isClickable = await firstLink.evaluate((el) => {
+ const styles = window.getComputedStyle(el)
+ return styles.pointerEvents !== 'none' && styles.visibility !== 'hidden'
+ })
+ expect(isClickable).toBe(true)
+
+ // Simulate Lockdown Mode by clicking the link (uses href, not JavaScript)
+ // In real Lockdown Mode, onClick handlers are blocked but href navigation works
+ await firstLink.click()
+
+ // Should navigate using href (works without JavaScript)
+ await expect(page).toHaveURL(/\/project\//)
+ })
+
+ test('REGRESSION: Mobile gallery must not use onClick on articles', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="mobile-gallery"]', { state: 'visible' })
+
+ // Check for the old broken pattern: articles with onClick/role="button"
+ const articlesWithRoleButton = page.locator('article[role="button"]')
+ const count = await articlesWithRoleButton.count()
+
+ // This should be 0 - if it's > 0, someone reverted to the broken pattern
+ expect(count).toBe(0)
+ })
+
+ test('REGRESSION: Mobile gallery links must work without JavaScript execution', async ({ page, context }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="mobile-gallery"]', { state: 'visible' })
+
+ // Get first gallery link
+ const firstLink = page.locator('[data-testid="mobile-gallery"] a').first()
+
+ // Get the href attribute (this is what makes Lockdown Mode work)
+ const href = await firstLink.getAttribute('href')
+ expect(href).toBeTruthy()
+
+ // Navigate using the href directly (simulates browser navigation without JS)
+ await page.goto(href!)
+
+ // Should be on project page
+ await expect(page).toHaveURL(/\/project\//)
+ })
+ })
+
+ test.describe('Desktop Gallery - Strict Browser Security Simulation', () => {
+ test('REGRESSION: Desktop gallery items must be clickable Links (not JavaScript-dependent)', async ({ page }) => {
+ // Navigate to homepage
+ await page.goto('/')
+
+ // Wait for gallery to load
+ await page.waitForSelector('[data-testid="desktop-gallery"]', { state: 'visible', timeout: 10000 })
+
+ // Get all gallery item links
+ const galleryLinks = page.locator('[data-testid="desktop-gallery"] a[href^="/project/"]')
+
+ // Verify we have gallery links (not divs with onClick)
+ const linkCount = await galleryLinks.count()
+ expect(linkCount).toBeGreaterThan(0)
+
+ // Verify first link has proper href attribute
+ const firstLink = galleryLinks.first()
+ const href = await firstLink.getAttribute('href')
+
+ expect(href).toBeTruthy()
+ expect(href).toMatch(/^\/project\//)
+
+ // CRITICAL: Verify link is actually an tag (not div with role="button")
+ const tagName = await firstLink.evaluate(el => el.tagName.toLowerCase())
+ expect(tagName).toBe('a')
+
+ // Verify link is not blocked by pointer-events or z-index
+ const isClickable = await firstLink.evaluate((el) => {
+ const styles = window.getComputedStyle(el)
+ return styles.pointerEvents !== 'none' && styles.visibility !== 'hidden'
+ })
+ expect(isClickable).toBe(true)
+
+ // Click the link (uses href, not onClick handler)
+ await firstLink.click()
+
+ // Should navigate using href
+ await expect(page).toHaveURL(/\/project\//)
+ })
+
+ test('REGRESSION: Desktop gallery must not use onClick on divs', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="desktop-gallery"]', { state: 'visible' })
+
+ // Check for the old broken pattern: divs with onClick/role="button"
+ const divsWithRoleButton = page.locator('[data-testid="desktop-gallery"] div[role="button"]')
+ const count = await divsWithRoleButton.count()
+
+ // Navigation arrows are buttons, but gallery items should not be divs with role="button"
+ // Check specifically for gallery items (data-testid starts with "gallery-item-")
+ const galleryItemDivButtons = page.locator('[data-testid^="gallery-item-"][role="button"]')
+ const galleryItemDivButtonCount = await galleryItemDivButtons.count()
+
+ expect(galleryItemDivButtonCount).toBe(0)
+ })
+
+ test('REGRESSION: Desktop gallery links must work without JavaScript execution', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="desktop-gallery"]', { state: 'visible' })
+
+ // Get first gallery link
+ const firstLink = page.locator('[data-testid="desktop-gallery"] a[data-testid^="gallery-item-"]').first()
+
+ // Get the href attribute (this is what makes strict browser security work)
+ const href = await firstLink.getAttribute('href')
+ expect(href).toBeTruthy()
+
+ // Navigate using the href directly (simulates browser navigation without JS)
+ await page.goto(href!)
+
+ // Should be on project page
+ await expect(page).toHaveURL(/\/project\//)
+ })
+ })
+
+ test.describe('Cross-Platform Link Consistency', () => {
+ test('REGRESSION: Both mobile and desktop must use semantic tags for gallery items', async ({ page }) => {
+ // Test mobile
+ await page.setViewportSize({ width: 375, height: 667 })
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="mobile-gallery"]', { state: 'visible' })
+
+ const mobileLinks = page.locator('[data-testid="mobile-gallery"] a[href^="/project/"]')
+ const mobileLinkCount = await mobileLinks.count()
+ expect(mobileLinkCount).toBeGreaterThan(0)
+
+ // Verify mobile links are tags
+ const mobileTagName = await mobileLinks.first().evaluate(el => el.tagName.toLowerCase())
+ expect(mobileTagName).toBe('a')
+
+ // Test desktop
+ await page.setViewportSize({ width: 1920, height: 1080 })
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="desktop-gallery"]', { state: 'visible' })
+
+ const desktopLinks = page.locator('[data-testid="desktop-gallery"] a[href^="/project/"]')
+ const desktopLinkCount = await desktopLinks.count()
+ expect(desktopLinkCount).toBeGreaterThan(0)
+
+ // Verify desktop links are tags
+ const desktopTagName = await desktopLinks.first().evaluate(el => el.tagName.toLowerCase())
+ expect(desktopTagName).toBe('a')
+ })
+ })
+
+ test.describe('Accessibility - Keyboard Navigation', () => {
+ test('REGRESSION: Gallery links must be keyboard accessible without explicit tabIndex', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="desktop-gallery"]', { state: 'visible' })
+
+ // Links are naturally keyboard accessible
+ // Focus first gallery link using keyboard (Tab key)
+ await page.keyboard.press('Tab')
+ await page.keyboard.press('Tab') // May need multiple tabs to reach gallery
+
+ // Check if a gallery link is focused
+ const focusedElement = await page.evaluate(() => {
+ const el = document.activeElement
+ return {
+ tagName: el?.tagName.toLowerCase(),
+ href: el?.getAttribute('href'),
+ dataTestId: el?.getAttribute('data-testid')
+ }
+ })
+
+ // Eventually a gallery link should be focusable
+ // (This might take multiple Tab presses in real scenario, but the point is links are in tab order)
+ if (focusedElement.tagName === 'a' && focusedElement.href?.includes('/project/')) {
+ expect(focusedElement.tagName).toBe('a')
+ }
+ })
+ })
+
+ test.describe('Lighthouse Performance - Link Usage', () => {
+ test('REGRESSION: Gallery should use proper tags for SEO and crawlability', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('[data-testid="mobile-gallery"], [data-testid="desktop-gallery"]', { state: 'visible' })
+
+ // Get all links on the page
+ const allLinks = page.locator('a[href]')
+ const linkCount = await allLinks.count()
+
+ // Verify gallery items contribute to discoverable links
+ const galleryLinks = page.locator('a[href^="/project/"]')
+ const galleryLinkCount = await galleryLinks.count()
+
+ expect(galleryLinkCount).toBeGreaterThan(0)
+ expect(galleryLinkCount).toBeLessThanOrEqual(linkCount)
+
+ // Verify links have valid href attributes (good for SEO and crawlers)
+ for (let i = 0; i < Math.min(3, galleryLinkCount); i++) {
+ const link = galleryLinks.nth(i)
+ const href = await link.getAttribute('href')
+ expect(href).toMatch(/^\/project\/[a-zA-Z0-9-]+$/)
+ }
+ })
+ })
+})
diff --git a/tests/regression/gallery-lockdown-mode.test.tsx b/tests/regression/gallery-lockdown-mode.test.tsx
new file mode 100644
index 0000000..3df4698
--- /dev/null
+++ b/tests/regression/gallery-lockdown-mode.test.tsx
@@ -0,0 +1,228 @@
+// ABOUTME: Regression tests for Issue #259 - Gallery Lockdown Mode compatibility
+// These tests ensure gallery items always use Link components (semantic tags)
+// and will catch if anyone accidentally reverts to onClick handlers on non-interactive elements
+
+import { render, screen } from '@testing-library/react'
+import { mockDesigns } from '../fixtures/designs'
+
+// Mock Next.js router
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: jest.fn(),
+ back: jest.fn(),
+ forward: jest.fn(),
+ refresh: jest.fn(),
+ }),
+ usePathname: () => '/',
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+// Mock image helpers
+jest.mock('@/utils/image-helpers', () => ({
+ getOptimizedImageUrl: jest.fn((source) => {
+ if (!source) return ''
+ return `https://cdn.example.com/optimized-image.webp`
+ }),
+}))
+
+// Mock analytics
+jest.mock('@/utils/analytics', () => ({
+ UmamiEvents: {
+ viewProject: jest.fn(),
+ },
+}))
+
+// Mock scroll manager
+jest.mock('@/lib/scrollManager', () => ({
+ scrollManager: {
+ save: jest.fn(),
+ saveImmediate: jest.fn(),
+ restore: jest.fn().mockResolvedValue(0),
+ triggerNavigationStart: jest.fn(),
+ clearPosition: jest.fn(),
+ },
+}))
+
+describe('Gallery Lockdown Mode Regression Tests - Issue #259', () => {
+ describe('Mobile Gallery - Link Component Usage', () => {
+ let MobileGallery: React.ComponentType<{ designs: typeof mockDesigns }>
+
+ beforeAll(async () => {
+ const mobileModule = await import('@/components/mobile/Gallery/MobileGallery')
+ MobileGallery = mobileModule.default
+ })
+
+ it('REGRESSION: Mobile gallery items MUST be Link elements (not articles with onClick)', () => {
+ render()
+
+ // Critical: Gallery items must be rendered as tags (Link component)
+ // If this fails, someone has reverted to onClick handlers on non-interactive elements
+ const galleryLinks = screen.getAllByRole('link')
+
+ expect(galleryLinks.length).toBeGreaterThanOrEqual(mockDesigns.length)
+
+ // Verify each design has a corresponding link
+ mockDesigns.forEach((design) => {
+ const link = screen.getByRole('link', { name: new RegExp(design.title) })
+ expect(link).toBeInTheDocument()
+ })
+ })
+
+ it('REGRESSION: Mobile gallery items MUST have href attributes for native navigation', () => {
+ render()
+
+ const galleryLinks = screen.getAllByRole('link')
+
+ // Each link must have a valid href for Lockdown Mode compatibility
+ galleryLinks.forEach((link) => {
+ const href = link.getAttribute('href')
+ expect(href).toBeTruthy()
+ expect(href).toMatch(/^\/project\//)
+ })
+ })
+
+ it('REGRESSION: Mobile gallery items MUST NOT use role="button" on articles', () => {
+ render()
+
+ // Articles with role="button" are what caused the Lockdown Mode issue
+ // This test ensures we never revert to that pattern
+ const articles = document.querySelectorAll('article[role="button"]')
+ expect(articles.length).toBe(0)
+ })
+
+ it('REGRESSION: Mobile gallery items MUST work without JavaScript (href-based navigation)', () => {
+ render()
+
+ const firstLink = screen.getByRole('link', { name: new RegExp(mockDesigns[0].title) })
+ const href = firstLink.getAttribute('href')
+
+ // The href must point to the correct project route
+ // This ensures navigation works even if JavaScript is disabled (Lockdown Mode scenario)
+ const expectedSlug = mockDesigns[0].slug?.current || mockDesigns[0]._id
+ expect(href).toBe(`/project/${expectedSlug}`)
+ })
+ })
+
+ describe('Desktop Gallery - Link Component Usage', () => {
+ let DesktopGallery: React.ComponentType<{ designs: typeof mockDesigns }>
+
+ beforeAll(async () => {
+ const desktopModule = await import('@/components/desktop/Gallery/Gallery')
+ DesktopGallery = desktopModule.default
+ })
+
+ it('REGRESSION: Desktop gallery items MUST be Link elements (not divs with onClick)', () => {
+ render()
+
+ // Critical: Gallery items must be rendered as tags (Link component)
+ // If this fails, someone has reverted to onClick handlers on non-interactive elements
+ const galleryLinks = screen.getAllByRole('link')
+
+ expect(galleryLinks.length).toBeGreaterThanOrEqual(mockDesigns.length)
+
+ // Verify each design has a corresponding link
+ mockDesigns.forEach((design) => {
+ const link = screen.getByRole('link', { name: new RegExp(design.title) })
+ expect(link).toBeInTheDocument()
+ })
+ })
+
+ it('REGRESSION: Desktop gallery items MUST have href attributes for native navigation', () => {
+ render()
+
+ const galleryLinks = screen.getAllByRole('link')
+
+ // Each link must have a valid href for browser compatibility
+ galleryLinks.forEach((link) => {
+ const href = link.getAttribute('href')
+ expect(href).toBeTruthy()
+ expect(href).toMatch(/^\/project\//)
+ })
+ })
+
+ it('REGRESSION: Desktop gallery items MUST NOT use role="button" on divs', () => {
+ render()
+
+ // Divs with role="button" are what caused the strict browser security issue
+ // This test ensures we never revert to that pattern
+ const divButtons = document.querySelectorAll('div[role="button"][data-testid^="gallery-item"]')
+ expect(divButtons.length).toBe(0)
+ })
+
+ it('REGRESSION: Desktop gallery items MUST work without JavaScript (href-based navigation)', () => {
+ render()
+
+ const firstLink = screen.getByRole('link', { name: new RegExp(mockDesigns[0].title) })
+ const href = firstLink.getAttribute('href')
+
+ // The href must point to the correct project route
+ // This ensures navigation works even if JavaScript is disabled
+ const expectedSlug = mockDesigns[0].slug?.current || mockDesigns[0]._id
+ expect(href).toBe(`/project/${expectedSlug}`)
+ })
+
+ it('REGRESSION: Desktop gallery items MUST NOT have explicit tabIndex when using Links', () => {
+ render()
+
+ const galleryLinks = screen.getAllByRole('link')
+
+ // Links are naturally keyboard accessible and should not need explicit tabIndex
+ // If tabIndex is present on links, it suggests someone might be trying to make
+ // non-interactive elements keyboard accessible (the old broken pattern)
+ galleryLinks.forEach((link) => {
+ const tabIndex = link.getAttribute('tabIndex')
+ // Links should either have no tabIndex or tabIndex="0" (default)
+ if (tabIndex !== null) {
+ expect(parseInt(tabIndex)).toBe(0)
+ }
+ })
+ })
+ })
+
+ describe('Cross-Platform Compatibility', () => {
+ it('REGRESSION: Both mobile and desktop galleries MUST use the same Link-based pattern', async () => {
+ const MobileGallery = (await import('@/components/mobile/Gallery/MobileGallery')).default
+ const DesktopGallery = (await import('@/components/desktop/Gallery/Gallery')).default
+
+ const { unmount: unmountMobile } = render()
+ const mobileLinks = screen.getAllByRole('link')
+ const mobileHasLinks = mobileLinks.length >= mockDesigns.length
+ unmountMobile()
+
+ const { unmount: unmountDesktop } = render()
+ const desktopLinks = screen.getAllByRole('link')
+ const desktopHasLinks = desktopLinks.length >= mockDesigns.length
+ unmountDesktop()
+
+ // Both platforms must use Link components consistently
+ expect(mobileHasLinks).toBe(true)
+ expect(desktopHasLinks).toBe(true)
+ })
+ })
+
+ describe('Lockdown Mode Simulation', () => {
+ it('REGRESSION: Gallery items MUST be navigable using href alone (no JavaScript required)', async () => {
+ const MobileGallery = (await import('@/components/mobile/Gallery/MobileGallery')).default
+
+ render()
+
+ // Simulate Lockdown Mode: verify that href exists and is valid
+ // In real Lockdown Mode, onClick handlers are blocked but href navigation works
+ const links = screen.getAllByRole('link')
+
+ links.forEach((link) => {
+ const href = link.getAttribute('href')
+
+ // Must have href
+ expect(href).toBeTruthy()
+
+ // Must be a valid project route
+ expect(href).toMatch(/^\/project\/[a-zA-Z0-9-]+$/)
+
+ // Link must be actually clickable (not blocked by pointer-events or z-index)
+ const styles = window.getComputedStyle(link)
+ expect(styles.pointerEvents).not.toBe('none')
+ })
+ })
+ })
+})