Index
Development Docs
This documentation is rendered as a website on GitHub Pages. Any change to markdown files will be deployed after it gets merged into the main branch.
Getting Started
This project uses pnpm (v10.16.0 or newer) as its package manager. The exact version we build with is pinned in the packageManager field of package.json.
Once you have installed node, install pnpm globally:
npm install --global pnpm
On Node 24 and older you can instead use Corepack, which reads the pinned version straight from package.json (Corepack is no longer bundled with Node 25+):
corepack enable pnpm
Then install the project's dependencies:
pnpm install
Common Commands
If you are coming from npm or yarn, most commands map over directly. Note that pnpm <script> works for any script in package.json, so pnpm run dev and pnpm dev are equivalent.
| Task | Command |
|---|---|
| Install all dependencies | pnpm install |
| Install exactly from the lockfile | pnpm install --frozen-lockfile |
| Run the dev server | pnpm dev |
| Build for production | pnpm build |
| Run the production build | pnpm start |
| Lint (eslint + stylelint + prettier) | pnpm lint |
| Auto-format | pnpm prettier |
| Type-check | pnpm tsc --noEmit |
| Open Cypress | pnpm cy:open |
| Run Cypress headlessly | pnpm cy:run |
| Build, serve and open Cypress | pnpm cy:dev |
| Build, serve and run Cypress | pnpm test:integration |
| Add a runtime dependency | pnpm add <pkg> |
| Add a dev dependency | pnpm add -D <pkg> |
| Remove a dependency | pnpm remove <pkg> |
| Update deps (respecting semver) | pnpm update |
| See why a package is installed | pnpm why <pkg> |
| List outdated packages | pnpm outdated |
| Run a one-off binary | pnpm dlx <pkg> (like npx) |
Frontend App
Backend API
The backend (auth, api, database) is all managed by a separate API outside of this repo.
Automated Testing
Frontend ↵
Frontend App Setup
The frontend uses Next to create the app.
Installation Requirements
See the Getting Started instructions for details about installing node.
Build Setup
# serve with hot reload at localhost:3000
pnpm dev
If you want to see how the app will behave in production, build and run it:
# build for production (this will take a while)
pnpm build
# runs the site in prod mode using the generated pages from the build-frontend step
pnpm start
Pages
Create a React component in the pages directory, where the name of the file is the path a user will navigate to in the url.
In general, try to keep page logic simple. Pull any complex logic out into a component in the components directory in a folder with the same name as the route.
Page transitions
Navigations animate with React's <ViewTransition>. Every page sits in a keyed sheet in PageWrapper, which fades out and in when its key changes. The key defaults to the URL path, so query changes update a page in place.
- A page that should animate on query changes sets a static
transitionKey, such asSearch.transitionKey = queryTransitionKey('q', 'variant'). - Paginated lists wrap only their results in
<PageTurn page={page}>, and their Previous/Next controls navigate withpushWithTransition(router, url, pageTurn(page, page + 1))(orpage - 1). The results turn like a book's page: going forward, their right half turns over onto the left one, with the next page's left half printed on its back; going back, the left half turns over to the right. The rest of the page stays put, and the turn starts from the top of the page, where Next would scroll anyway. Never return or awaitrouter.pushinsidestartTransition: it resolves only after the new page commits, so the transition would wait on itself forever. - The turning sheet needs half of each page while the flat halves show the other ones, but a view transition captures each element once. So while a page turns,
PageTurnkeeps a copy of its page's DOM under itself as thepage-turn-sheetsnapshot: rendering the page again would cost as much as the navigation itself, right before the turn starts. The copy follows the page being left until the navigation commits, then the new one while its effects fill it in. The view transition captures the old page as it is when React starts it, soNavigationTransitionTypescommits the copy withflushSyncright before Next renders the navigation, andPageTurnremoves it once the transition has finished. - The sheet stays flat: a snapshot only turns as one plane, and bending it would take a strip per facet, each one more print of both pages. Shading toward its lifted edge makes it look curved instead.
- A list paginated in its own state, like the lists of Find My Combos in
ComboResults, turns the same way without a navigation: it scrolls to its top, commits its sheet withflushSync(() => showTurningSheet(owner)), then sets its page in astartTransitiontagged withpageTurn. Several such lists can share a page, so each passes itsuseId()as theownerof itsPageTurn, and only the matching one prints the sheet: two snapshots with one name would cancel the whole transition. - Sorting the results again (
RESORT) crossfades them inside theirPageTurn, whether the page stays the same or goes back to the first one. /find-my-combosdoesn't fade in when it has results in its session cache: it restores them once mounted and scrolls back down to them, which would jump in the middle of the fade.- The home page's logo and the header's gear share the
site-logoname, so the logo shrinks into the gear as the search bar docks. - React only names a
<ViewTransition>whose elements are in the viewport, so a shared element that is scrolled out of view doesn't fly: the transition goes on without it.
Other transitions are tagged by their components with addTransitionType inside startTransition: Tab slides its panels across like a carousel, in the order of its tabs, and glides its underline over (TAB_FORWARD, TAB_BACK): its panel updates in place rather than being replaced, so the old and the new one share an image pair clipped to the panel, and a template card grows its wheel's card into its place in the replacement list and back (TEMPLATE_MORPH), through a <ViewTransition name share> that only one of them renders at a time. Load More slides the last row of a replacement list aside as its new cards rise in (LOAD_MORE): only the cards of the last page can move, so only they map update to an animation, which keeps the snapshots few, and the new cards rise in with @starting-style rather than as snapshots, so that the dialog's edges still clip them. Its transition starts when the page arrives, but only ever after a click on Load More. The theme switch is no React update, so ThemeSelector calls document.startViewTransition with the THEME_SWITCH type itself, and applies the theme in its update, after the old page is captured.
All motion lives in src/assets/view-transitions.css. _document gives the root an inline view-transition-name, so that file, rather than React, decides what the root snapshot does: it keeps it still. The header is named there too, through its siteHeader class, so that transitions that redraw the whole page, like the theme switch, can leave it in the root snapshot. Menus, the cookie banner and modals open and close with CSS transitions and @starting-style instead, without view transitions, and so does content that arrives on its own time, like the bracket estimate: in Firefox, a click that lands while a view transition is starting can be lost, so only an input should start one. Modal is a native <dialog> in the top layer: popups that portal to the body would sit under it, so they portal into the dialog given by ModalPortalContext when there is one.
Components
Create a React component in the components directory. If it's a component specific to a page, nest it in a directory with the same name as the page.
Ended: Frontend
Testing ↵
Integration Testing
Our integration tests use Cypress as our test runner.
What to test
Unlike unit tests, we really only want to test broad strokes behavior. The most common usages of the app, to ensure the functionality of the app remains unbroken. One spec per page or feature, and as few tests as possible in each of them.
The backend the tests run against
The tests run against a local backend with a known, tiny dataset: /combo/1-2/ is the Basalt Monolith + Mesmeric Orb combo, /combo/1-3/ is Basalt Monolith + Forsaken Monument, and nothing else exists. CI creates it from commander-spellbook-backend with .github/actions/backend, which starts the backend with docker compose, seeds those combos, and creates the account the tests log in with.
To reproduce that locally, clone the backend, run PORT=8000 docker compose up -d in it, then run the seeding and account steps of .github/actions/backend/action.yaml. pnpm build:test and pnpm start:test build and serve the app with NODE_ENV=test, which points it at http://127.0.0.1:8000 through .env.test.
Note that pnpm build only points assets at the CDN when BUILD_TYPE is set, which happens solely in the Dockerfile used for deployments. Local builds therefore serve their own assets and can be tested as-is.
How to Run the Tests
One command builds the app, serves it, waits for it to answer on http://localhost:3000 and then starts the test runner:
| Task | Command |
|---|---|
| Open the Cypress UI against a test build | pnpm cy:dev |
| Run the whole suite headlessly against a build | pnpm test:integration |
| Open the Cypress UI against the dev server | pnpm cy:debug |
If you already have the app running, you can start the runner on its own with pnpm cy:open or pnpm cy:run.
Logging in
Discord, the only login the app offers, is not reachable from CI, so cy.login() asks the backend for a token with the username and password of the test account and stores the same cookies the login page would. Everything it needs is in cypress.config.ts (apiUrl, username, password) and every value can be overridden with a CYPRESS_ prefixed environment variable, which is how the workflow passes the credentials it created the account with.
Videos/Screenshots
By default, because it takes so long to process video, we have the video and screenshot capabilities turned off. If it's useful to inspect the video of what happened after the tests run, you can append this config flag to the test command:
pnpm cy:run --config video=true,videoUploadOnPasses=true,screenshotOnRunFailure=true