Skip to content
Merged
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
1 change: 1 addition & 0 deletions .cursor/rules/00-project.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Golden rules:
- staging and production are isolated (Neon branch, Worker env, R2 bucket per env). Never mix secrets.
- Don't add anything you can't explain in the follow-up interview.
- When rubric-relevant work ships, update `SUBMISSION.md`, spec mapping, and checklists in the same change (see `.cursor/rules/111-submission-docs.mdc`).
- New routes and async UI must follow page-load motion rules (see `.cursor/rules/31-page-load-motion.mdc`) — animated skeletons, `AnimatedOutlet`, no abrupt pop-in.
6 changes: 6 additions & 0 deletions .cursor/rules/110-docs.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ globs: "**/*.md"
- Keep the README's five graded sections accurate to the code (perf, media, scale, security, prod).
- Record decisions as ADRs in docs/adr. Document every public surface. Wrong docs are worse than none.
- Reviewer-facing docs (`SUBMISSION.md`, checklists, spec mapping): see **111-submission-docs** — sync with [docs/HOMEWORK-SPEC.md](../../docs/HOMEWORK-SPEC.md) when rubric-relevant work ships; professional yet personable tone for 501.
- Game mode “how to play” copy: see **32-game-guides** — single source `apps/web/src/content/game-guides.ts` + [docs/GAME-GUIDES.md](../../docs/GAME-GUIDES.md).
- Form field tooltips: see **33-field-tooltips** — `field-tooltips.ts` + [docs/FIELD-TOOLTIPS.md](../../docs/FIELD-TOOLTIPS.md).
- Auth / JWT / API keys: see **34-auth** — [docs/AUTH.md](../../docs/AUTH.md).
- Scale / cache / rate limits: see **35-scale** — [docs/SCALE.md](../../docs/SCALE.md).
- Testing / contracts / visual: see **36-quality-dx**.
- PWA / i18n / scorecard / analytics / API docs: see **37-tier7-extras** — [docs/EXTRAS.md](../../docs/EXTRAS.md).
1 change: 1 addition & 0 deletions .cursor/rules/20-frontend-react.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ globs: "apps/web/**/*.{ts,tsx}"
- Route components are lazy-loaded (code splitting). No global store.
- Live updates via the `useLiveSession` WebSocket hook; polling is the documented fallback.
- API base URL comes from `import.meta.env.VITE_API_BASE` (per-env).
- Form inputs: use `TextField` / `SelectField` / `FileField` with required `tooltip` — see **33-field-tooltips**.
35 changes: 30 additions & 5 deletions .cursor/rules/30-styling.mdc
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
---
description: Styling — Tailwind v4 + shadcn only
description: Styling — Tailwind v4 + shadcn + Oche motion system
globs: "apps/web/**/*.{ts,tsx,css}"
---
- Tailwind utilities + shadcn/ui components only. No ad-hoc CSS files.
- Theme tokens live once in `src/index.css` (`@theme`). Identity = "live scoreboard":
deep slate canvas, chalk text, single `--color-oche` accent, condensed tabular `.score` numerals.
- Don't ship default-looking shadcn; keep the deliberate accent + spacing rhythm.
# Visual identity
- Tailwind utilities + shadcn/ui components only. Theme tokens live in `src/index.css` (`@theme`).
- Identity = "live scoreboard": deep slate canvas, chalk text, single `--color-oche` accent, condensed tabular `.score` numerals.
- Don't ship default-looking shadcn; keep deliberate accent + spacing rhythm.

# Motion philosophy (interview polish)
Every interactive surface should feel **modern, smooth, and subtly captivating** — never flashy or distracting.

**Page loads & route entry:** see **31-page-load-motion** — skeletons + slide entrances mandatory; no bare "Loading…" text.

