Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
35 changes: 35 additions & 0 deletions .cycles-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
"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 }
}
}
194 changes: 194 additions & 0 deletions e2e/mobile-viewports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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);
}

async function expectNoHorizontalPan(page: Page, route: string): Promise<void> {
const { scrollWidth, innerWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect
.soft(scrollWidth, `${route}: page pans horizontally (scrollWidth ${scrollWidth} > viewport ${innerWidth})`)
.toBeLessThanOrEqual(innerWidth + 1);
}

async function expectNoClippedControls(page: Page, route: string): Promise<void> {
const clipped = await page.evaluate(() => {
const offenders: string[] = [];
const containers = Array.from(
document.querySelectorAll('header, nav, [role="navigation"], [role="toolbar"]'),
);
const seen = new Set<Element>();
for (const container of containers) {
for (const el of Array.from(container.querySelectorAll('a, button, [role="button"]'))) {
Comment thread
njrini99-code marked this conversation as resolved.
Outdated
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);
offenders.push(`"${label}" [left ${Math.round(r.left)}, right ${Math.round(r.right)}] vs viewport ${window.innerWidth}`);
}
}
}
return offenders;
});
expect.soft(clipped, `${route}: header/nav controls clipped at viewport edge`).toEqual([]);
}

async function expectNoBottomNavCollision(
page: Page,
route: string,
opts: { expectBottomNav?: boolean } = {},
): Promise<void> {
const result = await 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;
});
if (!bottomNav) return { found: false, offenders: [] as string[] };
const navRect = bottomNav.getBoundingClientRect();
const offenders: string[] = [];
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);
offenders.push(`"${label}" overlaps bottom nav by ${Math.round(overlapX)}×${Math.round(overlapY)}px`);
}
}
return { found: true, offenders };
});
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(result.found, `${route}: no bottom navigation found at phone width (expected per MOBILE_DOCTRINE)`)
.toBe(true);
}
expect.soft(result.offenders, `${route}: fixed element collides with bottom navigation`).toEqual([]);
}

async function expectMobileFit(
page: Page,
route: string,
opts: { expectBottomNav?: boolean } = {},
): Promise<void> {
await gotoSettled(page, route);
await expectNoHorizontalPan(page, route);
await expectNoClippedControls(page, route);
await expectNoBottomNavCollision(page, 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 });
expect(page.url(), `expected ${route} to render without bouncing to /login`).not.toContain('/login');
});
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 });
expect(page.url(), `expected ${route} to render without bouncing to /login`).not.toContain('/login');
});
}
});
}
Loading
Loading