Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 15 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ jobs:
e2e/baseball-onboarding-smoke.spec.ts \
e2e/baseball-smoke.spec.ts

# Mobile viewport regression (blocking) — functional phone-width checks
# at 320/390/430px encoding the #799/#806 defect classes: horizontal
# page pan, clipped header/nav controls, fixed-element/bottom-nav
# collisions. See e2e/mobile-viewports.spec.ts.
- name: Run mobile viewport regression (blocking)
if: steps.baseball-auth-ready.outputs.ready == 'true'
run: |
npx playwright test \
--project=mobile-public \
--project=mobile-coach \
--project=mobile-player
Comment thread
njrini99-code marked this conversation as resolved.
Outdated
- name: Run mobile viewport regression (public routes only)
if: steps.baseball-auth-ready.outputs.ready != 'true'
run: npx playwright test --project=mobile-public

- name: Run Playwright tests
env:
PLAYWRIGHT_BASEBALL_SEEDED: ${{ env.SUPABASE_SERVICE_ROLE_KEY != '' && '1' || '' }}
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 }
}
}
169 changes: 169 additions & 0 deletions e2e/mobile-viewports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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): Promise<void> {
const collisions = 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);
}
const bottomNav = fixedEls.find((el) => {
const isNav = el.tagName === 'NAV' || el.getAttribute('role') === 'navigation' || el.querySelector('nav') !== null;
const r = el.getBoundingClientRect();
return isNav && r.top > window.innerHeight - 140 && r.width > window.innerWidth * 0.6;
});
Comment thread
njrini99-code marked this conversation as resolved.
Outdated
if (!bottomNav) return [];
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 offenders;
});
expect.soft(collisions, `${route}: fixed element collides with bottom navigation`).toEqual([]);
}

async function expectMobileFit(page: Page, route: string): Promise<void> {
await gotoSettled(page, route);
await expectNoHorizontalPan(page, route);
await expectNoClippedControls(page, route);
await expectNoBottomNavCollision(page, route);
}

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);
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);
expect(page.url(), `expected ${route} to render without bouncing to /login`).not.toContain('/login');
});
}
});
}
Loading
Loading