Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
- name: Lint
run: npm run lint

- name: Lint ratchet (warnings must not grow per-rule)
run: npm run lint:ratchet

- name: Unit tests
run: npm run test -- --run

Expand Down
13 changes: 13 additions & 0 deletions .lint-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"(null)": 2,
"@typescript-eslint/no-explicit-any": 44,
"@typescript-eslint/no-unused-vars": 1,
"helm/no-arbitrary-bg-white": 1302,
"helm/no-arbitrary-radius": 38,
"helm/no-arbitrary-text-px": 165,
"helm/no-banned-color": 260,
"helm/no-raw-button": 104,
"helm/no-raw-input": 458,
"jsx-a11y/anchor-is-valid": 1,
"jsx-a11y/aria-role": 19
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"build": "next build --webpack",
"start": "next start",
"lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 6000",
"lint:ratchet": "node scripts/lint-ratchet.mjs",
"typecheck": "tsc --noEmit",
"db:types": "npx supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/lib/types/database.ts",
"db:types:check": "npm run db:types && git diff --exit-code src/lib/types/database.ts || (echo '❌ Types are out of date. Run npm run db:types and commit changes.' && exit 1)",
Expand Down
164 changes: 164 additions & 0 deletions scripts/lint-ratchet.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* lint-ratchet.mjs
*
* Runs `npx eslint src --format json`, tallies warnings per rule-id, and
* compares them against .lint-baseline.json.
*
* Exit codes:
* 0 — no regression (all rule counts <= baseline)
* 1 — regression detected (at least one rule count > baseline)
*
* Flags:
* --update Rewrite .lint-baseline.json from the current run and exit 0.
*/

import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const BASELINE_PATH = resolve(ROOT, '.lint-baseline.json');

const UPDATE = process.argv.includes('--update');

