PhotoTools is an educational photography application — free calculators, simulators, and references plus a glossary. Built as a Next.js 16 App Router hub with a tool registry system. Deployed to Vercel at www.phototools.io.
Business model: SEO-driven traffic + advertisement revenue. All decisions should consider SEO impact (shareable URLs, metadata, semantic HTML, page titles) and ad placement compatibility (clean layout with predictable content regions, no layout shift).
- Next.js 16 (App Router, Turbopack dev)
- React 19 + TypeScript 6
- next-intl 4.x (i18n — 31 locales: en, es, ja, de, fr, nl, ko, pt, it, hi, zh, tr, pl, id, vi, th, ru, bn, zh-TW, uk, sv, da, nb, fi, cs, ro, hu, el, ms, fil, ca)
- Vitest + jsdom + @testing-library/react + @testing-library/jest-dom
- ESLint with typescript-eslint
- CSS Modules + CSS custom properties (design tokens)
- Canvas API for image rendering (FOV Simulator overlays)
- WebGL2 + GLSL shaders (Exposure Simulator, White Balance Visualizer, Perspective Compression Simulator)
@sqlite.org/sqlite-wasm+comlinkin a Web Worker (Lightroom Catalog Analyzer — parses.lrcatSQLite catalogs client-side)recharts(dashboard charts),@react-pdf/renderer+recharts-to-png(PDF export, dynamic-imported),idb-keyval(insight cache),pako(client gzip),@vercel/blob+nanoid(hosted share)- Vercel (deployment)
npm run dev— start dev server with Turbopack athttp://localhost:3200npm run build— production build vianext buildnpm run start— serve production build locallynpm test— run Vitest tests (870+ tests across 68 files)npm run test:coverage— run tests with coverage reportnpm run type-check— run TypeScript compiler in noEmit modenpm run test:watch— run tests in watch modenpm run test:e2e— run Playwright e2e tests (requires a build first)npm run test:e2e:ui— run Playwright tests with interactive UInpm run lint— run ESLint
All source code lives under src/, with @/ aliased to src/ in tsconfig.json and vitest.config.ts.
- App Router: All routes under
src/app/[locale]/. Root layout (src/app/layout.tsx) imports globals.css and provides<html>/<body>. Locale layout (src/app/[locale]/layout.tsx) validates the locale, callssetRequestLocale(), wraps children inNextIntlClientProvider, and renders providers. Homepage atsrc/app/[locale]/page.tsx. Each tool atsrc/app/[locale]/[slug]/page.tsx. Glossary atsrc/app/[locale]/learn/glossary/page.tsx. Additional pages:/[locale]/about,/[locale]/contact,/[locale]/privacy,/[locale]/terms. API route (not locale-prefixed):src/app/api/contact/route.ts. - Locale Routing: Routing Middleware (
src/proxy.ts—proxy.tsis the Next.js 16 name for middleware.ts) handles locale detection (URL prefix →NEXT_LOCALEcookie →Accept-Languageheader → fallbacken). All pages are locale-prefixed:/en/fov-simulator,/es/fov-simulator, etc. Tool slugs stay in English across all locales. - Tool Co-location: Each tool's components live in
src/app/[locale]/[slug]/_components/alongside itspage.tsx. The_prefix makes it a private folder (not a route segment). Page files import from./_components/...using relative paths. When importing types from tool_components/insrc/lib/, use@/app/[locale]/...path. - Shared Components:
src/components/contains only shared/reusable code:layout/(Nav, Footer, ThemeProvider, ThemeToggle) andshared/(LearnPanel, ChallengeCard, ControlPanel, FocalLengthField, ApertureField, DistanceField, ToolIcon, InfoTooltip, ShareModal, ToolActions, FileDropZone, PhotoUploadPanel, ScenePicker, DraftBanner, JsonLd, DoFDiagram, DoFCanvas, AnimatedGrid, ModeToggle, AdUnit, AdScripts, MobileAdBanner, LanguageSwitcher, ApertureLogo, Calculator, FaqSection, RelatedTools). - Tool Registry:
src/lib/data/tools.tsdefines all tools with slug, name, description,dev/prodstatus fields ('live'/'draft'/'disabled'), and category.getLiveTools()returns live tools.getVisibleTools()returns live + draft.getToolBySlug()looks up by slug.getAllTools()returns all tools regardless of status. - Education System:
src/lib/data/education/contains per-tool education skeletons (non-translatable data: IDs, difficulty levels, correct answers, option values). All translatable education text lives insrc/lib/i18n/messages/en/education/*.json.LearnPanelandChallengeCardrender by combining skeleton data with translations. - Pure Math Modules:
src/lib/math/contains pure functions for FOV, DOF, exposure (including shader math for CoC, motion blur, noise), diffraction, star trails, color (color.ts, color-harmony.ts, color-hsl.ts, color-kelvin.ts), histogram, compression, frame (frame.ts, frame-border.ts, frame-texture.ts), and grid (grid.ts, grid-basic.ts, grid-golden.ts) calculations. Each has co-located.test.tsfiles. TDD approach — math is tested independently from UI. - Data:
src/lib/data/centralizes all pure data. Shared data files (with tests): tool registry, education skeletons, sensors, focal lengths, scenes, glossary, camera settings (apertures/shutter speeds/ISOs), ND filters, white balance presets, FAQ. Per-tool data files:frameStudio.ts,exposureScenes.ts,fovSimulator.ts,colorSchemeGenerator.ts,exifViewer.ts,starTrailCalculator.ts,dofSimulator.ts,hyperfocalSimulator.ts,perspectiveCompression.ts,equivalentSettings.ts,focusStacking.ts.
src/
app/
layout.tsx Root layout — imports globals.css, provides <html>/<body>
globals.css Global styles and CSS custom properties (design tokens)
sitemap.ts Multi-locale sitemap with hreflang alternates
robots.ts Robots.txt
opengraph-image.tsx Root OG image
api/contact/ Contact form API route (not locale-prefixed)
[locale]/ All locale-prefixed routes
layout.tsx Locale layout — validates locale, NextIntlClientProvider
page.tsx Homepage
[slug]/ Each tool (e.g. /en/fov-simulator)
page.tsx Route entry point
_components/ Tool-specific UI components (co-located)
learn/glossary/ Photography glossary
about/, contact/, privacy/, terms/ Info pages
components/
layout/ Nav (mega-menu), Footer, ThemeProvider, ThemeToggle
shared/ LearnPanel, ChallengeCard, ControlPanel, LanguageSwitcher, ApertureLogo, Calculator, FaqSection, RelatedTools, etc.
lib/
i18n/
routing.ts Locale list, defineRouting config, locale metadata
navigation.ts Locale-aware Link, usePathname, useRouter, redirect
request.ts Message loader — merges all JSON files per locale
redirects.ts Centralized redirect rules (consumed by next.config.ts)
metadata.ts getAlternates() helper for canonical + hreflang in page metadata
messages/en/ English translations (source of truth, 41 files)
messages/es/ Spanish translations (41 files)
messages/ja/ Japanese translations (41 files)
messages/de/ German translations (41 files)
messages/fr/ French translations (41 files)
math/ Pure calculation modules
data/ All pure data: shared + per-tool
data/education/ Per-tool education skeletons, types, barrel export
ads.ts Ad configuration and feature flags
utils/ Query sync, EXIF parsing, canvas/image export helpers
types.ts Shared TypeScript types
og.tsx + og-layout.tsx OpenGraph image generation
proxy.ts Routing Middleware — locale detection, zh-TW redirect
e2e/ Playwright e2e test specs
scripts/
check-translations.mjs Verify all locales have complete key coverage
public/ Images, icons, manifest
Uses next-intl with 31 locales:
- Original 5: English (en), Spanish (es), Japanese (ja), German (de), French (fr)
- First wave (11): Dutch (nl), Korean (ko), Portuguese (pt), Italian (it), Hindi (hi), Simplified Chinese (zh), Turkish (tr), Polish (pl), Indonesian (id), Vietnamese (vi), Thai (th)
- Second wave (15): Russian (ru), Bengali (bn), Traditional Chinese (zh-TW), Ukrainian (uk), Swedish (sv), Danish (da), Norwegian Bokmål (nb), Finnish (fi), Czech (cs), Romanian (ro), Hungarian (hu), Greek (el), Malay (ms), Filipino (fil), Catalan (ca)
The 31-locale set is defined in src/lib/i18n/routing.ts along with localeNames (display names for the language switcher) and localeOpenGraph (BCP-47 → OG locale mapping). The LanguageSwitcher sorts locales alphabetically by display name and shows a scrollable dropdown (max-height: 60vh).
Routing: All routes are locale-prefixed (/en/fov-simulator, /es/fov-simulator, /zh-TW/fov-simulator, etc.). The proxy at src/proxy.ts (Next.js Routing Middleware, formerly middleware.ts) detects locale from URL → cookie → Accept-Language → fallback en, and adds a 308 redirect for lowercase /zh-tw/* → /zh-TW/* since Next.js routing is case-sensitive and zh-TW is the only hyphenated locale. Config lives in src/lib/i18n/routing.ts (locale list, defineRouting). Navigation uses locale-aware wrappers from src/lib/i18n/navigation.ts — always import Link, usePathname, useRouter, redirect from @/lib/i18n/navigation, NOT from next/link or next/navigation.
Layouts: Root layout (src/app/layout.tsx) provides <html>, <body>, and imports globals.css. It does NOT use any next-intl APIs. Locale layout (src/app/[locale]/layout.tsx) calls setRequestLocale(locale), wraps children in NextIntlClientProvider. Any client component using next-intl hooks (like usePathname from navigation, useLocale, useTranslations) MUST be rendered inside the NextIntlClientProvider.
Messages: Translatable strings live in JSON files under src/lib/i18n/messages/{locale}/ (41 files per locale, 1271 total across 31 locales). The message loader (src/lib/i18n/request.ts) dynamically imports all files for the request's locale and merges them into namespaces. English is the source of truth — other locales mirror the exact same key structure. Translation discipline: see src/lib/i18n/glossary.photography.json for the canonical photography terms in all 31 locales (used as the reference glossary by translation agents).
Tool names/descriptions are translated via the tools namespace (tools.{slug}.name, tools.{slug}.description). The homepage and nav read from translations, not from the hardcoded tool.name/tool.description in the tool registry. Tool slugs in URLs stay in English across all locales.
Translation coverage: Run node scripts/check-translations.mjs to verify all 31 locales have complete key coverage vs English. Run node scripts/find-english-leaks.mjs to detect English text that leaked into translated files (heuristic with allowlist for brand/tech codes — exits non-zero on HARD leaks). Run node scripts/translation-status.mjs for a per-locale file-count + last-modified summary.
Namespaces (top-level key in each JSON file → how components access strings):
| Namespace | JSON location | Access pattern |
|---|---|---|
common |
messages/en/common.json |
useTranslations('common') → t('nav.tools') |
home |
messages/en/home.json |
useTranslations('home') → t('hero.title') |
tools |
messages/en/tools.json |
useTranslations('tools') → t('fov-simulator.name') |
glossary |
messages/en/glossary.json |
useTranslations('glossary') → t('entries.aperture.term') |
metadata |
messages/en/metadata.json |
getTranslations('metadata') (server) |
about, contact, privacy, terms |
messages/en/<name>.json |
useTranslations('<name>') |
education.<tool-slug> |
messages/en/education/<tool-slug>.json |
useTranslations('education.<tool-slug>') → t('beginner') |
toolUI.<tool-slug> |
messages/en/tools/<tool-slug>.json |
useTranslations('toolUI.<tool-slug>') → t('labelName') |
Education files are merged into a single education namespace; tool UI files are merged into a single toolUI namespace. Both use <tool-slug> as a sub-key.
To an existing tool — add keys to the tool's JSON file in ALL 31 locales:
- Add key to
src/lib/i18n/messages/en/tools/<tool-slug>.jsonunder thetoolUI.<tool-slug>object - Add the same key with translated values to all 30 other locale folders (use the photography glossary in
src/lib/i18n/glossary.photography.jsonfor canonical terminology) - In component:
const t = useTranslations('toolUI.<tool-slug>')→t('newKey') - Run
node scripts/check-translations.mjsandnode scripts/find-english-leaks.mjsto verify
To a new tool — files must be created in ALL 31 locales and registered:
- Create
src/lib/i18n/messages/{locale}/tools/<tool-slug>.jsonwith{ "toolUI": { "<tool-slug>": { ... } } }for each of the 31 locales - Create
src/lib/i18n/messages/{locale}/education/<tool-slug>.jsonwith{ "education": { "<tool-slug>": { ... } } }for each locale - Register both files in
src/lib/i18n/request.ts: add an import to thePromise.allarray, add the variable to thetoolUIMessagesand/oreducationMessagesreducer array. If you skip this step, the strings will not load and you'll getMISSING_MESSAGEerrors at runtime. - Add the tool's name/description to
messages/{locale}/tools.jsonunder thetoolsnamespace for each locale - Run
node scripts/check-translations.mjsto verify coverage andnode scripts/find-english-leaks.mjsto detect leaks
To shared UI (nav, footer, actions, etc.) — add keys to messages/{locale}/common.json under the appropriate sub-object in ALL locales.
Server components / metadata — use const t = await getTranslations('namespace') (async).
Rich text — t.rich('key', { link: (chunks) => <a href="...">{chunks}</a> }) for inline markup.
All redirect rules are centralized in src/lib/i18n/redirects.ts. Static redirects (legacy paths, old domain) are consumed by next.config.ts. Dynamic locale redirects are handled by middleware. Never add redirect logic outside this file.
- Canvas/WebGL data — sensors, scenes, focal lengths, ND filters, WB presets keep English text directly in
src/lib/data/files becauseuseTranslationsisn't available in draw functions. - Education skeletons (
src/lib/data/education/) store only non-translatable data (IDs, difficulty, correctOption, optionValues). All text is in the education JSON files. - Glossary (
src/lib/data/glossary.ts) stores entry IDs and optional relatedTool slugs. Terms and definitions are inmessages/{locale}/glossary.json. - Tool slugs — URL slugs stay in English across all locales. Never translate slugs.
Noto Sans JP and Noto Sans Bengali are loaded conditionally via next/font in [locale]/layout.tsx and applied via [lang="ja"] and [lang="bn"] CSS rules in globals.css. Other locales use the default font stack. Both fonts use display: swap and the smallest applicable subset to minimize CWV impact.
Privacy and Terms pages render commonT('legalTranslationDisclaimer') as a callout <aside> for non-English locales, telling users that the translation is for convenience and the English version is legally binding. The disclaimer key is in every locale's common.json.
All pure data (constants, presets, lookup tables, configuration arrays) lives in src/lib/data/. UI plumbing (PARAM_SCHEMA, DEFAULT_STATE, component-specific config) stays co-located in _components/.
- Shared data — data used by 2+ tools gets its own file (e.g.
camera.tsexportsAPERTURESused by star-trail and hyperfocal tools). - Per-tool data — tool-specific constants get a file named after the tool (e.g.
fovSimulator.ts,frameStudio.ts). - i18n for data — user-facing prose goes to i18n JSON files (
src/lib/i18n/messages/en/), not data files. Data files consumed by canvas/WebGL keep English text directly (sensors, scenes, etc.) sinceuseTranslationsisn't available in draw functions. - When adding a new tool: extract all constants, presets, and lookup tables into
src/lib/data/<toolSlug>.ts. Only leave UI wiring in_components/.
Each tool in src/lib/data/tools.ts has separate dev and prod status fields with three states: 'live', 'draft', or 'disabled'.
live— fully accessible, appears in nav, homepage, and footer.draft— appears in nav/homepage as "Coming Soon" (disabled); still reachable by direct URL with a draft banner.disabled— hidden from all menus, not shown anywhere.- Development (
npm run dev): uses thedevstatus field. - Production (
npm run build): uses theprodstatus field. - To publish a tool, set its
prodstatus to'live'.
Each tool has a LearnPanel (right sidebar) with:
- Beginner explanation — 2-3 sentence intro for newcomers
- Deeper explanation — physics/optics/math behind the concept (plain string or array of
{ heading, text }sections) - Key factors — what controls the effect
- Pro tips — practical real-world advice (amber callout)
- Challenges — 3-5 progressive multiple-choice questions with pass/fail feedback, try-again on wrong answers, reset all progress, persisted to localStorage
- Tooltips — hover info icons on control labels (via
InfoTooltipcomponent)
Education skeletons in src/lib/data/education/content*.ts define non-translatable structure (IDs, difficulty, correct answers). All user-facing text is in src/lib/i18n/messages/en/education/*.json. To add education content for a new tool, add a ToolEducationSkeleton entry and a corresponding JSON file.
- No page scroll (desktop only). On desktop, the application must fit within the viewport (100vh). The page never scrolls — only individual panels (controls, canvas, LearnPanel) scroll internally via
overflow-y: auto. On mobile, pages are allowed to scroll naturally. - FOV Simulator is the reference implementation. All tools should match its look and feel: dark surface panels, compact controls, same spacing/typography tokens, and consistent use of
var(--accent)for interactive elements. - Three-column layout: Tool pages render content (left/center) + LearnPanel (right sidebar, collapsible). Each tool manages its own layout and includes LearnPanel directly.
- Tool icons: Each tool has an inline SVG icon (
components/shared/ToolIcon.tsx) displayed on homepage cards, nav mega-menu items, and tool page headers. Icons are mapped by slug. - Nav mega-menu: Tools dropdown groups tools by category (Visualizers, Calculators, Reference, File Tools) with icon + name + description per item.
GoogleAdSense integration managed via src/lib/ads.ts (configuration and feature flags). Components: AdUnit (individual ad slots), MobileAdBanner (responsive mobile banner), AdScripts (script injection in layout). Environment variables: NEXT_PUBLIC_ADSENSE_CLIENT, NEXT_PUBLIC_COOKIEYES_ID. Ad units are hidden until real slot IDs are configured.
Three external services provide production monitoring:
- Sentry (
@sentry/nextjs) — client + server error tracking, performance tracing. Config:sentry.client.config.ts,sentry.server.config.ts,src/instrumentation.ts. Tunnel via/monitoringroute (bypasses ad blockers). Source maps uploaded at build time viawithSentryConfig(). - Axiom — log aggregation via Vercel Marketplace log drain (zero code). Receives structured JSON from
src/lib/logger.ts(server-only). 10-day retention on free tier. - BetterStack — uptime monitoring. Pings
/api/healthand homepage every 3 minutes. Critical alerts → email + Discord.
Structured Logger (src/lib/logger.ts): server-only, import 'server-only' enforced. Three levels: info, warn, error. Required module param for Axiom filtering. JSON in production, pretty-print in development. See src/lib/logger.test.ts for usage patterns.
Environment variables (Vercel, Production + Preview only): NEXT_PUBLIC_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT.
next.config.ts defines CSP, HSTS, and other security headers. unsafe-eval is included in CSP script-src only in development (React requires it for dev tooling). Never add unsafe-eval in production. 'wasm-unsafe-eval' IS in script-src in all environments — the Lightroom Catalog Analyzer's @sqlite.org/sqlite-wasm worker needs it to compile WebAssembly; it is a narrow WASM-only directive, distinct from the general 'unsafe-eval'. connect-src includes data: so @react-pdf/renderer can fetch its embedded fontkit/WASM data-URI during client-side PDF export.
components/shared/ShareModal.tsx generates share/embed links with current query parameters via window.location.search. The FOV Simulator has its own ShareModal (fov-simulator/_components/ShareModal.tsx) using stateToQueryString().
A file-tool that diverges from the standard tool patterns — read this before working on it (src/app/[locale]/lightroom-catalog-analyzer/).
- 100% client-side parse. A Web Worker (
_components/worker/analyzer.worker.ts, bridged viacomlink) opens the user's.lrcat(a SQLite DB) with@sqlite.org/sqlite-wasm(open.tsdeserializes into an in-memory copy — the user's file is never written). 16 pure aggregators inworker/aggregators/produce a typedInsightBlob(src/lib/lrcat/types.ts); the Zod schema issrc/lib/lrcat/insight-blob.schema.ts. Aggregators are tested in Node withbetter-sqlite3fixtures (worker/aggregators/__test-helpers__.ts). - No LearnPanel. The right sidebar is a section-anchor nav (
_components/nav/SectionAnchorNav.tsx) with scroll-spy, NOT education content. This tool is exempt from the "every live tool has education content" integration test (see theEDUCATION_EXEMPTset). - Flattened context. Sections consume
useAnalyzer()from_components/analyzer/AnalyzerContext.tsxreturning{ status, insightBlob, worker, filter, applyFilter, setFilter, reset, setYearInReview, ... }— NOT{ state, dispatch }. The recipient view passesworker: null; sections that re-query (YearInReview, PeriodComparison) self-guard on!worker. - Demo catalog.
public/demo-catalogs/phototools-demo.lrcat(synthetic, 3000 photos, regenerate vianpm run demo:build→scripts/build-demo-catalog.ts). Autoloads on?demo=true; drives the E2E (src/e2e/tools/lightroom-catalog-analyzer.spec.ts). - Hosted share (the only server-side surface).
POST/GET/DELETE /api/share+GET /api/cron/expire-sharesstore an aggregated, PII-guarded blob in Vercel Blob (src/lib/lrcat/share-blob.ts, pathnameshare/{expiresAt-ISO}/{id}.json, expiry encoded in path, swept by a daily cron). Server gzip vianode:zlibwith a 256 KB decompressed cap (gzip-bomb guard); client gzip viapako. Recipient view atr/[id]/isforce-dynamic+noindex. WAF rate-limit rules:docs/vercel-waf-rules.md. Env:BLOB_READ_WRITE_TOKEN,CRON_SECRET. - PII by construction: keywords used on <3 photos are dropped; GPS is rounded to a ~5 km grid and clusters <5 photos dropped — baked into the aggregators, so the
InsightBlobis shareable-safe at creation. - Filter date bounds:
worker/filter.tscomparescaptureTimeas a raw string, so date-range UIs must pass end-of-day (T23:59:59) bounds to include the last day.
- CSS Modules for component styles (e.g.
Component.module.css), design tokens via CSS custom properties defined insrc/app/globals.css. Always checkglobals.cssfor available variables before using them — don't invent new variable names. 'use client'directive on interactive components; server components by default- TDD for math: all
lib/math/modules have thorough unit tests - Named exports for all components
- Shared components first: Before building tool-specific UI, check
src/components/shared/for existing components. When two or more tools need similar UI (e.g. file upload, scene selector, share modal), extract a shared component. Familiarize yourself with what each shared component does so you reuse them consistently. - DRY: Avoid duplicating logic, styles, constants, or markup. Extract shared utilities, components, and data modules. When adding a feature, check if similar patterns already exist in the codebase and reuse them.
- 200-line file limit: Keep all
.ts/.tsxfiles under 200 lines (test files exempt). If a file grows beyond this, break it into smaller focused modules (e.g. extract hooks, sub-components, helpers, constants, or types into separate files). - Test files co-located next to source files (
*.test.ts) - 68 test files, 870+ tests covering math, data, education, ads, i18n (including all 31 locales), sitemap, metadata helpers, observability, and component integration
- After file changes in
src/app/, clear.nextcache (rm -rf .next) and restart dev server to avoid stale MIME type and 404 errors - i18n strings required: Whenever a new user-facing string is added or an existing one is modified, create or update the corresponding translation in the appropriate JSON file under
src/lib/i18n/messages/en/. Never hardcode user-facing text directly in components — always useuseTranslations(client) orgetTranslations(server) to reference translation keys. - Privacy Sandbox is deprecated — do not discuss, recommend, or implement any Privacy Sandbox APIs (Topics, Attribution Reporting, Protected Audience, etc.)
Playwright integration tests live in src/e2e/ and run against a production build (npm run build + npm run start). Config: playwright.config.ts.
src/e2e/
smoke/all-pages.spec.ts Parameterized smoke tests for all pages (200 status, no console errors, content rendering, no desktop scroll)
tools/fov-simulator.spec.ts FOV Simulator interaction tests
tools/color-scheme.spec.ts Color Scheme Generator interaction tests
tools/sensor-size.spec.ts Sensor Size Comparison interaction tests
tools/star-trail.spec.ts Star Trail Calculator interaction tests
tools/white-balance.spec.ts White Balance Visualizer interaction tests
tools/dof-simulator.spec.ts Depth of Field Simulator interaction tests
tools/exposure-simulator.spec.ts Exposure Simulator interaction tests
tools/hyperfocal.spec.ts Hyperfocal Distance Simulator interaction tests
tools/nd-filter.spec.ts ND Filter Calculator interaction tests
fixtures/test-image.jpg Minimal JPEG fixture for upload tests
- Build first:
npm run build - Run tests:
npm run test:e2e(ornpx playwright test) - If port 3200 is in use:
lsof -ti:3200 | xargs kill -9then retry - Run a single spec:
npx playwright test src/e2e/tools/fov-simulator.spec.ts - Debug mode:
npx playwright test --debug - View last report:
npx playwright show-report
- Duplicate DOM elements: Every tool page renders controls twice — once in the desktop sidebar (
<aside>) and once in a mobile controls section. Always scope selectors to avoid matching both. Usepage.locator('aside').first()orpage.locator('[class*="sidebar"]').first(). - LearnPanel challenge buttons: The LearnPanel sidebar contains challenge questions with answer buttons (e.g. "24mm", "Complementary") that share text with tool control buttons. Use
sidebar.locator('button:text-is("...")')scoped to the controls panel, not the full page. - CSS Modules hashed classes: Class names are hashed at build time. Never match exact class names. Use
[class*="partialName"]attribute selectors (e.g.[class*="paletteBarSwatch"],[class*="editForm"],[class*="copyGroup"]). - Hidden file inputs: File upload uses a hidden
<input type="file">insideFileDropZone. Usepage.locator('input[type="file"]').setInputFiles(...)directly. - Ref-based state (no re-render): Some components use refs instead of state (e.g.
PhotoPicker'simageLoaded). When a ref controls visibility, the DOM won't update after async operations. Fix withpage.evaluate()to force styles, thenclick({ force: true }). - Canvas/WebGL tools: These render to
<canvas>, not DOM elements. Use screenshot comparison (canvas.screenshot()+Buffer.compare) to verify visual changes. - ESM module context: Test files use ESM. Use
import { fileURLToPath } from 'url'andpath.dirname(fileURLToPath(import.meta.url))instead of__dirname. - Console error filtering: Smoke tests assert no console errors, but filter benign ones: favicon 404, cookieyes, adsense, adsbygoogle,
_vercel/speed-insights. Add new benign patterns to the filter insrc/e2e/smoke/all-pages.spec.tsas needed. - i18n translated text: UI labels come from
next-intlmessage files (src/lib/i18n/messages/en/). If a placeholder or label changes, check the translation JSON, not just the component source. - Select elements use value, not label: For
<select>dropdowns (e.g. sensor picker), useselectOption('value_id')notselectOption({ label: '...' }).
Playwright runs in GitHub Actions after npm run build. See .github/workflows/deploy.yml. CI installs Chromium + Firefox, runs all tests, and uploads playwright-report/ as an artifact on failure.
- Dev logs:
.next/dev/logs/next-development.log— written automatically by Turbopack whennpm run devis running. Read this file when asked to "check the dev logs". - Prod logs: Use
vercel logs <url>or check the Vercel dashboard. Usevercel logs --followfor live tailing.
- Vercel auto-deploys from
mainbranch - GitHub Actions CI:
npm ci→npm audit→npm run lint→npm test→npm run build - Custom domain:
www.phototools.io(apexphototools.ioredirects to www) - NEVER push to remote or deploy without explicit user instruction