## Principles
- **Press feedback:** every `<button>`, shadcn `Button`, and tab control uses `.oche-press` (scale 0.92 + brightness on `:active`). Accent actions add `.oche-press-accent`. Never ship a button without active feedback.
- **Live scores:** use `AnimatedScore` (`@number-flow/react`) for changing numerals — digit scroll with Oche easing; respects `prefers-reduced-motion`.
- **Easing:** `--ease-oche` (smooth deceleration) for UI; `--ease-spring` for score numerals. Durations: `--duration-fast` (180ms), `--duration-normal` (280ms), `--duration-slow` (420ms).
- **Tab highlights:** always use `TabStrip` with sliding `.oche-indicator` — never instant background swaps.
- **Tab / route content:** wrap changing panels in `AnimatedPanel` or `AnimatedOutlet` so content **slides horizontally** (forward = from right, back = from left).
- **Lists:** stagger entry with `.oche-stagger` on `<ul>` children (scoreboards, player rows).
- **Reduced motion:** all animations respect `prefers-reduced-motion` in `index.css`. Never bypass this.

## When adding UI
1. Prefer motion utilities in `index.css` over one-off inline styles.
2. Reuse `components/motion/` (`TabStrip`, `AnimatedPanel`, `AnimatedOutlet`) before inventing new patterns.
3. Route-level direction logic lives in `lib/motion.ts`.
4. Score changes: `.oche-score-pulse` on the row; `.score` transitions for numeral updates.

## Don't
- Add framer-motion or other animation libraries unless explicitly requested.
- Use bounce/elastic easing or long (>500ms) transitions.
- Animate layout-critical properties without `prefers-reduced-motion` fallback.
68 changes: 68 additions & 0 deletions .cursor/rules/31-page-load-motion.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
description: Page load and route entry animations — stylish, professional, never abrupt
globs: "apps/web/src/{routes,main.tsx,App.tsx,components/motion/**,components/ui/skeleton.tsx}"
---
# Page load motion

Every route and async boundary must **arrive** — not pop in. Load states should feel deliberate and venue-dashboard polished (501 interview signal).

## Non-negotiables

1. **No bare loading copy** — never ship plain `"Loading…"` / `"Loading sessions…"` as the only UI. Use animated skeletons from `components/ui/skeleton.tsx` inside `.oche-enter-fade`.
2. **Route transitions** — all primary content flows through `AnimatedOutlet` in `App.tsx`. Do not render `<Outlet />` directly without it.
3. **Direction-aware slides** — nav tab changes slide horizontally (`oche-enter-forward` / `oche-enter-back`). Same-tab depth changes (e.g. history list → detail) slide forward/back via pathname length in `AnimatedOutlet`.
4. **Lazy route chunks** — wrap lazy routes in `Suspense` with `RouteSkeleton` (or route-specific skeleton), not a spinner or text node.
5. **Page root entrance** — top-level route content gets `.oche-enter-up` or inherits from `AnimatedOutlet`; section headers and primary panels should not appear without motion.
6. **Data fetching** — `isLoading` from TanStack Query → matching skeleton component; `isRefetching` keeps content visible (no full-page flash).
7. **Lists after load** — player rows, history items, timeline events use `.oche-stagger` on first paint.
8. **Reduced motion** — `prefers-reduced-motion` in `index.css` zeroes animations; skeletons may remain static (no pulse override needed).

## Layered load sequence (use this order)

| Layer | When | Pattern |
| ----- | ---- | ------- |
| App shell | First paint | `App` root: `.oche-enter-fade` |
| Route chunk | Lazy import | `Suspense` → `RouteSkeleton` + `.oche-enter-fade` |
| Route body | Pathname change | `AnimatedOutlet` slide class from `lib/motion.ts` |
| Nested panel | Tab / session switch | `AnimatedPanel` with index from `slideEnterClass()` |
| API data | Query pending | Route-specific skeleton until `data` resolves |
| Content lists | Data arrived | `.oche-stagger` on `<ul>` |

## Adding a new route

```tsx
// main.tsx — lazy + Suspense
const MyPage = lazy(() => import('./routes/MyPage').then((m) => ({ default: m.MyPage })));

// router children
{ path: 'my-page', element: <Page><MyPage /></Page> }

// MyPage.tsx — root + query loading
export function MyPage() {
const { data, isLoading } = useQuery(...);
if (isLoading) return <MyPageSkeleton />; // add to skeleton.tsx if new layout
return <section className="oche-enter-up">...</section>;
}
```

Register a skeleton branch in `RouteSkeleton` if the layout differs from Overview/History/Detail.

## Tone

- **Stylish:** fade + subtle slide (18px), staggered lists, skeleton shapes that match final layout.
- **Professional:** 280–420ms durations, `--ease-oche` only; no spinners, bounce, or full-screen overlays.
- **Captivating but subtle:** motion supports the content; one primary entrance animation per navigation action.

