diff --git a/.cycles-baseline.json b/.cycles-baseline.json new file mode 100644 index 000000000..3cf955ed4 --- /dev/null +++ b/.cycles-baseline.json @@ -0,0 +1,40 @@ +{ + "skipped": [ + "server-only" + ], + "cycles": [ + "app/golf/actions/stats-data.ts > lib/golf/detailed-stats-admin-bridge.ts", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/CoverHero.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/EmptyIssue.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/GradeStampGrid.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/KPIContentsStrip.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/PlayerRowPlate.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/RecruitCard.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/SlashLine.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/StatLineStack.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/TearSheet.tsx", + "components/baseball/living-annual/index.ts > components/baseball/living-annual/molecules/index.ts > components/baseball/living-annual/molecules/ToolRailStack.tsx", + "components/baseball/stat-visuals/StatVisualsSection.tsx > components/baseball/stat-visuals/index.ts", + "components/fairway/cards-insight/themes/CauseRow.tsx > components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx > components/fairway/cards-insight/themes/index.ts", + "components/fairway/cards-insight/themes/ThemeCard.tsx > components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx > components/fairway/cards-insight/themes/index.ts", + "components/fairway/cards-insight/themes/ThemesPanel.tsx > components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx > components/fairway/cards-insight/themes/index.ts", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayBrief.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayEffectiveness.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayMyDevelopment.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayMyDevelopment.tsx > components/fairway/pages/coachhelm/CausalWhyPanel.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayMyDevelopment.tsx > components/fairway/pages/coachhelm/FocusAreaCard.tsx > components/fairway/pages/coachhelm/PracticeRxForInsight.tsx > components/fairway/pages/coachhelm/PracticeRxPanel.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayMyDevelopment.tsx > components/fairway/pages/coachhelm/GoalsSection.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayMyDevelopment.tsx > components/fairway/pages/coachhelm/GoalsSection.tsx > components/fairway/pages/coachhelm/FairwayGoalCard.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/GenomeCompareView.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/GenomeDetailView.tsx", + "components/fairway/index.ts > components/fairway/pages/coachhelm/index.ts > components/fairway/pages/coachhelm/PlayersGridView.tsx", + "components/fairway/index.ts > components/fairway/pages/dashboard/index.ts > components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx", + "components/fairway/index.ts > components/fairway/pages/dashboard/index.ts > components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx > components/fairway/pages/dashboard/PlayerActionCenter.tsx", + "components/fairway/index.ts > components/fairway/pages/dashboard/index.ts > components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx > components/fairway/pages/dashboard/PlayerActionCenter.tsx > components/fairway/pages/dashboard/player-dashboard-parts.tsx", + "components/fairway/index.ts > components/fairway/pages/dashboard/index.ts > components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx > components/fairway/pages/dashboard/PlayerActionCenter.tsx > components/fairway/pages/hub/hub-parts.tsx", + "components/fairway/pages/coachhelm/FairwayBrief.tsx > components/fairway/pages/coachhelm/index.ts", + "components/golf/coachhelm/insights/DiagnosisPanel.tsx > components/golf/coachhelm/insights/EvidencePanel.tsx", + "lib/admin/data/incident-feed.ts > lib/admin/data/triage.ts" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0701ef133..5d2231ff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,6 +287,31 @@ jobs: npm run ui:routes npm run routes:hygiene:p0p1 + import-cycles: + name: Import-cycle ratchet + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # Value-level import cycles typecheck and build cleanly but can crash at + # cold runtime (TDZ) — the #803/#804 prod-crash class. Baseline-aware: + # fails only on cycles not in .cycles-baseline.json. + - name: Check for new runtime import cycles + run: npm run check:cycles + all: name: all if: always() @@ -308,6 +333,7 @@ jobs: - next-build - route-hygiene - supabase + - import-cycles steps: - name: Fail if any required CI check failed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index ce42c609c..2cb114148 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -154,15 +154,24 @@ jobs: if: steps.baseball-auth-ready.outputs.ready == 'true' run: npm run seed:baseball:ci - - name: Run BaseballHelm mandatory smoke (blocking) + # One invocation = one dev-server lifecycle. The mobile viewport + # regression projects (blocking; e2e/mobile-viewports.spec.ts — the + # #799/#806 phone-width defect classes at 320/390/430px) ride the same + # Playwright process as the mandatory smoke instead of booting a second + # webServer that could hit port-3000 contention. + - name: Run BaseballHelm mandatory smoke + mobile viewport regression (blocking) if: steps.baseball-auth-ready.outputs.ready == 'true' run: | npx playwright test \ --project=chromium \ --project=baseball-coach \ --project=baseball-player \ + --project=mobile-public \ + --project=mobile-coach \ + --project=mobile-player \ e2e/baseball-onboarding-smoke.spec.ts \ - e2e/baseball-smoke.spec.ts + e2e/baseball-smoke.spec.ts \ + e2e/mobile-viewports.spec.ts - name: Run Playwright tests env: diff --git a/.github/workflows/pr-smoke.yml b/.github/workflows/pr-smoke.yml index 9d959586c..dbfe323f8 100644 --- a/.github/workflows/pr-smoke.yml +++ b/.github/workflows/pr-smoke.yml @@ -91,10 +91,53 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium + # Started once and shared (via PLAYWRIGHT_BASE_URL below) by both test + # steps that follow. Without this, each step's own `npx playwright test` + # would boot its own webServer on port 3000 — the accessibility step's + # server torn down right before the mobile step tries to bind the same + # port, which can fail with a startup timeout/port-in-use error if the + # port hasn't been released yet, independent of either step's own + # test result. + - name: Start dev server + run: | + set -euo pipefail + npm run dev > /tmp/dev-server.log 2>&1 & + echo $! > /tmp/dev-server.pid + for i in $(seq 1 60); do + if curl -sf http://localhost:3000 -o /dev/null; then + echo "Dev server ready after ${i} attempt(s)." + exit 0 + fi + if ! kill -0 "$(cat /tmp/dev-server.pid)" 2>/dev/null; then + echo "::error::Dev server process died during startup" + cat /tmp/dev-server.log + exit 1 + fi + sleep 2 + done + echo "::error::Dev server did not become ready within 120s" + cat /tmp/dev-server.log + exit 1 + - name: Run public accessibility smoke continue-on-error: true + env: + PLAYWRIGHT_BASE_URL: http://localhost:3000 run: npx playwright test e2e/accessibility.spec.ts --project=chromium --reporter=line + # Public mobile viewport checks (320/390/430px): no horizontal pan, no + # clipped header/nav controls, no bottom-nav collisions. Blocking — these + # are pure layout-geometry assertions with no a11y-debt exemptions. + # Authenticated mobile coverage runs on main via playwright.yml. + - name: Run public mobile viewport checks + env: + PLAYWRIGHT_BASE_URL: http://localhost:3000 + run: npx playwright test --project=mobile-public --reporter=line + + - name: Stop dev server + if: always() + run: kill "$(cat /tmp/dev-server.pid)" 2>/dev/null || true + - name: Upload Playwright report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/.madgerc b/.madgerc new file mode 100644 index 000000000..223b78979 --- /dev/null +++ b/.madgerc @@ -0,0 +1,8 @@ +{ + "fileExtensions": ["ts", "tsx"], + "tsConfig": "tsconfig.json", + "detectiveOptions": { + "ts": { "skipTypeImports": true }, + "tsx": { "skipTypeImports": true } + } +} diff --git a/e2e/mobile-viewports.spec.ts b/e2e/mobile-viewports.spec.ts new file mode 100644 index 000000000..fcbfbb51d --- /dev/null +++ b/e2e/mobile-viewports.spec.ts @@ -0,0 +1,238 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Mobile viewport regression suite. + * + * Encodes the phone-width defect classes that have repeatedly shipped and + * been fixed by hand (PR #799 FAB/bottom-nav collisions, PR #806 calendar + * "Today" pill clipped at 390px) as functional assertions at real device + * widths, per docs/MOBILE_DOCTRINE.md: + * + * 1. No horizontal page pan — the page never scrolls sideways. + * 2. No partially-clipped header/nav controls — a control that is visible + * but cut off by the viewport edge (the #806 class). Fully off-screen + * elements are ignored: closed drawers/sheets legitimately park at + * translateX(100%). + * 3. No fixed element overlapping the bottom navigation (the #799 class). + * + * Projects (see playwright.config.ts): `mobile-public` runs unauthenticated; + * `mobile-coach` / `mobile-player` reuse the CI-seeded baseball storageState + * from the `setup` project, same as the mandatory smoke suite. + */ + +const VIEWPORTS = [ + { width: 320, height: 568 }, + { width: 390, height: 844 }, + { width: 430, height: 932 }, +] as const; + +const PUBLIC_ROUTES = ['/', '/golf/login', '/baseball/login']; + +const COACH_ROUTES = [ + '/baseball/dashboard/command-center', + '/baseball/dashboard/calendar', + '/baseball/dashboard/roster', + '/baseball/dashboard/stats-center', + '/baseball/dashboard/settings', +]; + +const PLAYER_ROUTES = [ + '/baseball/player/today', + '/baseball/dashboard/calendar', + '/baseball/dashboard/roster', + '/baseball/dashboard/my-stats', +]; + +async function gotoSettled(page: Page, route: string): Promise { + // Reduced motion stabilizes geometry: entrance animations otherwise leave + // elements mid-transform when we measure. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(route, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); + await page.waitForTimeout(250); +} + +type MobileFitState = { + scrollWidth: number; + innerWidth: number; + clipped: string[]; + bottomNavFound: boolean; + collisions: string[]; +}; + +// Single evaluate pass shared by all three checks below. The bottom-nav +// element is detected ONCE here and reused both as an extra container to +// scan for clipped controls and as the collision-check target, so a plain +// fixed wrapper (no