// ---------------------------------------------------------------------------
// 1. Run ESLint and collect per-rule warning counts
// ---------------------------------------------------------------------------
let eslintOutput;
try {
// execFileSync with an explicit argv array — no shell, no injection surface.
// stderr → 'inherit' so deprecation notices print directly and don't
// pollute the JSON stdout buffer we parse below.
eslintOutput = execFileSync(
'npx',
['eslint', 'src', '--format', 'json', '--max-warnings', '999999'],
// maxBuffer: 64 MB — the full-repo JSON output is ~10 MB today and will
// grow; 64 MB leaves ample headroom without meaningful memory cost.
{ cwd: ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'] }
);
} catch (err) {
// eslint exits non-zero when warnings/errors are present, but still writes
// valid JSON to stdout. Use that if it looks like JSON.
eslintOutput = (err.stdout || '').trim();
if (!eslintOutput.startsWith('[')) {
console.error('ESLint failed and did not produce JSON output.');
console.error(err.message);
process.exit(1);
}
}

/** @type {Array<{messages: Array<{severity: number, ruleId: string|null}>}>} */
let files;
try {
files = JSON.parse(eslintOutput);
} catch (parseErr) {
console.error('Could not parse ESLint JSON output:', parseErr.message);
process.exit(1);
}

/** @type {Record<string, number>} */
const current = {};
for (const file of files) {
for (const msg of file.messages) {
if (msg.severity === 1) {
// severity 1 = warning; severity 2 = error
const rule = msg.ruleId ?? '(null)';
current[rule] = (current[rule] ?? 0) + 1;
}
}
}

// Stable sorted copy for writing / printing
const sortedCurrent = Object.fromEntries(
Object.entries(current).sort(([a], [b]) => a.localeCompare(b))
);

const totalNow = Object.values(current).reduce((s, n) => s + n, 0);

// ---------------------------------------------------------------------------
// 2. --update: overwrite baseline and exit
// ---------------------------------------------------------------------------
if (UPDATE) {
writeFileSync(BASELINE_PATH, JSON.stringify(sortedCurrent, null, 2) + '\n', 'utf-8');
console.log(
`lint-ratchet: baseline updated — ${totalNow} warning${totalNow !== 1 ? 's' : ''} across ${Object.keys(sortedCurrent).length} rule${Object.keys(sortedCurrent).length !== 1 ? 's' : ''} locked in ${BASELINE_PATH}`
);
process.exit(0);
}

// ---------------------------------------------------------------------------
// 3. Load baseline
// ---------------------------------------------------------------------------
/** @type {Record<string, number>} */
let baseline;
try {
baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'));
} catch {
console.error(
`lint-ratchet: baseline file not found at ${BASELINE_PATH}.\n` +
'Run `npm run lint:ratchet -- --update` to create it.'
);
process.exit(1);
}

const totalBaseline = Object.values(baseline).reduce((s, n) => s + n, 0);

// ---------------------------------------------------------------------------
// 4. Per-rule comparison
// ---------------------------------------------------------------------------
/** @type {Array<{rule: string, baseline: number, now: number, delta: number}>} */
const regressions = [];

// Check every rule that appears in current run
for (const [rule, nowCount] of Object.entries(current)) {
const baseCount = baseline[rule] ?? 0;
if (nowCount > baseCount) {
regressions.push({ rule, baseline: baseCount, now: nowCount, delta: nowCount - baseCount });
}
}

// Also check rules in baseline that have gone to 0 (not a regression, just informational)
// No action needed — fewer warnings are always fine.

// ---------------------------------------------------------------------------
// 5. Report
// ---------------------------------------------------------------------------
if (regressions.length > 0) {
console.error('lint-ratchet: WARNING COUNT REGRESSION DETECTED\n');
console.error(
'The following rules have MORE warnings than the baseline.\n' +
'Fix the new violations, or run `npm run lint:ratchet -- --update` only\n' +
'after the net warning count has decreased.\n'
);

const maxRuleLen = Math.max(...regressions.map((r) => r.rule.length));
console.error(
` ${'Rule'.padEnd(maxRuleLen)} ${'Baseline'.padStart(8)} ${'Now'.padStart(8)} ${'Delta'.padStart(6)}`
);
console.error(` ${'-'.repeat(maxRuleLen + 28)}`);

for (const { rule, baseline: b, now, delta } of regressions.sort(
(a, b_) => b_.delta - a.delta
)) {
console.error(
` ${rule.padEnd(maxRuleLen)} ${String(b).padStart(8)} ${String(now).padStart(8)} +${String(delta).padStart(5)}`
);
}

console.error(`\n Total: ${totalBaseline} → ${totalNow} (net ${totalNow >= totalBaseline ? '+' : ''}${totalNow - totalBaseline})`);
process.exit(1);
}

if (totalNow < totalBaseline) {
console.log(
`lint-ratchet: warnings dropped (${totalBaseline} → ${totalNow}) — run \`npm run lint:ratchet -- --update\` to lock in the gains`
);
} else {
// totalNow === totalBaseline (per-rule no regressions, same total)
console.log(`lint-ratchet: OK — ${totalNow} warning${totalNow !== 1 ? 's' : ''}, no regressions`);
}

process.exit(0);
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@
<p className="text-warm-500 mt-1">Just one more step to complete your account</p>
</div>

<div className="bg-white rounded-2xl border border-warm-200 p-6 shadow-sm space-y-6">

Check warning on line 115 in src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
{/* Role Selection */}
<div>
<label className="text-sm font-medium text-warm-700 mb-3 block">I am a...</label>
<p className="text-sm font-medium text-warm-700 mb-3 block">I am a...</p>
<div className="grid grid-cols-2 gap-3">
<Button variant="primary"
onClick={() => { setRole('coach'); setPlayerType(null); }}
Expand Down
1 change: 1 addition & 0 deletions src/app/baseball/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
</p>
<Link href="/baseball/login">
<Button variant="ghost"
className="

Check warning on line 204 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
w-full py-2.5 sm:py-3
bg-white text-warm-700
font-medium text-sm
Expand Down Expand Up @@ -230,16 +230,17 @@

<div className="space-y-1.5">
<label htmlFor="forgot-email" className="text-sm font-medium text-warm-700">Email</label>
<input

Check warning on line 233 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Use <Input> from @/components/ui instead of a raw <input>. (W0 token foundation — synthesis §5 rule #1)
id="forgot-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: primary input on a single-field auth page
autoFocus
autoComplete="email"
className="

Check warning on line 243 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
w-full px-4 py-2.5 sm:py-3
bg-white
border border-warm-200
Expand Down Expand Up @@ -270,9 +271,9 @@
>
{loading ? (
<div className="flex items-center gap-1" role="status" aria-label="Sending reset link">
<span className="w-1.5 h-1.5 bg-white rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />

Check warning on line 274 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
<span className="w-1.5 h-1.5 bg-white rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />

Check warning on line 275 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
<span className="w-1.5 h-1.5 bg-white rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />

Check warning on line 276 in src/app/baseball/(auth)/forgot-password/page.tsx

View workflow job for this annotation

GitHub Actions / build

Replace "bg-white" with a canonical surface (bg-cream-50 for card-elevated, glass-standard for translucent panels, glass-subtle for filter rails, or a token-backed bg-surface-* utility). (W0 token foundation — synthesis §5)
<span className="sr-only">Sending reset link...</span>
</div>
) : (
Expand Down
1 change: 1 addition & 0 deletions src/app/baseball/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export default function ResetPasswordPage() {
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your new password"
required
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: primary input on a single-field auth page
autoFocus
autoComplete="new-password"
className="
Expand Down
1 change: 1 addition & 0 deletions src/app/baseball/(coach-dashboard)/coach/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function CoachDashboardLayout({ children }: { children: React.Rea
<SessionActivityProvider>
<LastSeenUpdater />
<PeekPanelProvider>
{/* eslint-disable-next-line jsx-a11y/aria-role -- role is a custom component prop, not an ARIA role */}
<BaseballDashboardShell role="coach">{children}</BaseballDashboardShell>
</PeekPanelProvider>
</SessionActivityProvider>
Expand Down
6 changes: 4 additions & 2 deletions src/app/baseball/(dashboard)/dashboard/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,10 @@ function DiscoverContent() {
{/* Mobile filter drawer with slide-in animation */}
{mobileFiltersOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div
className="absolute inset-0 bg-warm-900/50 backdrop-blur-sm animate-fade-in"
<button
type="button"
aria-label="Close filters"
className="absolute inset-0 bg-warm-900/50 backdrop-blur-sm animate-fade-in w-full h-full border-0 cursor-default"
onClick={() => setMobileFiltersOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-full max-w-sm bg-white shadow-xl overflow-y-auto animate-slide-in-left">
Expand Down
15 changes: 10 additions & 5 deletions src/app/baseball/(dashboard)/dashboard/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,10 @@ export default function EventsPage() {
{/* Create Event Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-warm-900/50 backdrop-blur-sm"
<button
type="button"
aria-label="Close modal"
className="absolute inset-0 bg-warm-900/50 backdrop-blur-sm w-full h-full border-0 cursor-default"
onClick={() => setShowCreateModal(false)}
/>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden max-h-[90vh] overflow-y-auto">
Expand Down Expand Up @@ -380,10 +382,11 @@ export default function EventsPage() {
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">
<label htmlFor="event-start-time" className="block text-sm font-medium text-warm-700 mb-1.5">
Start Time
</label>
<input
id="event-start-time"
type="datetime-local"
value={newEvent.start_time}
onChange={(e) => setNewEvent({ ...newEvent, start_time: e.target.value })}
Expand All @@ -392,10 +395,11 @@ export default function EventsPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">
<label htmlFor="event-end-time" className="block text-sm font-medium text-warm-700 mb-1.5">
End Time
</label>
<input
id="event-end-time"
type="datetime-local"
value={newEvent.end_time}
onChange={(e) => setNewEvent({ ...newEvent, end_time: e.target.value })}
Expand All @@ -410,10 +414,11 @@ export default function EventsPage() {
onChange={(e) => setNewEvent({ ...newEvent, location: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">
<label htmlFor="event-description" className="block text-sm font-medium text-warm-700 mb-1.5">
Description (Optional)
</label>
<textarea
id="event-description"
value={newEvent.description}
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
placeholder="Add notes or details about this event..."
Expand Down
18 changes: 10 additions & 8 deletions src/app/baseball/(dashboard)/dashboard/pipeline/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ export default function PipelinePage() {

{/* Grad Year Filter (shared) */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-warm-700 whitespace-nowrap">Grad Year:</label>
<span className="text-sm font-medium text-warm-700 whitespace-nowrap">Grad Year:</span>
<Select
options={gradYearOptions}
value={gradYearFilter}
Expand Down Expand Up @@ -623,7 +623,7 @@ export default function PipelinePage() {
{/* Additional Filters */}
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-warm-700">Position:</label>
<span className="text-sm font-medium text-warm-700">Position:</span>
<Select
options={[
{ value: 'all', label: 'All Positions' },
Expand Down Expand Up @@ -702,8 +702,9 @@ export default function PipelinePage() {
onChange={() => togglePlayerSelection(item.id)}
className="mt-1 rounded border-warm-300 text-primary-600 focus:ring-primary-500 w-5 h-5"
/>
<div
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
<button
type="button"
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer text-left appearance-none bg-transparent border-0 p-0"
onClick={() => setPeekPlayerId(item.player?.id || null)}
>
<Avatar
Expand All @@ -719,7 +720,7 @@ export default function PipelinePage() {
{item.player?.primary_position || 'N/A'} {item.player?.grad_year ? `\u2022 ${item.player.grad_year}` : ''}
</p>
</div>
</div>
</button>
<Badge
variant={
item.pipeline_stage === 'committed' ? 'success'
Expand Down Expand Up @@ -861,8 +862,9 @@ export default function PipelinePage() {
/>
</td>
<td className="px-6 py-4">
<div
className="flex items-center gap-3 cursor-pointer group"
<button
type="button"
className="flex items-center gap-3 cursor-pointer group text-left appearance-none bg-transparent border-0 p-0 w-full"
onClick={() => setPeekPlayerId(item.player?.id || null)}
>
<Avatar
Expand All @@ -878,7 +880,7 @@ export default function PipelinePage() {
{item.player?.high_school_name || 'No school'}
</p>
</div>
</div>
</button>
</td>
<td className="px-6 py-4 text-sm text-warm-600">
{item.player?.primary_position}
Expand Down
Loading
Loading