Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .cycles-baseline.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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')
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/pr-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ jobs:
continue-on-error: true
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
run: npx playwright test --project=mobile-public --reporter=line
Comment thread
njrini99-code marked this conversation as resolved.

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
Expand Down
8 changes: 8 additions & 0 deletions .madgerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"fileExtensions": ["ts", "tsx"],
"tsConfig": "tsconfig.json",
"detectiveOptions": {
"ts": { "skipTypeImports": true },
"tsx": { "skipTypeImports": true }
}
}
231 changes: 231 additions & 0 deletions e2e/mobile-viewports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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<void> {
// 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 <nav>/role="navigation") is treated identically by both
// checks instead of only being recognized by one of them.
async function collectMobileFitState(page: Page): Promise<MobileFitState> {
return page.evaluate(() => {
const fixedEls: Element[] = [];
for (const el of Array.from(document.body.querySelectorAll('*'))) {
const style = getComputedStyle(el);
if (style.position !== 'fixed') continue;
if (style.pointerEvents === 'none') continue; // toast portals, inset-0 wrappers
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
fixedEls.push(el);
}
// A bottom nav is either semantically marked (nav / role=navigation) OR
// merely tab-bar SHAPED: bottom-pinned, near-full-width, with 2+
// interactive children. The shape fallback keeps this check alive if the
// app shell ever renders the bar as a plain fixed wrapper.
const bottomNav =
fixedEls.find((el) => {
const r = el.getBoundingClientRect();
const bottomPinned = r.top > window.innerHeight - 140 && r.bottom >= window.innerHeight - 40;
const wide = r.width > window.innerWidth * 0.6;
if (!bottomPinned || !wide) return false;
const semantic =
el.tagName === 'NAV' || el.getAttribute('role') === 'navigation' || el.querySelector('nav') !== null;
const tabBarShaped = el.querySelectorAll('a, button, [role="button"], [role="tab"]').length >= 2;
return semantic || tabBarShaped;
}) ?? null;

// --- clipped header/nav/toolbar controls -----------------------------
const containers = new Set<Element>(
document.querySelectorAll('header, nav, [role="navigation"], [role="toolbar"]'),
);
// A plain fixed bottom-nav wrapper (recognized only via the shape
// heuristic above, not <nav>/role) is otherwise invisible to this scan —
// include it so a clipped control inside it is still caught.
if (bottomNav) containers.add(bottomNav);

const clipped: string[] = [];
const seen = new Set<Element>();
for (const container of containers) {
for (const el of Array.from(container.querySelectorAll('a, button, [role="button"]'))) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (seen.has(el)) continue;
seen.add(el);
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.bottom < 0 || r.top > window.innerHeight) continue;
// Only flag PARTIAL clipping: visible in the viewport but cut off at
// an edge. Fully off-screen = intentional off-canvas UI, not a bug.
const partiallyVisible = r.left < window.innerWidth && r.right > 0;
const cutRight = r.right > window.innerWidth + 1 && r.left < window.innerWidth - 8;
const cutLeft = r.left < -1 && r.right > 8;
if (partiallyVisible && (cutRight || cutLeft)) {
const label = (el.textContent || el.getAttribute('aria-label') || el.tagName)
.trim()
.slice(0, 40);
clipped.push(`"${label}" [left ${Math.round(r.left)}, right ${Math.round(r.right)}] vs viewport ${window.innerWidth}`);
}
}
}

// --- bottom-nav collision ---------------------------------------------
const collisions: string[] = [];
if (bottomNav) {
const navRect = bottomNav.getBoundingClientRect();
for (const el of fixedEls) {
if (el === bottomNav || bottomNav.contains(el) || el.contains(bottomNav)) continue;
const r = el.getBoundingClientRect();
const overlapX = Math.min(r.right, navRect.right) - Math.max(r.left, navRect.left);
const overlapY = Math.min(r.bottom, navRect.bottom) - Math.max(r.top, navRect.top);
if (overlapX > 8 && overlapY > 8) {
const label = (el.getAttribute('aria-label') || el.textContent || el.className.toString())
.trim()
.slice(0, 40);
collisions.push(`"${label}" overlaps bottom nav by ${Math.round(overlapX)}×${Math.round(overlapY)}px`);
}
}
}

return {
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
clipped,
bottomNavFound: bottomNav !== null,
collisions,
};
});
}

function assertNoHorizontalPan(state: MobileFitState, route: string): void {
expect
.soft(
state.scrollWidth,
`${route}: page pans horizontally (scrollWidth ${state.scrollWidth} > viewport ${state.innerWidth})`,
)
.toBeLessThanOrEqual(state.innerWidth + 1);
}

function assertNoClippedControls(state: MobileFitState, route: string): void {
expect.soft(state.clipped, `${route}: header/nav controls clipped at viewport edge`).toEqual([]);
}

function assertNoBottomNavCollision(
state: MobileFitState,
route: string,
opts: { expectBottomNav?: boolean } = {},
): void {
if (opts.expectBottomNav) {
// Authenticated dashboard surfaces carry a bottom nav at phone widths
// (docs/MOBILE_DOCTRINE.md). Without this assertion, a missed/renamed
// bottom nav would silently skip the collision check entirely.
expect
.soft(state.bottomNavFound, `${route}: no bottom navigation found at phone width (expected per MOBILE_DOCTRINE)`)
.toBe(true);
}
expect.soft(state.collisions, `${route}: fixed element collides with bottom navigation`).toEqual([]);
}

async function expectMobileFit(
page: Page,
route: string,
opts: { expectBottomNav?: boolean } = {},
): Promise<void> {
await gotoSettled(page, route);
const state = await collectMobileFitState(page);
assertNoHorizontalPan(state, route);
assertNoClippedControls(state, route);
assertNoBottomNavCollision(state, route, opts);
}

for (const viewport of VIEWPORTS) {
test.describe(`public routes @ ${viewport.width}px`, { tag: '@public' }, () => {
test.use({ viewport });
for (const route of PUBLIC_ROUTES) {
test(`${route} fits ${viewport.width}px`, async ({ page }) => {
await expectMobileFit(page, route);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});

test.describe(`baseball coach routes @ ${viewport.width}px`, { tag: '@coach' }, () => {
test.use({ viewport });
for (const route of COACH_ROUTES) {
test(`${route} fits ${viewport.width}px (coach)`, async ({ page }) => {
await expectMobileFit(page, route, { expectBottomNav: true });
// Assert the FINAL pathname, not just "didn't bounce to /login" — a
// server route guard (src/lib/baseball/server-route-guards.ts) can
// redirect an authed-but-unauthorized coach to a different dashboard
// route entirely, which the old /login-only check would miss.
expect(new URL(page.url()).pathname, `expected ${route} to render`).toBe(route);
});
Comment thread
njrini99-code marked this conversation as resolved.
}
});

test.describe(`baseball player routes @ ${viewport.width}px`, { tag: '@player' }, () => {
test.use({ viewport });
for (const route of PLAYER_ROUTES) {
test(`${route} fits ${viewport.width}px (player)`, async ({ page }) => {
await expectMobileFit(page, route, { expectBottomNav: true });
// Same rationale as the coach block above: assert the FINAL
// pathname rather than only rejecting /login.
expect(new URL(page.url()).pathname, `expected ${route} to render`).toBe(route);
});
}
});
}
Loading
Loading