## Don't

- Flash empty `<main>` while waiting for JS or data.
- Remount the entire app shell on route change (animate outlet only).
- Add page-load animation libraries (framer-motion, etc.) unless explicitly requested.
- Skip skeletons because "it's fast locally" — reviewers on cold cache must still see polish.

## Reference files

- Tokens & keyframes: `apps/web/src/index.css`
- Direction helpers: `apps/web/src/lib/motion.ts`
- Outlet / panel / tabs: `apps/web/src/components/motion/`
- General motion rules: **30-styling**
55 changes: 55 additions & 0 deletions .cursor/rules/32-game-guides.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
description: Playful game-mode instructions — keep copy and UI in sync
globs: "apps/web/src/{content,components}/**/*,apps/web/src/routes/*.tsx,docs/GAME-GUIDES.md,packages/db/src/seed.ts"
---
# Game guides (how to play)

Oche is a **venue scoreboard** for 501-style competitive socialising. Every active session should show **clear, playful** instructions for how that game is played.

## Single source of truth

All rules copy: **`apps/web/src/content/game-guides.ts`**

Human index: **[docs/GAME-GUIDES.md](../../docs/GAME-GUIDES.md)**

Do **not** duplicate long rules in components, README, or routes — import from `content/game-guides.ts`.

## When to update

Update guides when you:

- Add a new game mode or change rules copy
- Add seed sessions that showcase a mode (`packages/db/src/seed.ts` titles should match `resolveGameId()` keywords)
- Change how scores are tracked on the dashboard (update each guide’s `scoringHint`)
- Add create-session fields that pick game mode

## Required UI

| Surface | Requirement |
| ------- | ------------- |
| **Overview** (live) | `<GameGuideCard gameId={…} defaultOpen />` below session header |
| **Session detail** | `<GameGuideCard />` (collapsed default ok) |
| **Create session** | Game mode picker bound to `GameId` + live `GameGuideCard` preview |

Resolve mode: `resolveGameId(session.title)` unless an explicit `gameType` field exists on the session.

## Copy style (501-facing)

- **Playful, not silly** — one light venue joke per guide (`venueTip`); rules stay accurate.
- Structure every guide: `objective` → numbered `steps` → `scoringHint` (Oche board) → `venueTip`.
- Use plain English; explain busts, doubles, marks, lives where relevant.
- No walls of text — 4–6 steps max.

## Adding a new `GameId`

1. Extend `GameId` type and `GAME_GUIDES` record.
2. Add to `GAME_OPTIONS` (auto from record keys if id added to record).
3. Extend `resolveGameId()` with title keywords **or** wire explicit picker → persisted field.
4. Update `docs/GAME-GUIDES.md` index table.
5. Add/update a seed session title that triggers the mode in demos.

## Don’t

- Hard-code rules strings in `Overview.tsx`, `SessionDetail.tsx`, or `CreateSession.tsx`.
- Ship a new seed game title without checking `resolveGameId()` still maps correctly.
- Remove `scoringHint` — interviewers should see how software maps to venue workflow.
55 changes: 55 additions & 0 deletions .cursor/rules/33-field-tooltips.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
description: Every text/select/file field must have an accessible tooltip
globs: "apps/web/src/**/*.{ts,tsx}"
---
# Field tooltips

Every **text input**, **select**, **textarea**, and **file picker** in the SPA must expose helper text as a tooltip.

## Non-negotiables

1. **Never raw `<input>` / `<select>` / `<textarea>`** in routes or feature components — use `TextField`, `SelectField`, or `FileField` from `@/components/ui/form-fields`, or `MediaUpload` with a required `tooltip` prop.
2. **`tooltip` is required** (TypeScript-enforced on form field components) — short, plain-English guidance for venue staff.
3. **Triple exposure** (all three):
- Native `title` on the control (hover fallback)
- `aria-describedby` → tooltip element id
- Visible **?** control (`FieldTooltipIcon`) beside the label; bubble also shows when the field has focus (`.field-root:focus-within`)
4. **Copy lives in** `apps/web/src/content/field-tooltips.ts` — add new keys there; do not inline long tooltip strings in JSX.

## Components

