Files
novalon-website/CLAUDE.md
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

285 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Commands
```bash
# Development
npm run dev # Start dev server on port 3000
npm run dev:clean # Clean .next/dist then start dev server
# Build & Preview
npm run build # Build production files to dist/
npm run build:clean # Clean then build
npm run start # next start -p 3000 — serve the built output
npm run preview # ALIAS of `start` (identical `next start -p 3000`); it is NOT `npx serve`
# 说明(2026-09-23 文档同步轮核实):`next start` 与 `output: 'standalone'` 组合下 Next 16 会打印
# `"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js"
# instead.`(实测见 `docs/acceptance/2026-09-21-gates/ga4-production-run.txt`;抛出点
# `node_modules/next/dist/server/next.js` 的 `getServer()`)。它是 **warn 而非 throw**——服务照常起来,
# 但走的是不受支持的降级路径,所以门禁脚本正逐步脱离 `next start`:`test:e2e:prod` 已改为直接
# `node dist/standalone/server.js`(`e2e/playwright.config.ts:132-135`);`check:headings` 也已脱离
# `next start` —— `scripts/utils/check-heading-hierarchy.ts:55-66` 现为「存在 dist/standalone/server.js 就直接
# 起 standalone(注入 HOSTNAME/PORT/NODE_ENV),仅当产物缺失才回退 `npm run preview` 并打印告警」,
# 见验收报告 N-24③。至此门禁脚本不再依赖不受支持的 `next start` 路径。
# standalone 的受支持启动方式是 `node dist/standalone/server.js`,且服务启动前 `public/`
# 与 `dist/static` 必须已在 standalone 根目录下(server.js 启动时缓存静态索引,缺资源会让
# /_next/static/** 全 404)。**这一步由 `package.json:9` 的 `postbuild` 自动完成**(`npm run build`
# 结尾 `rm -rf` + `cp -R dist/static → dist/standalone/dist/static`、`cp -R public → dist/standalone/public`),
# 所以正常路径下"先 build 再起 standalone"即可,不必手工拷;仅当产物来自别处或要复用旧 dist 时才参照
# `Jenkinsfile:353-355`(axe 阶段复用上一阶段产物时的同套拷贝)与 `scripts/deploy.sh:162-175`
# (发布侧把 `public/` 与 `dist/static → dist/_next/static` 同步进 Nginx 站点根)。npm 侧没有包一层的启动脚本。
# Deploy (统一发布脚本)
./scripts/deploy.sh build # 构建静态产物
./scripts/deploy.sh deploy # 构建并发布到生产服务器
./scripts/deploy.sh deploy --skip-build # 使用现有 dist/ 直接发布
./scripts/deploy.sh rollback # 回滚到最近一次远程备份
./scripts/deploy.sh status # 查看生产环境发布状态
# Linting & Type Checking
npm run lint # ESLint flat config at repo root (`eslint.config.mjs`; `config/lint/.eslintrc.json` no longer exists)
npm run type-check # tsc --noEmit
# E2E Testing (Playwright)
npm run test # 全链路 E2E:先 test:functional(4 功能 project),再 test:visual:all(5 视觉 project),串行以免写库用例与截图并发污染基线
npm run test:functional # 仅功能 project(chromium / chromium-mobile / firefox / webkit)
npm run test:e2e # Same as test:functional
npm run test:smoke # Only @smoke-tagged tests
npm run test:e2e:prod # @smoke+@critical+@journey 打构建产物(E2E_TARGET=production → node dist/standalone/server.js,CI 门禁)
npm run test:visual # Visual regression (Desktop Chrome)
npm run test:visual:all # Visual regression (all projects)
npm run test:visual:update # Update visual snapshots
npx playwright test --grep "test name" # Run a single E2E test by name
# ⚠ E2E 目标口径(默认跑的是 dev server,不是产物)
# `npm run test` / `test:functional` 的 webServer 是 `npm run dev`(`e2e/playwright.config.ts:132-135`),
# 因此 **16 个 @critical GA4 用例(TC-GA4-001..004 × 4 project)在这一轮里是 skipped,不是 passed**:
# `NEXT_PUBLIC_GA_MEASUREMENT_ID` 只写在 `.env.production`,dev 读不到 → `GoogleAnalytics.tsx:81,119`
# 提前 return null → 用例内 `test.skip(measurementId === null, …)` 触发。证据:
# `docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json`(28 skipped 中该组恰为 16)。
# 它们真正断言的唯一入口是 `npm run test:e2e:prod`(`E2E_TARGET=production` → 直接
# `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js`,非 `next start`;须先 `npm run build`
# 并把 `dist/static` + `public` 并入 standalone 根目录,见 Build & Preview 段的装配口径),
# 见同目录 `ga4-production-run.txt` 的 4 passed。故「`npm run test` 全绿」不等于 GA4 覆盖已验证。
# Unit Testing (Jest)
npm run test:unit # Run all unit tests
npm run test:coverage # Coverage report(阈值以 jest.config.js 的 coverageThreshold 为准;根配置 re-export config/test/jest.config.js 以免双份漂移)
npx jest --testPathPattern="button" # Run a single test file matching pattern
# Quality Checks
npm run check:a11y # 可访问性静态门禁伞(= check:contrast + check:headings + check:brand-token,已并入 test:all)
npm run check:contrast # 令牌对比度审计(读 globals.css 令牌表,浅色+暗色双主题、含 alpha 组,缺令牌即红)
npm run check:headings # 标题层级审计(有 dist/standalone/server.js 时直起 standalone;产物缺失才回退 `npm run preview` 并打印告警 —— scripts/utils/check-heading-hierarchy.ts:52-67)
npm run check:brand-token # 品牌红文字必须走 text-brand-ink* 令牌(DESIGN.md 双通道红规则)
npm run check:motion # 动效契约门禁(N-29 收口):R1 令牌档位(instant 100ms / fast·normal 180–280ms,CONTEXT.md:76)、
# R2 时长 ≤700ms(CSS 声明、framer transition 对象的 duration、Tailwind duration-* 类、tailwind.config 的 animation;
# infinite 循环与 *-delay 豁免)、R3 transition 时长必须走 var(--transition-*)、
# R4 写死的 cubic-bezier 必须属于令牌层 --ease-*(允许集合从 CSS 解析,不硬编码)且 --ease-ink==[0.22,1,0.36,1]。
# 退出码 0 干净 / 1 违规 / 2 未能度量(空扫描、缺令牌文件)—— 空扫描不读作干净。
# ⚠ 当前树 23 处违规(EXIT=1),故**尚未**并入 check:a11y,详见 docs/development/quality-gates.md §5.1
npm run check:motion:test # 上述门禁的自证伪测试(19 例,双向:合规夹具判 0 + 违规夹具判对应规则红);
# 测试在 scripts/ 下,jest 默认 roots 只含 src,故该脚本显式传 --roots
npm run check:axe:routes # 全站路由清单(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)→ /tmp/axe-routes.xml
# 同样需要 :3100 的生产服务(SEED 默认取 ${BASE}/sitemap.xml)
npm run check:axe # 双引擎(chromium/firefox)×双主题(light/dark)逐页 axe 节点计数 + 三条规则级
# 规则(autocomplete-valid/presentation-role-conflict/svg-img-alt)的分母断言;
# 退出码 0 通过 / 1 判红 / 2 清单缺失或 0 条(假绿熔断)。证据落
# docs/acceptance/2026-09-21-axe/axe-evidence.json。需一个生产服务在 :3100:
# PORT=3100 HOSTNAME=127.0.0.1 node dist/standalone/server.js(先拷 dist/static 与 public,见上文)
# CI 侧由 Jenkinsfile「♿♿ 全站 axe 节点计数」阶段(仅 main)自动串起
npm run test:all # type-check + lint + test:coverage + test:integration:real + check:a11y + test:e2e:fast + test:security:headers
npm run test:e2e:prod # E2E_TARGET=production(构建产物目标);harness **不代跑构建**,须先 `npm run build`
# (standalone 根目录的 dist/static + public 由 postbuild 自动装配,见上文)
npm run lighthouse # Lighthouse CI(配置 config/test/lighthouserc.json;axe critical/serious 逐条 minScore:1;inspector-issues 亦判红——CSP/弃用类问题只走 DevTools issue 通道,errors-in-console 与 0.9 分类阈值都看不见它;报告落 lighthouse-reports/)
# Database (Prisma + SQLite)
npm run db:seed # Seed the dev database
npm run db:reset # Reset database with migrations
```
## Architecture
### Tech Stack
- **Next.js 16.3**(App Router)混合渲染 — SSG/ISR 静态页 + API routes;`output: 'standalone'`(`next.config.mjs:44`)、`distDir: 'dist'`(`:46`)。standalone 入口是 `node dist/standalone/server.js`,需另拷 `dist/static` 与 `public`;`npm run start`(= `next start`)在 standalone 下只算不受支持的降级路径,见下文 Build Output
- **中间件命名为 `proxy.ts`**(Next 16 起 `middleware.ts` 更名);其 matcher 排除 `/api/*`,因此每个 API 路由必须自行做鉴权
- **React 18**, **TypeScript 5** (strict mode with `noUncheckedIndexedAccess`)
- **Tailwind CSS 3** with design tokens exposed as CSS custom properties (all tokenized via `var()` references in `tailwind.config.js`)
- **Framer Motion** for animations, **Lucide React** for icons, **Zod** for validation
- **shadcn/ui** pattern (Radix UI + class-variance-authority + tailwind-merge)
- **Prisma** with SQLite for backend data (admin, auth, CMS)
### Path Alias
`@/` maps to `src/` — configured in both `tsconfig.json` (`paths`) and `jest.config.js` (`moduleNameMapper`).
### TypeScript Strictness
Beyond `strict: true`, the project enables:
- `noUncheckedIndexedAccess: true` — array/object index access returns `T | undefined`, requiring null checks. This is a significant constraint to be aware of when writing code.
- `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`
### Route Structure (App Router)
```
src/app/
├── layout.tsx # Root layout: fonts, metadata, theme, analytics, SEO schemas
├── (marketing)/ # Route group — all public marketing pages
│ ├── layout.tsx # Shared: Header + Footer + PageTransition + ErrorBoundary
│ ├── page.tsx # Home: server component, CMS zones via `lib/cms/data-server` → `home-content-v15.tsx`
│ ├── about/ # About page (client.tsx)
│ ├── news/ # News list + [slug] detail
│ ├── contact/ # Contact form
│ ├── products/ # Products hub (HSI model)
│ │ ├── page.tsx # Product listing (enterprise suites + standalone)
│ │ ├── [id]/ # Product detail (four-layer narrative)
│ │ ├── standalone/[id]/ # Standalone product detail
│ │ ├── erp-upgrade/ # Specific product landing pages
│ │ └── erp-upgrade-v3/
│ ├── services/ # Services list + [id] detail
│ ├── solutions/ # Solutions list + [id] detail (cross-references products)
│ ├── cases/ # Case studies
│ └── team/ # Team page
├── api/
│ ├── admin/ # Admin API
│ ├── auth/ # Authentication API
│ ├── cms/ # CMS API routes (draft mode, revalidation)
│ └── contact/ # Contact form submission
├── privacy/, terms/ # Legal pages
└── fonts/ # Local font files (Geist Sans/Mono)
```
### Archiving Convention
When replacing a page or component with a new version, move the old one to an `_archive/` subdirectory (e.g., `src/app/(marketing)/_archive/` for old homepage iterations). The archive is excluded from TypeScript compilation via `tsconfig.json`.
### Dark Mode
Dark mode uses the `data-theme="dark"` HTML attribute (not Tailwind's `dark:` class). Tailwind is configured with `darkMode: ['variant', '[data-theme="dark"] &']` — use the **variant** form, not `['selector', ...]`, because the selector form is accepted by the config but JIT emits zero `dark:` rules under Turbopack + Next.js 16.
**Three-state preference model** (`src/lib/theme.ts` is the single source of truth):
| Stored value (`localStorage['novalon-theme']`) | Meaning |
|---|---|
| `'light'` / `'dark'` | Explicit user choice — always wins over the OS |
| `'system'` / absent | Follow OS `prefers-color-scheme` (**default for new visitors**) |
- `src/app/layout.tsx` has an inline `<script>` in `<head>` that resolves the preference and sets `data-theme` **before first paint** (FOUC prevention). It must stay semantically in sync with `src/lib/theme.ts` — change both together.
- `src/components/theme/theme-toggle.tsx` cycles `跟随系统 → 浅色 → 深色`. It listens to `matchMedia('(prefers-color-scheme: dark)')` for live OS changes (only effective in the `system` state) and to `storage` events for cross-tab sync.
- `html[data-theme]` is an **output** of preference resolution — never read it back as the user's preference, or the `system` state collapses into `dark` on dark-OS machines and the cycle deadlocks.
### HSI Information Architecture
The site follows a **Hub-Spoke-Independent** model (see `CONTEXT.md` and ADR-0002):
- **Hub**: `/products` — product catalog, split into "企业套装" (6 enterprise products) and "专业产品" (standalone)
- **Spoke**: `/solutions` — industry scenarios, each recommending product combinations from the Hub
- **Independent**: Standalone products in the specialized zone have their own narrative path
### Four-Layer Narrative Model
Every detail page (product/service/solution/standalone) follows the same four-layer structure:
1. **L1 Hero**: Emotional entry with visual, title, value prop, status badge
2. **L2 Value Rationale**: Product-specific — features/benefits for products, pain-points→architecture for solutions, challenges→results for services
3. **L3 Trust Proof**: Case studies, data proofs, certifications, testimonials (currently stubbed — company is pre-launch)
4. **L4 CTA Conversion**: Primary action + secondary action + cross-recommendations
The detail components implementing this live in `src/components/detail/`:
- `detail-hero.tsx`, `detail-product-value.tsx`, `detail-trust-section.tsx`, `detail-cta-section.tsx`
- `solution-value.tsx`, `service-value.tsx` (type-specific L2 variants)
- `detail-cross-recommend.tsx` (cross-links between products↔solutions↔services)
### CMS Content Architecture
`src/lib/cms/` is a **real, server-side CMS layer over Prisma** — there is no mock layer and no client-side CMS SDK (the historical names `mock-home.ts`, `ContentZoneRenderer.tsx`, `component-registry.ts`, `src/components/cms/` do not exist in the tree; do not look for them):
- `types.ts` — `FieldType`/`FieldDefinition`, `ContentModel`, `ContentStatus`, `ContentItem`, `ContentZone`, `ThemeConfig` definitions
- `content-types.ts` — `CONTENT_TYPE_CONFIGS` + `registerAllContentTypes()`: the per-model field schemas the admin form and the validator both read
- `data-server.ts` — the read path: `getPublishedItems` (`:37`), `getPublishedItemBySlug` (`:48`), `getPageZones` (`:70`), `getZone` (`:85`), `getHomePageCopy` (`:171`), each wrapped in React `cache` so a render pass hits the DB once
- `workflow.ts` / `validate-content-data.ts` / `notifications.ts` — status state machine, field validation, in-app notifications (status changes only through `workflow.ts`; see `docs/cms/api-contract.md` §10.5)
- `index.ts` — re-exports the types plus `CONTENT_TYPE_CONFIGS`
The homepage is `src/app/(marketing)/page.tsx` (server component, `export const revalidate = 3600` at `:5`): it resolves the home zones with `getResolvedHomeZones()` (`:8`) and, when the CMS has nothing configured, falls back to per-model `getPublishedItems(...)` calls (`:23-29`); the markup lives in `home-content-v15.tsx`. Earlier documents calling it `home-content-cms.tsx` are stale.
### Design Token System
All visual tokens are defined as **CSS custom properties** in `src/app/globals.css` (`:root` block) and mapped into Tailwind's config via `var()` references:
- **Colors**: `--color-ink` (deep charcoal #0A0E14), `--color-brand` (vermilion #C41E3A), accent colors for service-coding (blue, teal, amber, purple), functional colors (success, warning, error, info), dark-section overrides
- **Typography**: `--font-size-*`, `--line-height-*`, `--letter-spacing-*` all mapped to Tailwind's `fontSize`/`lineHeight`/`letterSpacing` scales
- **Spacing, radius, shadows** all tokenized through CSS variables
- **Transitions**: `--transition-fast/normal/slow`, `--ease-ink` (cubic-bezier), `--ease-sharp`(`--ease-spring*` 死令牌已于 2026-09-19 polish 删除)
- **Dark mode**: `data-theme="dark"` attribute (set via inline script before paint to prevent flash); flipped by the `[data-theme='dark']` override block in `globals.css`, which only re-maps CSS variables — components do not restyle per element
- **Opacity**: use theme tokens (`bg-brand/20`, `border-ink/30`) — they resolve through `rgb(var(--x-rgb) / <alpha-value>)`. The arbitrary-value form `bg-[var(--color-brand)]/20` is **accepted by the parser but emits no CSS** (Tailwind v3 cannot apply an alpha modifier to a plain-hex custom property; see `tailwind.config.js:12-15`), so it fails silently. Verify any "is this style live?" claim against the compiled stylesheet, not against the source class list.
- **Two-channel red**: `--color-brand` (#C41E3A) is for surfaces only and does not flip in dark mode; text must use `text-brand-ink` (flips to #F87171). Enforced by `scripts/utils/check-brand-text-token.ts` on the text channel.
- **Hover / translucent states are unguarded**: axe skips `:hover` and `check:contrast` only asserts declared token *pairs*, so a hover background nobody noticed was dead becomes a live AA failure the moment you fix it. Composite it yourself — a semi-transparent layer **replaces** the element's own base color: `alpha*brand + (1-alpha)*pageBg`; at 20% that yields 4.18:1 against `text-brand-ink` in light theme (fail), at the `brand-soft` 12% token 4.80:1 (pass).
- **Motion tokens are gated, and the gate is currently RED**: `scripts/utils/check-motion-constraints.ts` (`npm run check:motion`, 配套 `npm run check:motion:test`) enforces the `CONTEXT.md` §动效设计四原则 bands (entrance 180–280ms, ≤700ms ceiling, durations must flow through `--transition-*` tokens, bezier allow-list **parsed from the CSS `--ease-*` tokens** — `--ease-out` is an alias of `ease-ink`, and the token layer deliberately carries 5 further curves). It is deliberately **not** part of `check:a11y`: the tree measures 23 violations / 13 files, so wiring it in would make the aggregate permanently red. `scripts/**` is eslint-ignored, so adding it did not move the lint baseline.
### Design DNA Framework
The site follows a three-dimensional design system (see `CONTEXT.md`):
- **Dimension 1 — Design System**: Quantifiable tokens (colors, typography, spacing, radius, shadows, motion, components)
- **Dimension 2 — Design Style**: Qualitative perception (atmosphere, visual language, composition, imagery, brand tone)
- **Dimension 3 — Visual Effects**: Scroll animations, micro-interactions, parallax, SVG effects
The **Consulting Professional** aesthetic (inspired by Accenture + Bain) is the primary skeleton. **Ink cultural elements** (水墨) are secondary decorations limited to ≤6 locations (logo, dividers, transition animations, footer texture).
**Brand red (#C41E3A) usage rule**: Every page must have ≥3 brand-red touchpoints. It must never be used as paragraph text color, as a large background, or alongside accent colors in the same card. Area ≤10%. Exception: full-bleed dark-red statement/CTA blocks use the separate `crimson-veil` (#8B1530) token — see DESIGN.md「Crimson Veil」(rule clarified 2026-09-19 critique to resolve this apparent contradiction).
**Motion design**: Animations are purposeful (not decorative). Durations come from tokens, **not** from a free-form "150-300ms" range — that phrasing conflicted with `CONTEXT.md`「动效设计四原则」and is replaced by its three tiers: **入场 180–280ms**(`--transition-fast: 180ms` … `--transition-normal: 280ms`,`src/app/globals.css:264-265`)· **hover 150ms** · **反馈 100ms**;`--transition-slow: 450ms` / `--transition-slower: 700ms` 只用于揭示型动效。`ease-ink` `[0.22, 1, 0.36, 1]`(`globals.css:280`)为默认缓动,children stagger 30-60ms、Section 间 stagger 100-150ms。No continuous looping animations except `pulse-soft` for skeletons. No spring easings for content entry(spring 仅用于按钮按压反馈). **No entry animations >700ms.** Note that `duration-300` 一类的 Tailwind 预设时长绕过令牌、且 300ms 不属任何档位(残留清点见 `CONTEXT.md`「duration-300 → 动效令牌」行,该项 in flight,勿引用其计数)。
### Data Layer
Most structured marketing content is still `src/lib/constants/` TypeScript constants, and the pages render from them; on top of that, published CMS items now override specific slots — the home page's hero/services/cases/stats/news data comes from `lib/cms/data-server` (Prisma) and only falls back to constants when the CMS has nothing configured (`src/app/(marketing)/page.tsx:8-36`), and per-section copy comes from `getHomePageCopy()` with in-component fallbacks. "Marketing content has no database" is therefore no longer true:
- `products.ts` — 6 enterprise products (ERP, CRM, CMS, BI, SDS, OA) + standalone products (NovaVis)
- `services.ts`, `solutions.ts` — Service and solution definitions
- `cases.ts` — Case studies with industry filtering
- `navigation.ts` — Nav structure + mega dropdown data
- `company.ts`, `stats.ts`, `team.ts`, `news.ts`, `methodology.ts`
- `hero-themes.ts` — Per-product hero visual theme variants
- `cross-references.ts` — Cross-links between products/solutions/services
Each product/solution/service implements the `Product`/`Solution`/`Service` interface which includes `caseStudies[]`, `dataProofs[]`, `certifications[]`, etc. for the four-layer narrative.
### Backend Layer (Prisma + API Routes)
The project has a backend layer for admin/auth/CMS functionality:
- **Prisma** with SQLite (`prisma/dev.db`) for admin user data, auth, and CMS content management
- API routes at `src/app/api/admin/`, `auth/`, `cms/`, `contact/`
- The marketing read path and the admin write path share this backend: pages fetch published items through `src/lib/cms/data-server.ts`, while `api/admin/*` writes drafts and `api/cms/*` (`draft/enable`, `draft/disable`, `revalidate`) handles preview cache invalidation
### Error Monitoring (Sentry)
`@sentry/nextjs@10` is wired through `src/instrumentation.ts` (server/edge, incl. `onRequestError`) and `src/instrumentation-client.ts` (client init + `onRouterTransitionStart`); `next.config.mjs` exports `withSentryConfig(nextConfig)`. **This Next 16 build defaults to Turbopack, which never loads the root `sentry.client.config.ts`** — client init therefore lives in `instrumentation-client.ts`, and the old file is kept as an empty stub solely to prevent a double `Sentry.init` on the `--webpack` path. Everything is gated on `NEXT_PUBLIC_SENTRY_DSN`: absent it, `Sentry.init` is skipped, both hooks no-op, **and the CSP is byte-identical** (`connect-src`/`worker-src` only gain the DSN's `protocol//host` when a valid DSN is present, `next.config.mjs:17-26`). Verify "is this inert?" by building without a DSN and diffing the emitted header, not by reading the source.
### Component Organization
```
src/components/
├── ui/ # Base: Button, Card, Input, Badge, ScrollReveal, AnimatedCounter, etc.
├── layout/ # Header, Footer, MobileTabBar, Breadcrumb, MegaDropdown(MobileMenu 已删除,抽屉内置于 Header)
├── sections/ # Page section components: HeroSectionV2, ServiceGrid, CTASection, etc.
├── detail/ # Four-layer narrative: DetailHero, ProductValueSection, DetailTrustSection, etc.
├── content/ # 仅 sections.tsx(+ 同名 .test.tsx)
├── admin/ # admin-layout.tsx、auth-context.tsx(React Context 在此,不在 src/contexts/)
├── theme/ # theme-toggle.tsx
├── seo/ # Structured data (OrganizationSchema, WebsiteSchema, etc.)
└── analytics/ # GA4, error tracking, cookie consent, scroll depth, outbound links
```
> 口径核对(2026-09-23,验收 N-33「不存在的目录」一项):`cms/`、`providers/`、`effects/` 三个目录在工作树与 `git ls-tree -r HEAD` 中**均不存在**,旧图里的对应条目是残留。`ContentRenderer` / `SectionRenderer` / `FieldRenderer` 三个标识符在 `src/**/*.{ts,tsx}` 中零命中,CMS 区块渲染实际由 `src/components/content/sections.tsx` + `src/lib/cms/` 承担;`effects/` 一族已在提交 `37296b5`(2026-05-10)删除,见 `CONTEXT.md`「特效组件」。
### Testing Setup
- **Jest** (unit): Tests alongside source in `src/**/*.test.{ts,tsx}`, coverage **thresholds** live in `config/test/jest.config.js` (global 75% stmts/lines/funcs, 82% branches) and are the single source of truth — root `jest.config.js` only re-exports it, never duplicate thresholds. **Measured values are recorded in exactly one place: `docs/development/quality-gates.md` §3** (2026-09-23 本树复跑:134 suites / 1697 tests / EXIT=0);该文件顶部注释里的百分比是滞后快照,引用前先复跑。Uses `@/` path alias and ts-jest with `jsx: 'react-jsx'` transform (since tsconfig uses `'preserve'`). Run single tests with `npx jest --testPathPattern="component-name"`.
- **Playwright** (E2E): Tests in `e2e/`, config in `e2e/playwright.config.ts` — run from `cd e2e` (`npm run test` does this). webServer is `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js` when `E2E_TARGET=production` (`e2e/playwright.config.ts:132-135` — build artifacts, real security headers; CI gate uses `npm run test:e2e:prod`, and `npm run build` + the `dist/static` / `public` copies must already exist because the harness never builds) and `npm run dev` otherwise; `reuseExistingServer` is disabled for production targets, so a busy :3000 aborts the run. Functional projects: chromium, chromium-mobile (iPhone 14, 390×844), firefox, webkit — plus 5 visual-regression projects at desktop/tablet/mobile breakpoints; snapshots in `e2e/visual-snapshots/`. `mobile-*.spec.ts` must pin their own viewport because they also execute under the desktop projects. Touch-target gate lives in `e2e/touch-targets.ts` (AA SC 2.5.8 ≥24px hard, AAA SC 2.5.5 44px advisory). Visual snapshots are server-target independent — every spec imports `test` from `e2e/fixtures.ts`, whose `context` fixture injects an init script that removes the `next dev`-only `<nextjs-portal>` devtools indicator (MutationObserver on `document`, because `documentElement` is still null at document start). The indicator's shadow-DOM `<footer class="error-overlay-footer">` is pierced by Playwright's CSS engine and made `locator('footer')` resolve to 2 elements (56 failures). `devIndicators: false` is *not* a fix here — measured: the CSP-blocked-`eval` dev error keeps the overlay mounted, so the portal survives; don't reintroduce that env gate. `visual-regression.spec.ts` additionally pins the cookie-consent state via `addInitScript` (constant timestamp) because the banner renders on a 2 s timer and the `visual-firefox-desktop` / `visual-webkit-desktop` projects use an empty storage state — without the pin, slower engines photograph the banner into the first viewport and faster ones don't (34 engine-specific failures). Client-component interactions must call `expectHydrated()` from `e2e/hydrated.ts` before hover/click — SSR HTML is visible ~1.7 s before React attaches handlers, and pointer events in that window are dropped without replay. Hover-driven UI additionally needs `expect(...).toPass()` re-driving the pointer, because a single `hover()` can land mid-layout-transition and `mouseenter` never re-fires. Run single tests with `npx playwright test --grep "test name"`.
### Build Output
The project builds to `dist/` (`distDir` in `next.config.mjs:46`) with **`output: 'standalone'`** (`next.config.mjs:44`) — this is a hybrid render model, *not* a static export (`output: 'export'` was dropped and the project has since moved on to standalone; `CONTEXT.md`「生产部署模式」records the 2026-08 switch). Concretely:
- **Prerendered/ISR**: the marketing pages, `privacy`, `terms`, `sitemap.ts`, `robots.ts`.
- **Runtime-backed**: `src/app/api/**` (20 route files — `admin/*`, `auth/*`, `cms/*`, `contact`), the `admin/` pages, and any ISR revalidation source. `proxy.ts` matchers exclude `/api/*`, so each API route authenticates itself.
- **Serving**: Nginx keeps a *disk copy* of the client static assets (`/_next/static/` with `try_files … @nextjs`, `nginx-static-production.conf:151-157`) plus `/uploads/` hardening, and proxies `/api/` (`:213`), `/admin` (`:226`) and everything else (`location /` `:238-240`, `@nextjs` `:249`) to the `nextjs_app` upstream running `dist/standalone/server.js`. Page HTML no longer has a static-file fast path — it was removed on purpose (`nginx-static-production.conf:20-25`), so "nginx serves the pages from `dist/`" is no longer true. `assetPrefix` is driven by `CDN_DOMAIN`. Container build is `Dockerfile.prod` + `docker-compose.server.yml`.
- **Images are unoptimized** (`next.config.mjs:48-61`, value at `:58`) because distribution goes through Nginx + CDN, and flipping it to `false` would require every environment to have a Node runtime taking over `/_next/image` (otherwise every image 404s). The reason is *deployment topology*, not a static-export limitation.
- **Boot command**: `npm run start` / `npm run preview` both run `next start -p 3000`, which Next 16 warns "does not work with output: standalone" (warning only — see the Build & Preview note above). The supported server is `dist/standalone/server.js`; `public/` and `dist/static` are copied into the standalone root **automatically by `package.json:9` 的 `postbuild`**,所以 `npm run build` 之后直接 `node dist/standalone/server.js` 即可。该启动方式仍没有包成 npm script —— CI 在 `Jenkinsfile:353-355` 复用产物时重做同套拷贝,发布侧由 `scripts/deploy.sh:162-175` 把 `public/` 与 `dist/static → dist/_next/static` 同步进 Nginx 站点根。
### Commit Convention
Uses Conventional Commits with commitlint (`@commitlint/config-conventional`). Husky + lint-staged enforces linting on pre-commit.
### Submission Flow (PR-First)
`dev`/`main` are only reached via feature branch + PR: sync `origin/dev` → rebase → push → PR gate → merge by **Rebase** (linear history, no merge commit).
`bash scripts/check-pr-checklist.sh <pr-description-file>` must pass before opening a PR — it verifies `.gitea/PULL_REQUEST_TEMPLATE.md` has the three required sections (全链路检查 / 测试分层检查 / 质量门禁) with ≥20 checklist items, and that every item in the PR body is checked (mark irrelevant ones as `N/A:<reason>`).
Rebase rewrites hashes: an already-pushed branch may only be updated with `--force-with-lease`. Always rebase onto `origin/dev`, never a possibly-stale local `dev`.
### Component Versioning History
The project has gone through multiple design iterations. Older component versions:
- `components/detail-v2/` — previous iteration (deleted per git status, migration to `detail/` completed)
- `components/detail-v3/` — another iteration (deleted, same reason)
- `home-content-v2.tsx` through `home-content-v11.tsx` — archived homepage iterations in `(marketing)/_archive/`
- The current canonical components are in `components/detail/` and `home-content-cms.tsx`