Skip to content
Open
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ count.txt
.output
.vinxi
todos.json
.pnpm-store
.pnpm-store.claude/settings.local.json

# AI setup files (in separate PR)
.agents/
.claude/skills/
197 changes: 179 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,41 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.

## Project Overview

Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an event website featuring 16 developers battling for CSS glory. Built with React 19, TanStack Router, and deploys to Cloudflare Workers.
Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an
event website featuring 16 developers battling for CSS glory. Built with React
19, TanStack Router, and deploys to Cloudflare Workers.

## Commands

**Package manager: pnpm**

```bash
# Development
npm run dev # Start dev server on port 3000
pnpm dev # Start dev server on port 3000
[Note] I will run the dev command myself unless otherwise specified

# Build & Deploy
npm run build # Build for production
npm run deploy # Build and deploy to Cloudflare Workers
pnpm build # Build for production
pnpm deploy # Build and deploy to Cloudflare Workers

# Code Quality
npm run check # Run Biome linter and formatter checks
npm run lint # Lint only
npm run format # Format only
pnpm check # Run Biome linter and formatter checks
pnpm lint # Lint only
pnpm format # Format only

# Testing
npm run test # Run Vitest tests
pnpm test # Run Vitest tests

# Database
npm run db:generate # Generate Drizzle migrations from schema
npm run db:migrate:local # Apply migrations to local D1
npm run db:migrate:prod # Apply migrations to production D1
npm run db:studio # Open Drizzle Studio
npm run db:setup # Generate + migrate local (full setup)
pnpm db:generate # Generate Drizzle migrations from schema
pnpm db:migrate:local # Apply migrations to local D1
pnpm db:migrate:prod # Apply migrations to production D1
pnpm db:studio # Open Drizzle Studio
pnpm db:setup # Generate + migrate local (full setup)
```

## Database Setup
Expand Down Expand Up @@ -66,6 +72,20 @@ BETTER_AUTH_SECRET=your_random_secret
BETTER_AUTH_URL=http://localhost:3000
```

### GitHub OAuth Setup

1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App
2. Fill in:
- **Application name:** Mad CSS (Local) or similar
- **Homepage URL:** `http://localhost:3000/test`
- **Authorization callback URL:**
`http://localhost:3000/api/auth/callback/github`
3. Click "Register application"
4. Copy the **Client ID** to `GITHUB_CLIENT_ID` in `.dev.vars`
5. Generate a new **Client Secret** and copy to `GITHUB_CLIENT_SECRET`

The login flow redirects to `/test` after authentication.

### Production Deployment

1. Set secrets in Cloudflare dashboard (Workers > Settings > Variables):
Expand All @@ -82,21 +102,162 @@ BETTER_AUTH_URL=http://localhost:3000

**Stack:** TanStack Start (SSR framework) + React 19 + Vite + Cloudflare Workers

**File-based routing:** Routes live in `src/routes/`. TanStack Router auto-generates `src/routeTree.gen.ts` - don't edit this file manually.
**File-based routing:** Routes live in `src/routes/`. TanStack Router
auto-generates `src/routeTree.gen.ts` - don't edit this file manually.

**Key directories:**

- `src/routes/` - Page components and API routes
- `src/routes/__root.tsx` - Root layout, includes Header and devtools
- `src/components/` - Reusable components (Header, Ticket, Roster)
- `src/components/` - Reusable components (Header, Ticket, LoginSection,
bracket/, roster/, footer/, rules/)
- `src/lib/` - Auth setup (better-auth) and utilities (cfImage.ts for Cloudflare
Images)
- `src/data/` - Player data (players.ts with 16 contestants)
- `src/styles/` - CSS files imported directly into components
- `public/` - Static assets (logos, images)
- `public/` - Static assets (logos, images, card artwork)

**Path alias:** `@/*` maps to `./src/*`

**Styling:** Plain CSS with CSS custom properties defined in `src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter) and texture backgrounds.
**Styling:** Plain CSS with CSS custom properties defined in
`src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter)
and texture backgrounds.

## Design System & Aesthetic

The app has a **retro sports tournament / arcade** aesthetic inspired by vintage
ticket stubs, torn paper textures, and classic sports programs.

### Color Palette

```css
--orange: #f3370e; /* Primary accent, CTAs, highlights */
--yellow: #ffae00; /* Secondary accent, warnings, badges */
--black: #000000; /* Borders, shadows, text */
--white: #ffffff; /* Text on dark backgrounds */
--beige: #f5eeda; /* Paper/background color */
```

### Typography

- **Block/Display:** `Alfa Slab One` (`--font-block`) - Headlines, buttons,
badges. Always uppercase with letter-spacing (0.05-0.1em)
- **Serif:** Custom serif font (`--font-serif`) - Body text, descriptions
- **Sans:** `Inter` (`--font-sans`) - UI elements, small text

### Key Design Patterns

**Borders & Shadows:**
- 3-4px solid black borders on interactive elements
- 4px black box-shadows that shift on hover/active states
- Button hover: `transform: translate(2px, 2px)` + reduced shadow
- Button active: `transform: translate(4px, 4px)` + no shadow