| Component | Use for |
| --------- | ------- |
| `TextField` | `type="text"`, email, number, etc. |
| `SelectField` | `<select>` dropdowns |
| `FileField` | Visible file inputs — hidden `<input type="file">` + outline **Choose file** button (same affordance as `MediaUpload`) |
| `MediaUpload` | Hidden file input + upload UX |
| `FieldTooltipIcon` | Custom labels (only if wrapping a control manually) |

## Adding a new field

```tsx
import { TextField } from '@/components/ui/form-fields';
import { FIELD_TOOLTIPS } from '@/content/field-tooltips';

// 1. Add copy to field-tooltips.ts
// 2. Use the component:
<TextField label="Lane" tooltip={FIELD_TOOLTIPS.lane} ... />
```

## Tooltip copy style

- One or two sentences max.
- Explain **what** the field is for and **any limits** (max length, file types, MB).
- Venue-friendly tone; no jargon without a brief gloss.

## Don't

- Use placeholder text as the only help (placeholders ≠ tooltips).
- Ship a bare native file input in the UI — always use the hidden input + visible button pattern.
- Ship `title=""` or omit `aria-describedby`.
- Add `@radix-ui/react-tooltip` unless explicitly requested — use shared `form-fields` pattern.

## Docs

Human index: [docs/FIELD-TOOLTIPS.md](../../docs/FIELD-TOOLTIPS.md)
37 changes: 37 additions & 0 deletions .cursor/rules/34-auth.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
description: JWT auth, API keys, owner switcher, RLS upgrade path
globs: "apps/{api,web}/src/**/*.{ts,tsx}"
---
# Auth & multi-tenant (Tier 4)

## Request principal

Resolve order in `resolvePrincipal()`:

1. `Authorization: Bearer` JWT (or `?token=` for WebSocket)
2. `x-oche-owner` / `?key=` API key (demo, env JSON, or `api_keys` table)

Every DB call still uses `withPrincipal(db, ownerId, fn)` until Neon JWT RLS is enabled — see docs/AUTH.md.

## Secrets

- `OCHE_JWT_SECRET` — Wrangler secret per env; never in the SPA bundle.
- API keys stored **hashed** (SHA-256) in `api_keys`; plaintext returned once on create.
- Demo keys (`demo-key-a/b`) remain for RLS isolation demos.

## SPA

- Use `AuthProvider` + `useAuth()`; never hardcode `demo-key-a` in `api.ts`.
- Owner switcher invalidates TanStack Query cache on venue change.
- API key UI at `/settings/keys`.

## Adding auth to a new route

```typescript
const ownerId = await resolvePrincipal(c, getDb(c.env));
if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
```

## Neon upgrade

Document only in take-home — do not drop GUC policies without migration plan. Reference: `docs/examples/neon-jwt-rls.sql`.
31 changes: 31 additions & 0 deletions .cursor/rules/35-scale.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
description: Scale, caching, rate limits, observability
globs: "apps/api/**/*.ts"
---
# Scale & reliability (Tier 5)

## Rate limiting

- Use `RateLimiter` Durable Object (`RATE_LIMITER` binding) — one DO per IP.
- Do not reintroduce global in-memory Maps for production limits.
- Exempt `/health` and `/openapi.json` from rate limit.

## Session list cache

- Cache only `GET /sessions` via `lib/session-cache.ts` + `CACHE` KV.
- TTL 60s in KV (Cloudflare minimum); HTTP `max-age=15`; invalidate with `bumpSessionListCache()` on `POST` / `PATCH`.
- Set `X-Cache: HIT|MISS` on list responses.

## Logging

- Use `logInfo` / `logWarn` / `logError` from `lib/logger.ts` — JSON only, no raw `console.log` in routes.
- Every response gets `X-Trace-Id`; include `traceId` in 500 JSON body.

## Health

- Shallow: `GET /health` (no DB).
- Readiness: `GET /health?deep=1` — `select 1`; return 503 if DB unreachable.

## Docs

[docs/SCALE.md](../../docs/SCALE.md) · queue/transcode pipeline in [docs/MEDIA.md](../../docs/MEDIA.md)
29 changes: 29 additions & 0 deletions .cursor/rules/36-quality-dx.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Testing, contracts, visual regression, deploy DX
globs: "{apps/web,tests}/**/*.{ts,tsx}"
---
# Quality & DX (Tier 6)

