diff --git a/.gitignore b/.gitignore index faad045..4d2b8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # testing /coverage +/test-results/ +/playwright-report/ # next.js /.next/ diff --git a/README.md b/README.md index c430e9b..e830a44 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Arquivos de cada projeto em `public/videos//`: | Arquivo | Para quê | | --- | --- | | `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) | -| `scrub-desktop.mp4` e `scrub-mobile.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem | +| `scrub-desktop.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem (o celular da moldura mostra só o pôster) | | `og.jpg` | capa 1200x630 de compartilhamento | Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`. diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..27d8189 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { APIRequestContext } from "@playwright/test"; + +/** Caminhos (`/`, `/cases/…`) de todas as rotas do /sitemap.xml, sem o domínio de produção. */ +export async function sitemapPaths(request: APIRequestContext) { + const response = await request.get("/sitemap.xml"); + if (response.status() !== 200) + throw new Error(`/sitemap.xml respondeu ${response.status()}`); + const xml = await response.text(); + const paths = [...xml.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)].map( + ([, loc]) => { + const { pathname } = new URL(loc); + return pathname === "" ? "/" : pathname; + }, + ); + return [...new Set(paths)]; +} + +/** Nomes dos projetos de cliente, como a home os lista (public/portfolio-data.json, kind "cliente"). */ +export function clientProjectNames() { + const data = JSON.parse( + readFileSync( + join(__dirname, "..", "public", "portfolio-data.json"), + "utf8", + ), + ) as { projects: Array<{ title: string; kind?: string }> }; + return data.projects + .filter((project) => project.kind === "cliente") + .map((project) => project.title); +} + +/** Texto do cabeçalho da seção de contato da home. */ +export const CONTACT_HEADING = + "Vamos conversar sobre o que sua empresa precisa."; + +export const DESKTOP = { width: 1440, height: 900 } as const; +export const PHONE = { width: 390, height: 844 } as const; diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts new file mode 100644 index 0000000..e51a0a1 --- /dev/null +++ b/e2e/keyboard.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { DESKTOP } from "./helpers"; + +/** + * (c) Do topo da home, o Tab chega ao campo de nome do contato em no máximo 30 toques. + * O teste continua apertando Tab (até 120) depois do limite só para dizer, na falha, quantos toques faltavam. + */ + +const MAX_TABS = 30; +const GIVE_UP_AFTER = 120; + +test.use({ viewport: DESKTOP }); + +test(`Tab do topo da home chega ao campo de nome do contato em até ${MAX_TABS} toques`, async ({ + page, +}) => { + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(800); + // o foco começa no documento, como numa visita nova + await page.evaluate(() => (document.activeElement as HTMLElement)?.blur()); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + + const visited: string[] = []; + let reached = 0; + for (let tab = 1; tab <= GIVE_UP_AFTER; tab++) { + await page.keyboard.press("Tab"); + const where = await page.evaluate(() => { + const element = document.activeElement as HTMLElement | null; + if (!element) return { isName: false, label: "(nada)" }; + const label = ( + element.getAttribute("aria-label") || + element.textContent || + element.getAttribute("name") || + element.tagName + ) + .trim() + .replace(/\s+/g, " ") + .slice(0, 40); + return { + isName: + element.getAttribute("name") === "name" && + Boolean(element.closest("#contact")), + label: `${element.tagName.toLowerCase()} "${label}"`, + }; + }); + visited.push(`${tab}. ${where.label}`); + if (where.isName) { + reached = tab; + break; + } + } + + expect( + reached, + `o campo de nome não apareceu nem em ${GIVE_UP_AFTER} Tabs`, + ).toBeGreaterThan(0); + expect( + reached, + `o campo de nome só foi alcançado no Tab ${reached} (limite ${MAX_TABS}); passou por:\n${visited.join("\n")}`, + ).toBeLessThanOrEqual(MAX_TABS); + await expect(page.locator("#contact input[name='name']")).toBeFocused(); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..2d4662d --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; +import { DESKTOP } from "./helpers"; + +/** + * (d) Clicar em "Cases" no menu e depois num case navega sem recarregar o documento (um marcador em `window` + * sobrevive) e cada troca de página dispara uma startViewTransition; a carga inicial, nenhuma. + */ + +type Probe = { __marker?: string; __viewTransitions?: number }; + +test.use({ viewport: DESKTOP }); + +test("menu Cases e depois um case: sem recarregar o documento, uma transição de página por navegação", async ({ + page, +}) => { + // conta as chamadas de document.startViewTransition, desde antes do primeiro script da página + await page.addInitScript(() => { + const probe = window as unknown as Probe; + probe.__viewTransitions = 0; + const original = document.startViewTransition?.bind(document); + if (!original) return; + document.startViewTransition = ((...args: Parameters) => { + (probe.__viewTransitions as number) += 1; + return original(...args); + }) as typeof document.startViewTransition; + }); + const transitions = () => + page.evaluate(() => (window as unknown as Probe).__viewTransitions); + + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(1500); + await page.evaluate(() => { + (window as unknown as Probe).__marker = "mesmo-documento"; + }); + expect( + await page.evaluate(() => "startViewTransition" in document), + "o navegador precisa ter startViewTransition", + ).toBe(true); + expect(await transitions(), "carga inicial").toBe(0); + + await page + .getByRole("navigation", { name: "Navegação principal" }) + .getByRole("link", { name: "Cases" }) + .click(); + await page.waitForURL("**/cases"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.waitForTimeout(500); + expect(await transitions(), "depois de Cases").toBe(1); + + await page.getByRole("link", { name: "Ver o case" }).first().click(); + await page.waitForURL(/\/cases\/[^/]+$/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.waitForTimeout(500); + expect(await transitions(), "depois de abrir um case").toBe(2); + + expect( + await page.evaluate(() => (window as unknown as Probe).__marker), + "o documento foi recarregado (o marcador sumiu)", + ).toBe("mesmo-documento"); +}); diff --git a/e2e/no-js.spec.ts b/e2e/no-js.spec.ts new file mode 100644 index 0000000..0e21811 --- /dev/null +++ b/e2e/no-js.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { CONTACT_HEADING, clientProjectNames, DESKTOP } from "./helpers"; + +/** (e) Com JavaScript desligado, a home mostra o texto do contato e o nome dos clientes. */ + +test.use({ viewport: DESKTOP, javaScriptEnabled: false }); + +test("sem JavaScript a home mostra o contato e os nomes dos 4 clientes", async ({ + page, +}) => { + const clients = clientProjectNames(); + expect(clients, "clientes em public/portfolio-data.json").toHaveLength(4); + + const response = await page.goto("/", { waitUntil: "load" }); + expect(response?.status()).toBe(200); + + await expect( + page.getByRole("heading", { level: 2, name: CONTACT_HEADING }), + ).toBeVisible(); + for (const name of clients) { + await expect( + page.getByText(name, { exact: false }).first(), + `cliente "${name}"`, + ).toBeVisible(); + } + // o texto está no HTML entregue, não só na tela: vale também para quem lê sem renderizar + const html = await (await page.request.get("/")).text(); + expect(html).toContain(CONTACT_HEADING); + for (const name of clients) expect(html).toContain(name); +}); diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts new file mode 100644 index 0000000..97bfd39 --- /dev/null +++ b/e2e/routes.spec.ts @@ -0,0 +1,91 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import { DESKTOP, PHONE, sitemapPaths } from "./helpers"; + +/** + * (a) Todas as rotas do /sitemap.xml respondem 200, sem erro de console e sem rolagem horizontal, no computador + * (1440x900) e no celular (390x844). + * (b) O axe não acha violação serious ou critical em nenhuma delas. + * As rotas vêm do próprio sitemap: uma página nova entra no teste sozinha. + */ + +const VIEWPORTS = [ + { name: "computador 1440x900", size: DESKTOP }, + { name: "celular 390x844", size: PHONE }, +] as const; + +for (const { name, size } of VIEWPORTS) { + test.describe(`rotas do sitemap, ${name}`, () => { + test.use({ viewport: size }); + + test("respondem 200, sem erro de console e sem rolagem horizontal", async ({ + page, + request, + }) => { + const paths = await sitemapPaths(request); + expect(paths.length, "rotas no sitemap").toBeGreaterThan(10); + + for (const path of paths) { + await test.step(path, async () => { + const problems: string[] = []; + const onConsole = ( + message: import("@playwright/test").ConsoleMessage, + ) => { + if (message.type() === "error") + problems.push(`console.error: ${message.text()}`); + }; + const onPageError = (error: Error) => + problems.push(`erro não tratado: ${error.message}`); + page.on("console", onConsole); + page.on("pageerror", onPageError); + + const response = await page.goto(path, { waitUntil: "load" }); + // dá tempo de a hidratação e das primeiras animações acontecerem (e de errarem) + await page.waitForTimeout(800); + + page.off("console", onConsole); + page.off("pageerror", onPageError); + + expect.soft(response?.status(), `${path}: status`).toBe(200); + expect.soft(problems, `${path}: console`).toEqual([]); + const overflow = await page.evaluate( + () => + Math.max( + document.documentElement.scrollWidth, + document.body.scrollWidth, + ) - document.documentElement.clientWidth, + ); + expect + .soft(overflow, `${path}: px de rolagem horizontal`) + .toBeLessThanOrEqual(0); + }); + } + }); + }); +} + +test.describe("acessibilidade (axe), computador", () => { + test.use({ viewport: DESKTOP }); + + test("nenhuma violação serious ou critical em nenhuma rota do sitemap", async ({ + page, + request, + }) => { + const paths = await sitemapPaths(request); + + for (const path of paths) { + await test.step(path, async () => { + await page.goto(path, { waitUntil: "load" }); + await page.waitForTimeout(800); + const { violations } = await new AxeBuilder({ page }).analyze(); + const serious = violations + .filter(({ impact }) => impact === "serious" || impact === "critical") + .map( + ({ id, impact, nodes }) => + `${id} (${impact}): ${nodes.length} elemento(s), por exemplo ${nodes[0]?.target.join(" ")}`, + ); + expect.soft(serious, `${path}: axe`).toEqual([]); + }); + } + }); +}); diff --git a/e2e/scroll.spec.ts b/e2e/scroll.spec.ts new file mode 100644 index 0000000..688dfbc --- /dev/null +++ b/e2e/scroll.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { DESKTOP } from "./helpers"; + +/** + * (f) No computador, nenhuma das paradas a cada 10 % da rolagem da home tem tela vazia: sempre há ao menos um + * elemento de texto visível na janela. Cabeçalho, avisos, leitores de tela e itens ocultos não contam + * (o cabeçalho fixo estaria em todas as paradas e esconderia uma tela vazia). + */ + +test.use({ viewport: DESKTOP }); + +test("nenhuma parada a cada 10 % da home tem tela vazia", async ({ page }) => { + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(1500); + + const empty: string[] = []; + const stops: string[] = []; + for (let step = 0; step <= 10; step++) { + await page.evaluate((fraction) => { + const max = document.documentElement.scrollHeight - innerHeight; + window.scrollTo({ top: Math.round(max * fraction), behavior: "instant" }); + }, step / 10); + // a rolagem suave, as animações ligadas à rolagem e as imagens preguiçosas assentam + await page.waitForTimeout(700); + + const result = await page.evaluate(() => { + const ignored = + "header, footer [aria-hidden], [aria-hidden='true'], [role='dialog'], .sr-only, .skip-link, script, style, noscript"; + const effectiveOpacity = (element: Element | null) => { + let opacity = 1; + for (let node = element; node; node = node.parentElement) + opacity *= Number.parseFloat(getComputedStyle(node).opacity); + return opacity; + }; + const visible: string[] = []; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + ); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent?.trim() ?? ""; + const parent = node.parentElement; + if (text.length < 3 || !parent || parent.closest(ignored)) continue; + const style = getComputedStyle(parent); + if (style.visibility !== "visible" || style.display === "none") + continue; + if (effectiveOpacity(parent) < 0.2) continue; + const range = document.createRange(); + range.selectNodeContents(node); + const rect = range.getBoundingClientRect(); + const inside = + rect.width > 1 && + rect.height > 1 && + rect.bottom > 0 && + rect.top < innerHeight && + rect.right > 0 && + rect.left < innerWidth; + if (inside) visible.push(text.slice(0, 40)); + } + return { count: visible.length, sample: visible.slice(0, 2) }; + }); + + const label = `${step * 10}%`; + stops.push(`${label}: ${result.count} trecho(s) de texto`); + if (result.count === 0) empty.push(label); + } + + expect( + empty, + `paradas com tela vazia (nenhum texto visível):\n${stops.join("\n")}`, + ).toEqual([]); +}); diff --git a/jest.config.ts b/jest.config.ts index d7ac6b3..85189f0 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,12 +5,13 @@ const config: Config = { testEnvironment: "jsdom", roots: ["/app", "/lib"], modulePathIgnorePatterns: ["/.worktrees/"], + // o teste de fumaça (Playwright) roda com `pnpm test:e2e`, não no Jest + testPathIgnorePatterns: ["/node_modules/", "/e2e/"], setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { "^@/(.*)$": "/$1", "^.+\\.(css|sass|scss)$": "identity-obj-proxy", - "^.+/animations/PointCloud$": - "/app/components/animations/__mocks__/PointCloud.tsx", + "^server-only$": "/lib/__mocks__/server-only.ts", }, testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], transform: { diff --git a/package.json b/package.json index 90c5a90..772f9b8 100644 --- a/package.json +++ b/package.json @@ -26,24 +26,24 @@ "format": "biome format --write", "test": "jest", "test:watch": "jest --watch", + "test:e2e": "playwright test", "check:projects": "node scripts/check-projects.mjs" }, "dependencies": { "@react-three/drei": "10.7.7", "@react-three/fiber": "9.5.0", - "clsx": "2.1.1", - "gsap": "3.13.0", "lenis": "^1.3.26", "lucide-react": "0.542.0", - "motion": "12.23.12", "next": "16.3.8", "react": "19.1.0", "react-dom": "19.1.0", - "tailwind-merge": "3.3.1", + "server-only": "0.0.1", "three": "0.182.0" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@biomejs/biome": "2.2.0", + "@playwright/test": "^1.64.1", "@tailwindcss/postcss": "4.1.12", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ec3e778 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Teste de fumaça de ponta a ponta (pasta e2e/), rodado contra um servidor que já está no ar: + * BASE_URL endereço do site (padrão http://localhost:3001) + * PW_CHROMIUM caminho do chrome.exe, para não baixar navegador do Playwright + * (ex.: %LOCALAPPDATA%\ms-playwright\chromium-1248\chrome-win64\chrome.exe) + * Este arquivo não sobe servidor nenhum: `pnpm build && pnpm start -p 3001` (ou `pnpm dev`) antes de rodar. + */ +const baseURL = process.env.BASE_URL ?? "http://localhost:3001"; +const executablePath = process.env.PW_CHROMIUM || undefined; + +export default defineConfig({ + testDir: "./e2e", + // um servidor de desenvolvimento compila cada rota na primeira visita: com folga + timeout: 120_000, + expect: { timeout: 10_000 }, + // um navegador por vez: o servidor local (principalmente o dev) não aguenta várias compilações juntas + workers: 1, + fullyParallel: false, + retries: 0, + reporter: [["list"]], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + launchOptions: { executablePath }, + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: { executablePath }, + }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b14317..d1ea62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,40 +18,37 @@ importers: '@react-three/fiber': specifier: 9.5.0 version: 9.5.0(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.182.0) - clsx: - specifier: 2.1.1 - version: 2.1.1 - gsap: - specifier: 3.13.0 - version: 3.13.0 lenis: specifier: ^1.3.26 version: 1.3.26(react@19.1.0) lucide-react: specifier: 0.542.0 version: 0.542.0(react@19.1.0) - motion: - specifier: 12.23.12 - version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 16.3.8 - version: 16.3.8(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.1)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) - tailwind-merge: - specifier: 3.3.1 - version: 3.3.1 + server-only: + specifier: 0.0.1 + version: 0.0.1 three: specifier: 0.182.0 version: 0.182.0 devDependencies: + '@axe-core/playwright': + specifier: ^4.13.0 + version: 4.13.0(playwright-core@1.64.1) '@biomejs/biome': specifier: 2.2.0 version: 2.2.0 + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 '@tailwindcss/postcss': specifier: 4.1.12 version: 4.1.12 @@ -113,6 +110,11 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -769,6 +771,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@react-three/drei@10.7.7': resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} peerDependencies: @@ -1203,6 +1210,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + babel-jest@30.3.0: resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1329,10 +1340,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1507,20 +1514,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - framer-motion@12.43.0: - resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1560,9 +1553,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gsap@3.13.0: - resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==} - handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -2059,26 +2049,6 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - motion-dom@12.43.0: - resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} - - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - - motion@12.23.12: - resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2205,6 +2175,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -2310,6 +2290,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + sharp@0.35.4: resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} @@ -2434,9 +2417,6 @@ packages: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} - tailwind-merge@3.3.1: - resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} - tailwindcss@4.1.12: resolution: {integrity: sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==} @@ -2739,6 +2719,11 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@axe-core/playwright@4.13.0(playwright-core@1.64.1)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.64.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3468,6 +3453,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.182.0))(@types/react@19.1.12)(@types/three@0.182.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.182.0)': dependencies: '@babel/runtime': 7.29.7 @@ -3866,6 +3855,8 @@ snapshots: aria-query@5.3.2: {} + axe-core@4.13.0: {} + babel-jest@30.3.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -4019,8 +4010,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clsx@2.1.1: {} - co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -4168,15 +4157,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - framer-motion@12.43.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - motion-dom: 12.43.0 - motion-utils: 12.39.0 - tslib: 2.8.1 - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4212,8 +4192,6 @@ snapshots: graceful-fs@4.2.11: {} - gsap@3.13.0: {} - handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -4905,20 +4883,6 @@ snapshots: dependencies: minipass: 7.1.3 - motion-dom@12.43.0: - dependencies: - motion-utils: 12.39.0 - - motion-utils@12.39.0: {} - - motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - framer-motion: 12.43.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tslib: 2.8.1 - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - ms@2.1.3: {} nanoid@3.3.18: {} @@ -4929,7 +4893,7 @@ snapshots: neo-async@2.6.2: {} - next@16.3.8(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.1)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 16.3.8 '@swc/helpers': 0.5.23 @@ -4948,6 +4912,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.8 '@next/swc-win32-arm64-msvc': 16.3.8 '@next/swc-win32-x64-msvc': 16.3.8 + '@playwright/test': 1.64.1 sharp: 0.35.4(@types/node@22.20.1) transitivePeerDependencies: - '@babel/core' @@ -5024,6 +4989,12 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + postcss@8.5.23: dependencies: nanoid: 3.3.18 @@ -5116,6 +5087,8 @@ snapshots: semver@7.8.5: {} + server-only@0.0.1: {} + sharp@0.35.4(@types/node@22.20.1): dependencies: '@img/colour': 1.1.0 @@ -5244,8 +5217,6 @@ snapshots: dependencies: '@pkgr/core': 0.3.6 - tailwind-merge@3.3.1: {} - tailwindcss@4.1.12: {} tapable@2.3.3: {}