**Torn Paper Edges:**
- Use CSS `mask-image` with paper texture PNGs
- Top edge: `repeating-paper-top.png`
- Bottom edge: `repeating-paper-bottom.png`
- Combined with `mask-composite: exclude`

**Ticket Stub Elements:**
- Dashed tear lines (4px dashed borders)
- Notched edges using radial gradients
- Barcode decorations
- "ADMIT ONE" style typography

**Buttons:**
```css
.button {
background: var(--yellow);
border: 3px solid var(--black);
box-shadow: 4px 4px 0 var(--black);
font-family: var(--font-block);
text-transform: uppercase;
letter-spacing: 0.05em;
transition: transform 0.1s, box-shadow 0.1s;
}
.button:hover {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 var(--black);
}
.button:active {
transform: translate(4px, 4px);
box-shadow: none;
}
```

**Cards/Containers:**
- Beige (`--beige`) or yellow (`--yellow`) backgrounds
- Thick black borders (4-6px)
- Optional torn paper mask on edges

**Status Badges:**
- Uppercase, small font (0.625-0.75rem)
- Solid fill for active states (yellow bg, black text)
- Outline style for inactive states (transparent bg, colored border)

### What to Avoid

- Rounded corners (keep things sharp/angular)
- Gradients (except for mask effects)
- Drop shadows (use solid offset shadows only)
- Generic sans-serif styling
- Soft/pastel colors

## Code Style

- Biome for linting/formatting (tabs, double quotes)
- TypeScript strict mode
- XY Flow library for tournament bracket visualization

## Bracket System

**Tournament structure (FEEDER_GAMES in Bracket.tsx):**
- 16 players, single elimination bracket
- Left side games: r1-0, r1-1, r1-2, r1-3 → qf-0, qf-1 → sf-0
- Right side games: r1-4, r1-5, r1-6, r1-7 → qf-2, qf-3 → sf-1
- Finals: sf-0 winner vs sf-1 winner

**Tournament stages (sequential order):**
1. Left R1 - games r1-0, r1-1, r1-2, r1-3
2. Right R1 - games r1-4, r1-5, r1-6, r1-7
3. QF - games qf-0, qf-1, qf-2, qf-3 (all together)
4. SF - games sf-0, sf-1 (semifinals)
5. Finals

**Node sizing logic (`isNodeLarge()` in Bracket.tsx):**
- A node is "large" (`round1` class, ~130px with bio) when:
- Its feeder game IS decided (we know the player)
- The current game is NOT decided (active round)
- A node is "small" (`later` class, ~90px, no bio) when:
- Its feeder is NOT decided (TBD state), OR
- The current game IS already decided (completed)

**Dynamic Y positioning:**
- Node Y offsets adjust based on `isNodeLarge()` to center nodes properly
- QF: 0.5 (large) vs 0.62 (small)
- SF: 1.35 (large) vs 1.5 (small)
- Finals: 3.35 (large) vs 3.5 (small)

**Key constants (Bracket.tsx):**
- `NODE_HEIGHT = 70`, `VERTICAL_GAP = 76`, `MATCH_GAP = 146`
- `ROUND_GAP = 220` (horizontal spacing between rounds)

**User picking flow:**
- Users can pick winners for games where both players are known
- Picks stored in `predictions` object keyed by game ID
- `isPickable` flag enables click handlers on player nodes

## Comment Policy

### Unacceptable Comments

- Comments that repeat what code does
- Commented-out code (delete it)
- Obvious comments ("increment counter")
- Comments instead of good naming

### Principle

Code should be self-documenting. If you need a comment to explain WHAT the code
does, consider refactoring to make it clearer.
5 changes: 4 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noImportantStyles": "off"
}
}
},
"javascript": {
Expand Down
94 changes: 94 additions & 0 deletions drizzle/0000_daffy_shard.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`username` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint
CREATE TABLE `user_bracket_status` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`is_locked` integer DEFAULT false NOT NULL,
`locked_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_bracket_status_user_id_unique` ON `user_bracket_status` (`user_id`);--> statement-breakpoint
CREATE INDEX `user_bracket_status_userId_idx` ON `user_bracket_status` (`user_id`);--> statement-breakpoint
CREATE TABLE `user_prediction` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`game_id` text NOT NULL,
`predicted_winner_id` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `user_prediction_userId_idx` ON `user_prediction` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_prediction_userId_gameId_unique` ON `user_prediction` (`user_id`,`game_id`);--> statement-breakpoint
CREATE TABLE `user_score` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`round1_score` integer DEFAULT 0 NOT NULL,
`round2_score` integer DEFAULT 0 NOT NULL,
`round3_score` integer DEFAULT 0 NOT NULL,
`round4_score` integer DEFAULT 0 NOT NULL,
`total_score` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_score_user_id_unique` ON `user_score` (`user_id`);--> statement-breakpoint
CREATE INDEX `user_score_userId_idx` ON `user_score` (`user_id`);--> statement-breakpoint
CREATE INDEX `user_score_totalScore_idx` ON `user_score` (`total_score`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
Loading