From 5bc70c5857ab02ffbdba5d5e069833015f180869 Mon Sep 17 00:00:00 2001 From: Matthew Polak Date: Sat, 18 Jul 2026 11:33:48 -0700 Subject: [PATCH 01/16] docs: specify website redesign Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012 --- ...-18-minimal-leaderboard-redesign-design.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-minimal-leaderboard-redesign-design.md diff --git a/docs/superpowers/specs/2026-07-18-minimal-leaderboard-redesign-design.md b/docs/superpowers/specs/2026-07-18-minimal-leaderboard-redesign-design.md new file mode 100644 index 0000000..bc1916e --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-minimal-leaderboard-redesign-design.md @@ -0,0 +1,316 @@ +# Straif Website Redesign + +## Summary + +Redesign the existing Vue website as a two-route, minimalist black game site: + +- `/` presents the game through the official trailer, a concise introduction, selected map imagery, and a small leaderboard preview. +- `/leaderboard` exposes every existing leaderboard discipline through a focused, filterable, paginated table. + +The approved direction is cinematic editorial rather than overtly technical or game-themed. It uses a deep-black interface, restrained map color, large but factual typography, and quiet data presentation. Copy remains descriptive and avoids slogans. + +## Goals + +- Keep the frontend in Vue 3 and Vite. +- Add a real homepage without weakening leaderboard access. +- Expose Bhop, Target, Aim scenarios, and all available overall rankings. +- Make every leaderboard entry reachable with 25-row numbered pagination. +- Improve semantic HTML, keyboard usability, responsive behavior, and failure states. +- Reuse the supplied trailer and existing repository map screenshots. +- Preserve API compatibility for existing clients. + +## Non-goals + +- Player profiles, authentication, run replay, search, and leaderboard submission. +- A content-management system. +- New game modes, scoring rules, or map metadata. +- Browser end-to-end test infrastructure. +- Decorative animation beyond restrained transitions that respect reduced-motion preferences. + +## Approved design direction + +### Visual system + +- Near-black page and surface colors with fine neutral borders. +- Off-white primary text and muted gray supporting text. +- Existing screenshots retain reduced, restrained color rather than becoming fully monochrome. +- Archivo is the primary interface and display typeface. +- IBM Plex Mono is limited to compact labels, ranks, dates, times, and pagination metadata. +- No gradients as decorative backgrounds, glow effects, heavy shadows, rounded dashboard cards, or gaming-style ornament. +- Ranking numbers remain visible because they are data, not decorative section markers. + +### Navigation + +A shared semantic app shell contains: + +- A `header` with the Straif wordmark. +- A primary `nav` linking to Home and Leaderboard. +- A route-level `main` region. +- A restrained `footer` with repeated primary navigation and existing project links only when they have a verified destination. + +The active route is visually and programmatically identifiable. + +## Route design + +### Home route + +The homepage uses this order: + +1. Shared header and navigation. +2. Trailer hero. +3. Concise game introduction. +4. Alternating map features. +5. Current leaderboard preview. +6. Footer. + +#### Trailer hero + +- The trailer is the opening visual and uses video ID `CfzotZZ3Sd0`. +- Initial rendering uses a lightweight thumbnail with an accessible play control. +- Activating the control replaces the thumbnail with a privacy-enhanced `youtube-nocookie.com` iframe. +- The iframe has a descriptive title and supports keyboard activation. +- The hero copy is limited to the game name and factual trailer metadata. + +#### Game introduction + +The introduction describes Straif as a fast-paced 3D platforming shooter with hand-crafted maps, precise movement, and global leaderboards. It does not introduce marketing slogans or unsupported claims. + +#### Map gallery + +- Use an alternating image-and-copy layout rather than a card grid or contact sheet. +- Curate a small set of strong screenshots, including Streets and Taurus, from `images/screenshots`. +- Copy identifies the map and the leaderboard disciplines available for it. +- Images use meaningful alt text when informative and empty alt text only when repeated decorative media is already described by adjacent text. +- On narrow screens, each feature becomes a vertical image-then-copy sequence. + +#### Leaderboard preview + +- Show a compact set of current Bhop Rooftops records. +- Use the same table primitives and formatting as the full leaderboard. +- Include a clear link to the matching filtered leaderboard route. +- Preview failure does not prevent the rest of the homepage from rendering. + +### Leaderboard route + +The route starts with one `h1`, a short factual description, category tabs, compatible filters, a status region, the result table, and pagination. + +#### Categories + +The category tabs map to the current APIs as follows: + +| Category | Data source | Secondary control | +| --- | --- | --- | +| Movement | Bhop map runs | Map | +| Target | Target map runs | Map | +| Aim | Aim scenario scores | Scenario: Gridshot, Flick, Tracking | +| Overall | Overall rankings | Discipline: Movement, Target, Aim | + +Category labels are user-facing terms; API mode values remain `bhop` and `target`. + +#### URL state + +The route query string is the source of truth for: + +- `category` +- `map` +- `scenario` +- `discipline` +- `page` + +Invalid or incompatible values are normalized to a valid default and reflected in the URL. Changing a category or filter resets the page to 1. Browser back and forward navigation restores the visible board. + +Examples: + +- `/leaderboard?category=movement&map=map_rooftops&page=1` +- `/leaderboard?category=aim&scenario=gridshot&page=2` +- `/leaderboard?category=overall&discipline=aim&page=1` + +#### Table presentation + +- Render a real `table` with a descriptive `caption`, `thead`, `tbody`, and scoped column headers. +- Movement and Target columns: rank, player, time, date. +- Aim scenario columns: rank, player, score, accuracy, reaction time, date. +- Overall Movement and Target columns: rank, player, points. +- Overall Aim columns: rank, player, total score, scenarios completed, accuracy, average reaction time. +- Format times consistently to three decimal places in seconds. +- Format scores, percentages, reaction time, and dates consistently through shared formatters. +- Preserve the table structure on mobile and place it in a keyboard-accessible horizontal overflow region instead of converting rows into cards. + +#### Pagination + +- The website requests 25 rows per page. +- Numbered pagination provides previous, next, nearby page numbers, current-page state, total entries, and the displayed range. +- Pagination is contained in an aria-labeled `nav`. +- Page changes retain the selected category and filters. +- The route scrolls or focuses back to the leaderboard heading after a page change without forcing motion when reduced motion is enabled. + +## Frontend architecture + +Keep presentation, route state, and transport responsibilities separate. + +### App shell + +`App.vue` owns the shared site shell and renders a `RouterView`. It does not contain leaderboard fetching or route-specific content. + +### Views + +- `HomeView` composes trailer, introduction, map gallery, and leaderboard preview components. +- `LeaderboardView` coordinates route-backed filters and renders the selected board. + +### Components + +Use focused components with explicit props and emitted events: + +- `SiteHeader` +- `SiteFooter` +- `TrailerHero` +- `GameIntro` +- `MapGallery` +- `LeaderboardPreview` +- `LeaderboardCategoryTabs` +- `LeaderboardFilters` +- `LeaderboardTable` +- `LeaderboardPagination` +- `LeaderboardStatus` + +The table remains one reusable component driven by explicit column definitions and normalized rows rather than separate duplicated tables for every discipline. + +### Data and helpers + +- A dedicated API module owns endpoint construction, fetch error handling, and response parsing. +- A leaderboard composable maps route state to requests, cancels stale requests, and exposes loading, success, empty, and error states. +- A normalizer maps the current API response variants to a stable frontend shape. +- Shared formatting helpers handle times, scores, percentages, reaction times, and dates. +- Static map and scenario metadata lives outside components. + +No fetch calls or API URL construction remain inside templates or presentational components. + +## API changes + +The website needs full paginated access while existing game and API clients must retain current behavior. + +### Pagination query + +Applicable leaderboard endpoints accept: + +- `page`: zero-based non-negative integer, default `0`. +- `limit`: positive integer capped at `100`, default `10`. + +The website sends `limit=25`. Existing callers that omit `limit` continue receiving 10 entries. + +### Map and scenario boards + +Add the validated `limit` parameter to: + +- `GET /leaderboard/mode/:mode_name/maps/:map_name/runs` +- `GET /leaderboard/aim/scenarios/:scenario/scores` + +Both responses retain their existing data fields and totals. + +### Overall Movement and Target + +`GET /leaderboard/mode/:mode_name/overall` accepts `page` and `limit`. + +- Compute the complete points-ranked player list using the existing scoring rules. +- Slice the ordered list by page and limit. +- Keep the existing `data` array for compatibility. +- Add a top-level `total` count. + +### Overall Aim + +`GET /leaderboard/aim/overall` accepts `page` and `limit`. + +- Paginate the ordered aggregate ranking. +- Keep `data.scores`. +- Add `data.total`. + +### Validation and errors + +- Invalid `page` or `limit` values return the route's established 400 error shape. +- Unknown aim scenarios continue returning 400. +- Limits above 100 are rejected rather than silently expanded. +- Database and internal failures continue returning explicit 500 responses. + +## Loading, empty, and error behavior + +- Filter changes immediately identify the table region as busy while preserving the surrounding route structure. +- A loading state appears inside the result region and does not fabricate skeleton rows that resemble results. +- Empty responses show a specific no-records message for the selected category and filter. +- API failures show an inline error with a retry button. +- Retry preserves current URL state. +- Stale requests are aborted when filters or pages change. +- The homepage preview handles its loading, empty, and error states independently from the rest of the page. + +## Semantics and accessibility + +- Set the document language to English and provide route-specific document titles and descriptions. +- Use semantic landmarks and meaningful heading order. +- Use native links, buttons, and selects rather than clickable generic elements. +- Label every filter visibly. +- Expose active tabs and pagination state programmatically. +- Announce asynchronous status changes through an appropriately scoped `aria-live` region. +- Provide strong focus-visible styles with sufficient contrast. +- Meet WCAG AA contrast for text, controls, and focus indicators. +- Respect `prefers-reduced-motion`. +- Avoid autoplaying video or audio. +- Ensure touch targets remain comfortably usable on mobile. + +## Responsive behavior + +- Home sections use a wide editorial layout on desktop and a single reading column on small screens. +- Alternating map features preserve source order so mobile reading order remains image, heading, and description. +- Leaderboard tabs may scroll horizontally when necessary. +- Filters stack vertically on narrow screens. +- Tables retain semantic columns inside a labeled horizontal overflow container. +- Pagination remains reachable without requiring the user to scroll the table horizontally first. +- Typography uses bounded fluid sizing to prevent oversized headings on laptops and phones. + +## Dependencies + +Add only the dependencies needed for the approved architecture and tests: + +- Runtime: `vue-router`. +- Development: `vitest`, `@vue/test-utils`, and `jsdom`. + +Use hosted Archivo and IBM Plex Mono font files through a deliberate font-loading strategy with local fallbacks. No component framework, icon library, animation library, or CSS framework is required. + +## Testing strategy + +### Frontend + +Use Vitest, Vue Test Utils, and jsdom to cover: + +- Home and Leaderboard route rendering. +- Semantic landmarks, headings, labels, captions, and scoped headers. +- Trailer activation and privacy-enhanced embed creation. +- Category and filter transitions. +- URL normalization and browser navigation behavior. +- Page reset rules and 25-row request parameters. +- Response normalization for every leaderboard category. +- Loading, empty, error, retry, and stale-request behavior. +- Pagination labels and disabled states. + +Avoid brittle pixel-level snapshot tests. + +### Server + +Extend the existing leaderboard tests to cover: + +- Default 10-row compatibility. +- Explicit 25-row requests. +- Page offset behavior. +- Total counts. +- Overall Movement, Target, and Aim pagination. +- Invalid and over-limit pagination inputs. +- Existing score ordering and tie-breaking. + +### Verification + +Run the targeted frontend and server tests, then the existing production builds for both packages. Review the rendered pages at desktop and mobile widths, including keyboard navigation and API failure states. + +## Accepted trade-offs + +- Numbered pagination is less continuous than an infinite list but provides stable URLs, predictable performance, and better keyboard and return navigation. +- Keeping tables intact on mobile requires horizontal overflow, but it preserves comparison semantics better than converting each row into a card. +- Click-to-load YouTube requires one extra activation but improves initial load, privacy, and page stability. +- Curating a small map set means the homepage does not display every map; the leaderboard remains the complete index. From 29eb464098d2ddcb164c6d797228f62cb4e53570 Mon Sep 17 00:00:00 2001 From: Matthew Polak Date: Sat, 18 Jul 2026 11:46:22 -0700 Subject: [PATCH 02/16] docs: plan website redesign Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012 --- ...2026-07-18-minimal-leaderboard-redesign.md | 2763 +++++++++++++++++ 1 file changed, 2763 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-minimal-leaderboard-redesign.md diff --git a/docs/superpowers/plans/2026-07-18-minimal-leaderboard-redesign.md b/docs/superpowers/plans/2026-07-18-minimal-leaderboard-redesign.md new file mode 100644 index 0000000..2d7d462 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-minimal-leaderboard-redesign.md @@ -0,0 +1,2763 @@ +# Minimal Leaderboard Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a minimalist black two-route Straif website with a trailer-led homepage and complete paginated Movement, Target, Aim, and Overall leaderboards. + +**Architecture:** Preserve Vue 3 and Vite, add Vue Router, and separate the shared app shell, route views, leaderboard transport, route-backed state, and presentational components. Extend the Hono leaderboard API with a shared validated pagination contract so the website can request 25 rows while existing callers retain the 10-row default. + +**Tech Stack:** Vue 3, Vue Router 4, Vite 7, Vitest, Vue Test Utils, jsdom, Hono, Zod, Drizzle ORM, TypeScript, pnpm. + +--- + +## File structure + +### Server + +- Create `server/src/leaderboard_pagination.ts` for the shared page/limit schema, OpenAPI parameters, offsets, and in-memory pagination. +- Create `server/src/leaderboard_pagination.test.ts` for default, validation, offset, ordering, and total-count behavior. +- Modify `server/src/routes/leaderboard.ts` to paginate map and overall Movement/Target boards. +- Modify `server/src/routes/aim_leaderboard.ts` to paginate scenario and overall Aim boards. +- Modify `server/package.json` to expose the existing Node test files through `pnpm test`. + +### Website foundation + +- Modify `website/package.json`, `website/pnpm-lock.yaml`, and `website/vite.config.js` for Vue Router and Vitest. +- Modify `website/index.html` for language and baseline metadata. +- Modify `website/src/main.js` to install the router and global styles. +- Replace `website/src/App.vue` with the shared app shell. +- Create `website/src/router/index.js` for lazy route definitions, memory-history test injection, and route metadata. +- Create `website/src/components/layout/SiteHeader.vue` and `website/src/components/layout/SiteFooter.vue`. +- Create `website/src/styles/base.css` for tokens, reset, typography, focus, layout, table, and responsive behavior. + +### Website leaderboard domain + +- Create `website/src/data/leaderboards.js` for category, map, scenario, discipline, default, and column metadata. +- Create `website/src/utils/formatters.js` for times, scores, percentages, reaction times, dates, and integer formatting. +- Create `website/src/services/leaderboardApi.js` for request construction, response validation, and normalized rows. +- Create `website/src/composables/useLeaderboard.js` for abortable loading, empty, success, and error states. +- Create `website/src/components/leaderboard/LeaderboardCategoryTabs.vue`. +- Create `website/src/components/leaderboard/LeaderboardFilters.vue`. +- Create `website/src/components/leaderboard/LeaderboardStatus.vue`. +- Create `website/src/components/leaderboard/LeaderboardTable.vue`. +- Create `website/src/components/leaderboard/LeaderboardPagination.vue`. +- Create `website/src/components/leaderboard/LeaderboardPreview.vue`. +- Create `website/src/views/LeaderboardView.vue`. + +### Website homepage + +- Create `website/src/components/home/TrailerHero.vue`. +- Create `website/src/components/home/GameIntro.vue`. +- Create `website/src/components/home/MapGallery.vue`. +- Create `website/src/views/HomeView.vue`. +- Copy selected screenshots to `website/src/assets/maps/`. + +### Tests + +- Create `website/src/router/index.test.js`. +- Create `website/src/data/leaderboards.test.js`. +- Create `website/src/utils/formatters.test.js`. +- Create `website/src/services/leaderboardApi.test.js`. +- Create `website/src/composables/useLeaderboard.test.js`. +- Create `website/src/components/home/TrailerHero.test.js`. +- Create `website/src/components/leaderboard/LeaderboardTable.test.js`. +- Create `website/src/components/leaderboard/LeaderboardPagination.test.js`. +- Create `website/src/views/HomeView.test.js`. +- Create `website/src/views/LeaderboardView.test.js`. + +## Task 1: Add the shared server pagination contract + +**Files:** +- Create: `server/src/leaderboard_pagination.test.ts` +- Create: `server/src/leaderboard_pagination.ts` +- Modify: `server/package.json` + +- [ ] **Step 1: Write the failing pagination tests** + +Create `server/src/leaderboard_pagination.test.ts`: + +```ts +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + LeaderboardPaginationQuery, + get_leaderboard_offset, + paginate_leaderboard, +} from './leaderboard_pagination'; + +test('leaderboard pagination preserves the compatibility defaults', () => { + assert.deepEqual(LeaderboardPaginationQuery.parse({}), { + page: 0, + limit: 10, + }); +}); + +test('leaderboard pagination coerces valid query strings', () => { + assert.deepEqual( + LeaderboardPaginationQuery.parse({ page: '2', limit: '25' }), + { page: 2, limit: 25 } + ); +}); + +test('leaderboard pagination rejects invalid and excessive values', () => { + assert.equal( + LeaderboardPaginationQuery.safeParse({ page: '-1', limit: '25' }).success, + false + ); + assert.equal( + LeaderboardPaginationQuery.safeParse({ page: '0', limit: '0' }).success, + false + ); + assert.equal( + LeaderboardPaginationQuery.safeParse({ page: '0', limit: '101' }).success, + false + ); +}); + +test('get_leaderboard_offset uses zero-based pages', () => { + assert.equal(get_leaderboard_offset({ page: 3, limit: 25 }), 75); +}); + +test('paginate_leaderboard preserves order and reports the unsliced total', () => { + const result = paginate_leaderboard( + ['first', 'second', 'third', 'fourth', 'fifth'], + { page: 1, limit: 2 } + ); + + assert.deepEqual(result, { + rows: ['third', 'fourth'], + total: 5, + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify the missing-module failure** + +Run: + +```bash +cd server +pnpm exec tsx --test src/leaderboard_pagination.test.ts +``` + +Expected: FAIL because `./leaderboard_pagination` does not exist. + +- [ ] **Step 3: Implement the pagination module** + +Create `server/src/leaderboard_pagination.ts`: + +```ts +import type { DescribeRouteOptions } from 'hono-openapi'; +import { z } from 'zod'; + +type OpenApiParameter = Exclude< + NonNullable[number], + { $ref: string } +>; + +export const LEADERBOARD_DEFAULT_LIMIT = 10; +export const LEADERBOARD_MAX_LIMIT = 100; + +export const LeaderboardPaginationQuery = z.object({ + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce + .number() + .int() + .min(1) + .max(LEADERBOARD_MAX_LIMIT) + .default(LEADERBOARD_DEFAULT_LIMIT), +}); + +export type LeaderboardPagination = z.infer< + typeof LeaderboardPaginationQuery +>; + +export const LeaderboardPaginationParameters = [ + { + name: 'page', + in: 'query', + required: false, + schema: { + type: 'integer', + minimum: 0, + default: 0, + }, + description: 'Zero-based leaderboard page number.', + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { + type: 'integer', + minimum: 1, + maximum: LEADERBOARD_MAX_LIMIT, + default: LEADERBOARD_DEFAULT_LIMIT, + }, + description: 'Rows per page.', + }, +] satisfies OpenApiParameter[]; + +export function get_leaderboard_offset({ + page, + limit, +}: LeaderboardPagination) { + return page * limit; +} + +export function paginate_leaderboard( + entries: readonly T[], + pagination: LeaderboardPagination +) { + const offset = get_leaderboard_offset(pagination); + return { + rows: entries.slice(offset, offset + pagination.limit), + total: entries.length, + }; +} +``` + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +cd server +pnpm exec tsx --test src/leaderboard_pagination.test.ts +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Add and verify the server test script** + +Add this script to `server/package.json`: + +```json +"test": "tsx --test src/*.test.ts" +``` + +Run: + +```bash +cd server +pnpm test +``` + +Expected: all pagination, aim leaderboard, and world-record tests pass. + +- [ ] **Step 6: Commit the pagination contract** + +```bash +git add server/package.json server/src/leaderboard_pagination.ts server/src/leaderboard_pagination.test.ts +git commit -m "feat: add leaderboard pagination contract" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 2: Paginate map and aim-scenario endpoints + +**Files:** +- Modify: `server/src/routes/leaderboard.ts:33-63,227-289` +- Modify: `server/src/routes/aim_leaderboard.ts:46-71,178-210,379-443` + +- [ ] **Step 1: Extend the pagination test with the website page size** + +Add to `server/src/leaderboard_pagination.test.ts`: + +```ts +test('leaderboard pagination accepts the website page size', () => { + const parsed = LeaderboardPaginationQuery.parse({ + page: '1', + limit: '25', + }); + + assert.equal(parsed.limit, 25); + assert.equal(get_leaderboard_offset(parsed), 25); +}); +``` + +- [ ] **Step 2: Run the pagination test** + +Run: + +```bash +cd server +pnpm exec tsx --test src/leaderboard_pagination.test.ts +``` + +Expected: 6 tests pass and establish the route contract before route wiring. + +- [ ] **Step 3: Extend the general leaderboard route descriptor** + +In `server/src/routes/leaderboard.ts`, import `DescribeRouteOptions` and the shared pagination exports: + +```ts +import { + describeRoute, + resolver, + validator as zValidator, + type DescribeRouteOptions, +} from 'hono-openapi'; +import { + get_leaderboard_offset, + LeaderboardPaginationParameters, + LeaderboardPaginationQuery, + paginate_leaderboard, +} from '../leaderboard_pagination'; +``` + +Change `describe_leaderboard_route` to accept optional route metadata: + +```ts +function describe_leaderboard_route( + description: string, + success_schema: T, + options: Omit = {} +) { + return describeRoute({ + description, + tags: ['leaderboard'], + ...options, + responses: { + 200: { + description: 'Successful', + content: { + 'application/json': { + schema: resolver(success_schema), + }, + }, + }, + 400: { + description: 'Error', + content: { + 'application/json': { + schema: resolver(z.object({ error: z.string() })), + }, + }, + }, + }, + }); +} +``` + +- [ ] **Step 4: Wire page and limit into map runs** + +Replace manual `page_string` parsing in the map-runs handler with: + +```ts +describe_leaderboard_route( + 'Retrieves a paginated leaderboard of runs for the specified map.', + MapRunsResponse, + { parameters: LeaderboardPaginationParameters } +), +zValidator('query', LeaderboardPaginationQuery), +async (c) => { + const map_name = c.req.param('map_name'); + const run_mode = coerce_to_run_mode(c.req.param('mode_name')); + const pagination = c.req.valid('query'); + + try { + const runs_result = await db + .select({ + time_ms: runs.time_ms, + steam_id: runs.steam_id, + username: runs.username, + created_at: runs.created_at, + }) + .from(runs) + .where(and(eq(runs.map_name, map_name), eq(runs.mode, run_mode))) + .orderBy(asc(runs.time_ms)) + .limit(pagination.limit) + .offset(get_leaderboard_offset(pagination)); + + const formatted_result = runs_result.map((run) => ({ + ...run, + created_at: format_date(run.created_at), + })); + + return c.json({ + data: { + runs: formatted_result, + total: await get_run_count(run_mode, map_name), + }, + }); + } catch (e) { + console.log(e); + return c.json({ error: 'Internal server error' }, 500); + } +} +``` + +- [ ] **Step 5: Replace the aim route's local page schema** + +In `server/src/routes/aim_leaderboard.ts`, remove `PaginationQuery` and `PageQueryParameter`, then import: + +```ts +import { + get_leaderboard_offset, + LeaderboardPaginationParameters, + LeaderboardPaginationQuery, +} from '../leaderboard_pagination'; +``` + +Update the scenario endpoint metadata and query validation: + +```ts +parameters: [ScenarioPathParameter, ...LeaderboardPaginationParameters] +``` + +```ts +zValidator('query', LeaderboardPaginationQuery) +``` + +Use: + +```ts +const pagination = c.req.valid('query'); +``` + +and replace the fixed query pagination with: + +```ts +.limit(pagination.limit) +.offset(get_leaderboard_offset(pagination)) +``` + +Set each returned position with: + +```ts +formatAimScoreRow( + score, + get_leaderboard_offset(pagination) + index + 1 +) +``` + +- [ ] **Step 6: Run server tests and build** + +Run: + +```bash +cd server +pnpm test +pnpm build +``` + +Expected: all tests pass and TypeScript/tsup complete without errors. + +- [ ] **Step 7: Commit map and scenario pagination** + +```bash +git add server/src/routes/leaderboard.ts server/src/routes/aim_leaderboard.ts server/src/leaderboard_pagination.test.ts +git commit -m "feat: paginate leaderboard routes" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 3: Paginate every overall leaderboard + +**Files:** +- Modify: `server/src/leaderboard_pagination.test.ts` +- Modify: `server/src/routes/leaderboard.ts:76-131` +- Modify: `server/src/routes/aim_leaderboard.ts:148-161,163-176,445-483` + +- [ ] **Step 1: Add the overall slicing regression test** + +Add: + +```ts +test('paginate_leaderboard can expose every overall entry across pages', () => { + const entries = Array.from({ length: 53 }, (_, index) => ({ + rank: index + 1, + })); + + const first = paginate_leaderboard(entries, { page: 0, limit: 25 }); + const last = paginate_leaderboard(entries, { page: 2, limit: 25 }); + + assert.equal(first.rows.length, 25); + assert.equal(last.rows.length, 3); + assert.equal(last.rows[0].rank, 51); + assert.equal(last.total, 53); +}); +``` + +- [ ] **Step 2: Run the focused test** + +Run: + +```bash +cd server +pnpm exec tsx --test src/leaderboard_pagination.test.ts +``` + +Expected: all pagination tests pass. + +- [ ] **Step 3: Paginate Movement and Target overall results** + +Replace the overall response schema with: + +```ts +const OverallLeaderboardResponse = z.object({ + data: z.array(PlayerPoints), + total: z.number().int().min(0), +}); +``` + +Add pagination metadata and validation to `/mode/:mode_name/overall`, retrieve: + +```ts +const pagination = c.req.valid('query'); +``` + +After constructing and sorting the complete `points_map`, paginate it: + +```ts +const sorted_leaderboard = Array.from(points_map.values()).sort( + (a, b) => b.points - a.points || a.steam_id.localeCompare(b.steam_id) +); +const page = paginate_leaderboard(sorted_leaderboard, pagination); + +return c.json({ + data: page.rows, + total: page.total, +}); +``` + +The route declaration must include: + +```ts +describe_leaderboard_route( + 'Fetches the overall leaderboard for a specific mode.', + OverallLeaderboardResponse, + { parameters: LeaderboardPaginationParameters } +), +zValidator('query', LeaderboardPaginationQuery) +``` + +- [ ] **Step 4: Paginate overall Aim in SQL** + +Update the response: + +```ts +const AimOverallLeaderboardResponse = z.object({ + data: z.object({ + scores: z.array(AimOverallScore), + total: z.number().int().min(0), + }), +}); +``` + +Add: + +```ts +const DistinctPlayerCount = + sql`count(distinct ${aim_scores.steam_id})`.mapWith(Number); +``` + +Add pagination metadata and validation to `/overall`, then replace its handler body with: + +```ts +const pagination = c.req.valid('query'); + +try { + const [scores, totals] = await Promise.all([ + db + .select({ + steam_id: aim_scores.steam_id, + username: DeterministicUsername, + total_score: TotalScore, + scenarios_completed: ScenariosCompleted, + accuracy: AccuracyAverage, + avg_reaction_ms: AvgReaction, + }) + .from(aim_scores) + .groupBy(aim_scores.steam_id) + .orderBy( + desc(TotalScoreExpression), + desc(ScenariosCompletedExpression), + desc(AccuracyAverageExpression), + asc(AvgReactionExpression), + asc(aim_scores.steam_id) + ) + .limit(pagination.limit) + .offset(get_leaderboard_offset(pagination)), + db.select({ count: DistinctPlayerCount }).from(aim_scores), + ]); + + return c.json({ + data: { + scores, + total: totals[0].count, + }, + }); +} catch (e) { + console.log(e); + return c.json({ error: 'Internal server error' }, 500); +} +``` + +- [ ] **Step 5: Run complete server verification** + +Run: + +```bash +cd server +pnpm test +pnpm build +``` + +Expected: all tests pass and the production server bundle succeeds. + +- [ ] **Step 6: Commit complete API pagination** + +```bash +git add server/src/leaderboard_pagination.test.ts server/src/routes/leaderboard.ts server/src/routes/aim_leaderboard.ts +git commit -m "feat: expose complete overall leaderboards" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 4: Establish the tested Vue router and semantic shell + +**Files:** +- Modify: `website/package.json` +- Modify: `website/pnpm-lock.yaml` +- Modify: `website/vite.config.js` +- Modify: `website/src/main.js` +- Replace: `website/src/App.vue` +- Create: `website/src/router/index.js` +- Create: `website/src/router/index.test.js` +- Create: `website/src/components/layout/SiteHeader.vue` +- Create: `website/src/components/layout/SiteFooter.vue` +- Create: `website/src/views/HomeView.vue` +- Create: `website/src/views/LeaderboardView.vue` + +- [ ] **Step 1: Install only the approved dependencies** + +Run: + +```bash +cd website +pnpm add vue-router@^4 +pnpm add -D vitest @vue/test-utils jsdom +``` + +Expected: `package.json` and `pnpm-lock.yaml` update without unrelated dependency upgrades. + +- [ ] **Step 2: Add the test script and environment** + +Add to `website/package.json`: + +```json +"test": "vitest run" +``` + +Add to `defineConfig` in `website/vite.config.js`: + +```js +test: { + environment: 'jsdom', + clearMocks: true, +} +``` + +- [ ] **Step 3: Write the failing router test** + +Create `website/src/router/index.test.js`: + +```js +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from 'vue-router' +import { createStraifRouter } from './index' + +describe('Straif router', () => { + it('defines home and leaderboard routes with document metadata', async () => { + const router = createStraifRouter(createMemoryHistory()) + + await router.push('/leaderboard') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('leaderboard') + expect(router.currentRoute.value.meta.title).toBe('Leaderboard — Straif') + expect(router.getRoutes().map((route) => route.path)).toEqual( + expect.arrayContaining(['/', '/leaderboard']) + ) + }) +}) +``` + +- [ ] **Step 4: Run the test and verify it fails** + +Run: + +```bash +cd website +pnpm test -- src/router/index.test.js +``` + +Expected: FAIL because `createStraifRouter` does not exist. + +- [ ] **Step 5: Create the router** + +Create `website/src/router/index.js`: + +```js +import { + createRouter, + createWebHistory, +} from 'vue-router' + +const routes = [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + meta: { + title: 'Straif', + description: + 'Straif is a fast-paced 3D platforming shooter built around movement and global leaderboards.', + }, + }, + { + path: '/leaderboard', + name: 'leaderboard', + component: () => import('@/views/LeaderboardView.vue'), + meta: { + title: 'Leaderboard — Straif', + description: + 'Browse Straif movement, target, aim, and overall leaderboards.', + }, + }, +] + +export function createStraifRouter(history = createWebHistory()) { + const router = createRouter({ history, routes }) + + router.afterEach((to) => { + document.title = to.meta.title + document + .querySelector('meta[name="description"]') + ?.setAttribute('content', to.meta.description) + }) + + return router +} +``` + +- [ ] **Step 6: Create the semantic shell** + +Create `SiteHeader.vue`: + +```vue + +``` + +Create `SiteFooter.vue`: + +```vue + +``` + +Replace `App.vue`: + +```vue + + + +``` + +Use minimal temporary route views: + +```vue + + +``` + +```vue + + +``` + +Update `main.js`: + +```js +import { createApp } from 'vue' +import App from './App.vue' +import { createStraifRouter } from './router' +import './styles/base.css' + +createApp(App).use(createStraifRouter()).mount('#app') +``` + +Create an initial `styles/base.css` containing the reset and focus contract: + +```css +:root { + color-scheme: dark; + font-family: Archivo, Arial, sans-serif; + background: #050505; + color: #f2f2ed; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; background: #050505; } +a { color: inherit; } +button, select { font: inherit; } +:focus-visible { outline: 2px solid #f2f2ed; outline-offset: 4px; } +.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; } +.skip-link:focus { top: 1rem; } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition-duration: 0.01ms !important; } +} +``` + +- [ ] **Step 7: Run router test and build** + +Run: + +```bash +cd website +pnpm test -- src/router/index.test.js +pnpm build +``` + +Expected: router test passes and Vite builds both lazy routes. + +- [ ] **Step 8: Commit the Vue foundation** + +```bash +git add website +git commit -m "feat: add website routes and semantic shell" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 5: Define leaderboard metadata and formatters + +**Files:** +- Create: `website/src/data/leaderboards.test.js` +- Create: `website/src/data/leaderboards.js` +- Create: `website/src/utils/formatters.test.js` +- Create: `website/src/utils/formatters.js` + +- [ ] **Step 1: Write the failing metadata and formatter tests** + +Create `website/src/data/leaderboards.test.js`: + +```js +import { describe, expect, it } from 'vitest' +import { + getCategoryMaps, + normalizeLeaderboardQuery, +} from './leaderboards' + +describe('leaderboard metadata', () => { + it('excludes bhop-only maps from Target', () => { + expect(getCategoryMaps('target').map((map) => map.value)).not.toContain( + 'map_taurus' + ) + expect(getCategoryMaps('movement').map((map) => map.value)).toContain( + 'map_taurus' + ) + }) + + it('normalizes incompatible route values', () => { + expect( + normalizeLeaderboardQuery({ + category: 'aim', + scenario: 'unknown', + page: '-4', + }) + ).toEqual({ + category: 'aim', + scenario: 'gridshot', + page: 1, + }) + }) +}) +``` + +Create `website/src/utils/formatters.test.js`: + +```js +import { describe, expect, it } from 'vitest' +import { + formatDate, + formatInteger, + formatPercentage, + formatReaction, + formatTime, +} from './formatters' + +describe('leaderboard formatters', () => { + it('formats leaderboard metrics consistently', () => { + expect(formatTime(18_442)).toBe('18.442s') + expect(formatInteger(84220)).toBe('84,220') + expect(formatPercentage(92.456)).toBe('92.46%') + expect(formatReaction(243.6)).toBe('244ms') + expect(formatDate('2026-07-18T11:00:00.000Z')).toBe('2026.07.18') + }) +}) +``` + +- [ ] **Step 2: Run tests and verify missing-module failures** + +Run: + +```bash +cd website +pnpm test -- src/data/leaderboards.test.js src/utils/formatters.test.js +``` + +Expected: FAIL because both implementation modules are missing. + +- [ ] **Step 3: Implement metadata** + +Create `website/src/data/leaderboards.js` with: + +```js +export const PAGE_SIZE = 25 + +export const CATEGORIES = [ + { value: 'movement', label: 'Movement' }, + { value: 'target', label: 'Target' }, + { value: 'aim', label: 'Aim' }, + { value: 'overall', label: 'Overall' }, +] + +export const MAPS = [ + ['Tutorial', 'Tutorial', ['movement', 'target']], + ['map_rooftops', 'Rooftops', ['movement', 'target']], + ['map_streets', 'Streets', ['movement', 'target']], + ['map_flow', 'Flow', ['movement', 'target']], + ['map_line', 'Line', ['movement']], + ['map_rookie', 'Rookie', ['movement']], + ['map_dawn', 'Dawn', ['movement']], + ['map_structure', 'Structure', ['movement', 'target']], + ['map_graybox', 'Graybox', ['movement', 'target']], + ['map_graybox2', 'Graybox 2', ['movement', 'target']], + ['map_subway', 'Subway', ['movement', 'target']], + ['map_rooftops2', 'Rooftops 2', ['movement', 'target']], + ['map_slope', 'Slope', ['movement']], + ['map_taurus', 'Taurus', ['movement']], +].map(([value, label, categories]) => ({ value, label, categories })) + +export const AIM_SCENARIOS = [ + { value: 'gridshot', label: 'Gridshot' }, + { value: 'flick', label: 'Flick' }, + { value: 'tracking', label: 'Tracking' }, +] + +export const OVERALL_DISCIPLINES = [ + { value: 'movement', label: 'Movement' }, + { value: 'target', label: 'Target' }, + { value: 'aim', label: 'Aim' }, +] + +export function getCategoryMaps(category) { + return MAPS.filter((map) => map.categories.includes(category)) +} + +export function normalizeLeaderboardQuery(query = {}) { + const category = CATEGORIES.some((entry) => entry.value === query.category) + ? query.category + : 'movement' + const page = Math.max(Number.parseInt(query.page, 10) || 1, 1) + + if (category === 'aim') { + const scenario = AIM_SCENARIOS.some( + (entry) => entry.value === query.scenario + ) + ? query.scenario + : 'gridshot' + return { category, scenario, page } + } + + if (category === 'overall') { + const discipline = OVERALL_DISCIPLINES.some( + (entry) => entry.value === query.discipline + ) + ? query.discipline + : 'movement' + return { category, discipline, page } + } + + const maps = getCategoryMaps(category) + const map = maps.some((entry) => entry.value === query.map) + ? query.map + : 'map_rooftops' + return { category, map, page } +} +``` + +- [ ] **Step 4: Implement formatters** + +Create `website/src/utils/formatters.js`: + +```js +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) + +export const formatInteger = (value) => integerFormatter.format(value) +export const formatTime = (value) => `${(value / 1000).toFixed(3)}s` +export const formatPercentage = (value) => `${Number(value).toFixed(2)}%` +export const formatReaction = (value) => `${Math.round(value)}ms` +export const formatDate = (value) => + new Date(value).toISOString().slice(0, 10).replaceAll('-', '.') +``` + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +cd website +pnpm test -- src/data/leaderboards.test.js src/utils/formatters.test.js +``` + +Expected: all metadata and formatter tests pass. + +- [ ] **Step 6: Commit domain metadata** + +```bash +git add website/src/data website/src/utils +git commit -m "feat: define leaderboard metadata" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 6: Build and test the normalized leaderboard API client + +**Files:** +- Create: `website/src/services/leaderboardApi.test.js` +- Create: `website/src/services/leaderboardApi.js` + +- [ ] **Step 1: Write failing request and normalization tests** + +Create tests covering these exact cases: + +```js +import { describe, expect, it, vi } from 'vitest' +import { fetchLeaderboard } from './leaderboardApi' + +function response(body, ok = true) { + return { ok, json: vi.fn().mockResolvedValue(body) } +} + +describe('fetchLeaderboard', () => { + it('requests a 25-row Movement page and normalizes ranks', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + data: { + runs: [{ + steam_id: '1', + username: 'Alice', + time_ms: 18442, + created_at: '2026-07-18', + }], + total: 26, + }, + }) + ) + + const result = await fetchLeaderboard( + { category: 'movement', map: 'map_rooftops', page: 2 }, + { fetchImpl } + ) + + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining( + '/leaderboard/mode/bhop/maps/map_rooftops/runs?page=1&limit=25' + ), + expect.objectContaining({ signal: undefined }) + ) + expect(result.rows[0]).toMatchObject({ + id: '1', + rank: 26, + username: 'Alice', + time_ms: 18442, + }) + expect(result.total).toBe(26) + }) + + it('normalizes overall Aim scores', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + data: { + scores: [{ + steam_id: '2', + username: 'Bob', + total_score: 84220, + scenarios_completed: 3, + accuracy: 92.4, + avg_reaction_ms: 240, + }], + total: 1, + }, + }) + ) + + const result = await fetchLeaderboard( + { category: 'overall', discipline: 'aim', page: 1 }, + { fetchImpl } + ) + + expect(result.rows[0].total_score).toBe(84220) + expect(result.total).toBe(1) + }) + + it('throws the API message for failed requests', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ error: 'Leaderboard unavailable.' }, false)) + + await expect( + fetchLeaderboard( + { category: 'aim', scenario: 'gridshot', page: 1 }, + { fetchImpl } + ) + ).rejects.toThrow('Leaderboard unavailable.') + }) +}) +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: + +```bash +cd website +pnpm test -- src/services/leaderboardApi.test.js +``` + +Expected: FAIL because `fetchLeaderboard` does not exist. + +- [ ] **Step 3: Implement endpoint selection and normalization** + +Create `website/src/services/leaderboardApi.js`: + +```js +import { PAGE_SIZE } from '@/data/leaderboards' + +const BASE_URL = + import.meta.env.VITE_API_BASE_URL ?? 'https://straifapi.pumped.software' + +function endpointFor(query) { + const params = new URLSearchParams({ + page: String(query.page - 1), + limit: String(PAGE_SIZE), + }) + + if (query.category === 'aim') { + return `/leaderboard/aim/scenarios/${query.scenario}/scores?${params}` + } + + if (query.category === 'overall') { + if (query.discipline === 'aim') { + return `/leaderboard/aim/overall?${params}` + } + const mode = query.discipline === 'target' ? 'target' : 'bhop' + return `/leaderboard/mode/${mode}/overall?${params}` + } + + const mode = query.category === 'target' ? 'target' : 'bhop' + return `/leaderboard/mode/${mode}/maps/${query.map}/runs?${params}` +} + +function normalizeRows(query, body) { + if (query.category === 'aim') return body.data.scores + if (query.category === 'overall' && query.discipline === 'aim') { + return body.data.scores + } + if (query.category === 'overall') return body.data + return body.data.runs +} + +function normalizeTotal(query, body) { + if (query.category === 'aim') return body.data.total + if (query.category === 'overall' && query.discipline === 'aim') { + return body.data.total + } + if (query.category === 'overall') return body.total + return body.data.total +} + +export async function fetchLeaderboard( + query, + { signal, fetchImpl = fetch } = {} +) { + const response = await fetchImpl(`${BASE_URL}${endpointFor(query)}`, { + signal, + }) + const body = await response.json() + + if (!response.ok) { + throw new Error(body.error ?? 'Could not load the leaderboard.') + } + + const offset = (query.page - 1) * PAGE_SIZE + return { + rows: normalizeRows(query, body).map((row, index) => ({ + ...row, + id: row.steam_id, + rank: row.position ?? offset + index + 1, + })), + total: normalizeTotal(query, body), + } +} +``` + +- [ ] **Step 4: Run the focused API tests** + +Run: + +```bash +cd website +pnpm test -- src/services/leaderboardApi.test.js +``` + +Expected: all API client tests pass. + +- [ ] **Step 5: Commit the API client** + +```bash +git add website/src/services +git commit -m "feat: add leaderboard API client" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 7: Add abortable leaderboard state + +**Files:** +- Create: `website/src/composables/useLeaderboard.test.js` +- Create: `website/src/composables/useLeaderboard.js` + +- [ ] **Step 1: Write failing state-transition tests** + +Create tests that mount the composable in an effect scope: + +```js +import { computed, effectScope, nextTick, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { useLeaderboard } from './useLeaderboard' + +describe('useLeaderboard', () => { + it('loads rows and exposes success state', async () => { + const request = ref({ category: 'movement', map: 'map_rooftops', page: 1 }) + const loader = vi.fn().mockResolvedValue({ rows: [{ id: '1' }], total: 1 }) + const scope = effectScope() + const state = scope.run(() => + useLeaderboard(computed(() => request.value), loader) + ) + + await vi.waitFor(() => expect(state.status.value).toBe('success')) + expect(state.rows.value).toEqual([{ id: '1' }]) + scope.stop() + }) + + it('aborts the stale request when filters change', async () => { + const request = ref({ category: 'movement', map: 'map_rooftops', page: 1 }) + const signals = [] + const loader = vi.fn((_query, { signal }) => { + signals.push(signal) + return new Promise(() => {}) + }) + const scope = effectScope() + scope.run(() => useLeaderboard(computed(() => request.value), loader)) + + request.value = { category: 'movement', map: 'map_streets', page: 1 } + await nextTick() + + expect(signals[0].aborted).toBe(true) + scope.stop() + }) +}) +``` + +- [ ] **Step 2: Run the test and verify failure** + +Run: + +```bash +cd website +pnpm test -- src/composables/useLeaderboard.test.js +``` + +Expected: FAIL because the composable is missing. + +- [ ] **Step 3: Implement the composable** + +Create `website/src/composables/useLeaderboard.js`: + +```js +import { onScopeDispose, ref, watch } from 'vue' +import { fetchLeaderboard } from '@/services/leaderboardApi' + +export function useLeaderboard(request, loader = fetchLeaderboard) { + const rows = ref([]) + const total = ref(0) + const status = ref('loading') + const error = ref('') + let controller + + async function load() { + controller?.abort() + controller = new AbortController() + status.value = 'loading' + error.value = '' + + try { + const result = await loader(request.value, { + signal: controller.signal, + }) + rows.value = result.rows + total.value = result.total + status.value = result.rows.length === 0 ? 'empty' : 'success' + } catch (cause) { + if (cause?.name === 'AbortError') return + rows.value = [] + total.value = 0 + error.value = + cause instanceof Error ? cause.message : 'Could not load the leaderboard.' + status.value = 'error' + } + } + + watch(request, load, { deep: true, immediate: true }) + onScopeDispose(() => controller?.abort()) + + return { rows, total, status, error, reload: load } +} +``` + +- [ ] **Step 4: Run composable tests** + +Run: + +```bash +cd website +pnpm test -- src/composables/useLeaderboard.test.js +``` + +Expected: all composable tests pass. + +- [ ] **Step 5: Commit leaderboard state** + +```bash +git add website/src/composables +git commit -m "feat: add abortable leaderboard state" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 8: Build the semantic leaderboard primitives + +**Files:** +- Create: `website/src/components/leaderboard/LeaderboardTable.test.js` +- Create: `website/src/components/leaderboard/LeaderboardPagination.test.js` +- Create: `website/src/components/leaderboard/LeaderboardCategoryTabs.vue` +- Create: `website/src/components/leaderboard/LeaderboardFilters.vue` +- Create: `website/src/components/leaderboard/LeaderboardStatus.vue` +- Create: `website/src/components/leaderboard/LeaderboardTable.vue` +- Create: `website/src/components/leaderboard/LeaderboardPagination.vue` + +- [ ] **Step 1: Write the failing semantic table test** + +Create: + +```js +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import LeaderboardTable from './LeaderboardTable.vue' + +describe('LeaderboardTable', () => { + it('renders a caption, scoped headers, and formatted cells', () => { + const wrapper = mount(LeaderboardTable, { + props: { + caption: 'Rooftops Bhop leaderboard', + columns: [ + { key: 'rank', label: 'Rank', format: (row) => row.rank }, + { key: 'username', label: 'Player', format: (row) => row.username }, + { key: 'time', label: 'Time', format: (row) => `${row.time_ms}ms` }, + ], + rows: [{ id: '1', rank: 1, username: 'Alice', time_ms: 18442 }], + }, + }) + + expect(wrapper.get('caption').text()).toBe('Rooftops Bhop leaderboard') + expect(wrapper.findAll('th[scope="col"]')).toHaveLength(3) + expect(wrapper.get('tbody th[scope="row"]').text()).toBe('1') + expect(wrapper.text()).toContain('Alice') + }) +}) +``` + +- [ ] **Step 2: Write the failing pagination test** + +Create: + +```js +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import LeaderboardPagination from './LeaderboardPagination.vue' + +describe('LeaderboardPagination', () => { + it('labels navigation and emits the selected page', async () => { + const wrapper = mount(LeaderboardPagination, { + props: { page: 2, total: 76, pageSize: 25 }, + }) + + expect(wrapper.get('nav').attributes('aria-label')).toBe( + 'Leaderboard pages' + ) + expect(wrapper.text()).toContain('26–50 of 76') + await wrapper.get('button[aria-label="Go to page 3"]').trigger('click') + expect(wrapper.emitted('update:page')).toEqual([[3]]) + }) +}) +``` + +- [ ] **Step 3: Run tests and verify missing-component failures** + +Run: + +```bash +cd website +pnpm test -- src/components/leaderboard/LeaderboardTable.test.js src/components/leaderboard/LeaderboardPagination.test.js +``` + +Expected: FAIL because both components are missing. + +- [ ] **Step 4: Implement the generic table** + +Create `LeaderboardTable.vue`: + +```vue + + + +``` + +- [ ] **Step 5: Implement numbered pagination** + +Create `LeaderboardPagination.vue` with computed total pages, displayed range, and a bounded five-page window: + +```vue + + + +``` + +- [ ] **Step 6: Implement tabs, filters, and status** + +Create `LeaderboardCategoryTabs.vue`: + +```vue + + + +``` + +Create `LeaderboardFilters.vue` so only the compatible native select is present: + +```vue + + + +``` + +Create `LeaderboardStatus.vue`: + +```vue + + + +``` + +- [ ] **Step 7: Run component tests** + +Run: + +```bash +cd website +pnpm test -- src/components/leaderboard +``` + +Expected: table and pagination tests pass. + +- [ ] **Step 8: Commit leaderboard primitives** + +```bash +git add website/src/components/leaderboard +git commit -m "feat: add semantic leaderboard components" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 9: Assemble the complete route-backed leaderboard + +**Files:** +- Create: `website/src/views/LeaderboardView.test.js` +- Replace: `website/src/views/LeaderboardView.vue` +- Modify: `website/src/data/leaderboards.js` + +- [ ] **Step 1: Add column factories to leaderboard metadata** + +Import formatters and export `getLeaderboardColumns(query)` with these exact column sets: + +```js +const rank = { key: 'rank', label: 'Rank', format: (row) => row.rank } +const player = { + key: 'username', + label: 'Player', + format: (row) => row.username, +} + +export function getLeaderboardColumns(query) { + if (query.category === 'aim') { + return [ + rank, + player, + { key: 'score', label: 'Score', format: (row) => formatInteger(row.score) }, + { key: 'accuracy', label: 'Accuracy', format: (row) => formatPercentage(row.accuracy) }, + { key: 'reaction', label: 'Reaction', format: (row) => formatReaction(row.avg_reaction_ms) }, + { key: 'date', label: 'Date', format: (row) => formatDate(row.created_at) }, + ] + } + + if (query.category === 'overall' && query.discipline === 'aim') { + return [ + rank, + player, + { key: 'total', label: 'Total score', format: (row) => formatInteger(row.total_score) }, + { key: 'scenarios', label: 'Scenarios', format: (row) => row.scenarios_completed }, + { key: 'accuracy', label: 'Accuracy', format: (row) => formatPercentage(row.accuracy) }, + { key: 'reaction', label: 'Reaction', format: (row) => formatReaction(row.avg_reaction_ms) }, + ] + } + + if (query.category === 'overall') { + return [ + rank, + player, + { key: 'points', label: 'Points', format: (row) => formatInteger(row.points) }, + ] + } + + return [ + rank, + player, + { key: 'time', label: 'Time', format: (row) => formatTime(row.time_ms) }, + { key: 'date', label: 'Date', format: (row) => formatDate(row.created_at) }, + ] +} +``` + +- [ ] **Step 2: Write the failing view test** + +Create a test that mocks `fetchLeaderboard`, mounts with a memory router, and verifies URL normalization and page resets: + +```js +it('normalizes filters and resets the page when the category changes', async () => { + const router = createStraifRouter(createMemoryHistory()) + await router.push('/leaderboard?category=movement&map=map_taurus&page=3') + await router.isReady() + const wrapper = mount(LeaderboardView, { + global: { plugins: [router] }, + }) + + await vi.waitFor(() => expect(wrapper.text()).toContain('Leaderboard')) + await wrapper.get('button[data-category="target"]').trigger('click') + + expect(router.currentRoute.value.query).toMatchObject({ + category: 'target', + map: 'map_rooftops', + page: '1', + }) +}) +``` + +- [ ] **Step 3: Run the test and verify it fails** + +Run: + +```bash +cd website +pnpm test -- src/views/LeaderboardView.test.js +``` + +Expected: FAIL because the temporary view has no route-backed controls. + +- [ ] **Step 4: Implement `LeaderboardView.vue`** + +Replace `LeaderboardView.vue` with: + +```vue + + + +``` + +- [ ] **Step 5: Run view and domain tests** + +Run: + +```bash +cd website +pnpm test -- src/views/LeaderboardView.test.js src/data/leaderboards.test.js src/services/leaderboardApi.test.js +``` + +Expected: all route normalization, metadata, and API tests pass. + +- [ ] **Step 6: Commit the full leaderboard route** + +```bash +git add website/src/views/LeaderboardView.vue website/src/views/LeaderboardView.test.js website/src/data/leaderboards.js +git commit -m "feat: build full leaderboard route" \ + -m "Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: da0c9d87-f2bf-4cb3-b656-7f17cd105012" +``` + +## Task 10: Build the trailer-led homepage + +**Files:** +- Copy: `images/screenshots/map_streets.png` to `website/src/assets/maps/map_streets.png` +- Copy: `images/screenshots/map_taurus.png` to `website/src/assets/maps/map_taurus.png` +- Copy: `images/screenshots/map_rooftops.png` to `website/src/assets/maps/map_rooftops.png` +- Create: `website/src/components/home/TrailerHero.test.js` +- Create: `website/src/components/home/TrailerHero.vue` +- Create: `website/src/components/home/GameIntro.vue` +- Create: `website/src/components/home/MapGallery.vue` +- Create: `website/src/components/leaderboard/LeaderboardPreview.vue` +- Create: `website/src/views/HomeView.test.js` +- Replace: `website/src/views/HomeView.vue` + +- [ ] **Step 1: Copy the approved map assets** + +Run: + +```bash +mkdir -p website/src/assets/maps +cp images/screenshots/map_streets.png website/src/assets/maps/ +cp images/screenshots/map_taurus.png website/src/assets/maps/ +cp images/screenshots/map_rooftops.png website/src/assets/maps/ +``` + +Expected: three tracked images exist under the website source tree. + +- [ ] **Step 2: Write the failing trailer test** + +Create: + +```js +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import TrailerHero from './TrailerHero.vue' + +describe('TrailerHero', () => { + it('loads the privacy-enhanced embed only after activation', async () => { + const wrapper = mount(TrailerHero) + + expect(wrapper.find('iframe').exists()).toBe(false) + await wrapper.get('button[aria-label="Play the Straif trailer"]').trigger('click') + + const iframe = wrapper.get('iframe') + expect(iframe.attributes('src')).toContain( + 'https://www.youtube-nocookie.com/embed/CfzotZZ3Sd0' + ) + expect(iframe.attributes('title')).toBe('Straif official trailer') + }) +}) +``` + +- [ ] **Step 3: Run the trailer test and verify failure** + +Run: + +```bash +cd website +pnpm test -- src/components/home/TrailerHero.test.js +``` + +Expected: FAIL because `TrailerHero.vue` is missing. + +- [ ] **Step 4: Implement the click-to-load trailer** + +Create: + +```vue + + +