## SPA component tests

- Use **Vitest + Testing Library** in `apps/web/src/**/*.test.tsx` (jsdom).
- Mock `@/lib/api` and hooks; wrap with `QueryClientProvider` (+ `MemoryRouter` when routing matters).
- Mock `@number-flow/react` in `apps/web/src/test/setup.ts`.

## Contract tests

- `tests/contracts/` — OpenAPI paths must match homework + `@oche/shared` Zod schemas stay aligned.
- Run: `npm run test:contracts`

## Visual regression

- Playwright `@visual` tests in `tests/e2e/visual.spec.ts`; snapshots under `tests/e2e/__snapshots__/`.
- Run: `npm run test:visual` · update baselines: `npx playwright test --grep @visual --update-snapshots`

## Deploy

- **Staging full pipeline:** `npm run deploy:staging:full` (migrate → force-rls → rls check → seed → deploy).

## Wrangler

- Project uses **Wrangler 4** (`apps/api`, `apps/web`). Run wrangler from `apps/api` for API/KV/DO commands.
36 changes: 36 additions & 0 deletions .cursor/rules/37-tier7-extras.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
description: PWA, i18n, scorecard export, analytics, API docs (Tier 7)
globs: "{apps/web,scripts}/**/*.{ts,tsx,mjs,html,json,svg}"
---
# Nice extras (Tier 7)

## PWA / offline read-only

- **vite-plugin-pwa** in `apps/web/vite.config.ts` — Workbox `NetworkFirst` for GET `/sessions*`.
- **React Query persistence** — `PersistQueryClientProvider` in `main.tsx`; keys `sessions` + `session` only.
- **Offline banner** — `OfflineBanner` in `App.tsx`; hide upload/mutations when offline.
- Mutations stay `networkMode: 'online'` — no offline score edits.

## Scorecard export

- `apps/web/src/lib/export-scorecard.ts` — print-ready HTML → browser Save as PDF.
- Button on `SessionDetail` header only (history detail).

## i18n

- **i18next** + single `en` locale: `apps/web/src/i18n/`.
- Nav + session detail + scorecard labels use `useTranslation()`.
- Game guides / field tooltips stay English in source files until a second locale ships.

## Analytics

- Optional `VITE_PLAUSIBLE_DOMAIN` or `VITE_CF_WEB_ANALYTICS_TOKEN` — `initAnalytics()` in `main.tsx`.
- No custom events with session IDs or PII.

## OpenAPI docs

- Source: `apps/api/src/openapi/spec.ts` → `npm run export:openapi` → `apps/web/public/openapi.json`.
- Static Redoc: `/docs/` (`apps/web/public/docs/index.html`).
- Live JSON also at `{API}/openapi.json`.

Human index: [docs/EXTRAS.md](../../docs/EXTRAS.md)
3 changes: 2 additions & 1 deletion .cursor/rules/40-api-hono.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ globs: "apps/api/**/*.ts"
- Endpoints: GET /sessions, GET /sessions/:id, POST /sessions, PATCH /sessions/:id, WS /sessions/:id/live.
- Validate bodies with Zod `.strict()`; respond `{ error, issues? }` on failure (422), never a stack trace.
- Resolve the principal once per request; wrap every DB call in `withPrincipal()`.
- CORS allowlist = env `APP_ORIGIN`. Security headers + rate limit on all routes.
- CORS allowlist = env `APP_ORIGIN`. Security headers + DO rate limit on all routes (except `/health`, `/openapi.json`).
- Structured JSON logging + `X-Trace-Id`; deep readiness at `GET /health?deep=1`. See **35-scale**.
2 changes: 1 addition & 1 deletion .cursor/rules/60-realtime-do.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
description: Durable Object realtime conventions
globs: "apps/api/src/session-room.ts"
---
- `SessionRoom` is SQLite-backed (`new_sqlite_classes`) — required for the free plan.
- `SessionRoom` and `RateLimiter` are SQLite-backed (`new_sqlite_classes`) — required for the free plan.
- Use hibernatable WebSockets (`ctx.acceptWebSocket`, `webSocketMessage`, `webSocketClose`).
- Messages are typed from `@oche/shared`. Keep frames small. Simulate mode via `storage.setAlarm`.
Loading
Loading