project-buddy is a personal Next.js site that teaches Blender, the open-source 3D creation suite.
Before changing the code, read AGENTS.md, which links the rules for code, tests and production in coding practices and project practices, and the Decisions pages in docs/ (the design decisions and what they rejected). Remaining work is tracked in GitHub issues. The issue pipeline in the ecosystem repository works them; its docs are in that repository's docs/ folder.
Planning-skill additions to those decisions are tracked with the code in docs/Decisions.md, including account-authoritative prompt choices and the distinction from display-only extras. They require no separate wiki publication or installation.
The repo is a monorepo of npm workspaces with mise tasks (monorepo-layout):
apps/web/: the site, a Next.js app. Everything below describes it, and its paths are relative to this folder.apps/instruction-counter/: a pi-backed text-file measurement tool that emits one JSON line; see itsREADME.md.docs/: product scope, design decisions and operating notes, starting atdocs/README.md.- The root holds the shared tooling (
package.jsonwith the workspaces, the onepackage-lock.json,mise.toml,biome.jsonc) and the top-level docs (AGENTS.mdand this README).
Node is pinned to 24 in the root mise.toml. Run mise install once, then use mise shell activation or prefix commands with mise exec --. Install every workspace's dependencies once, at the root, with npm ci.
From anywhere in the repo, mise runs the site's tasks:
mise //apps/web:check # npm run check
mise //apps/web:test:server # npm run test:server
mise //apps/web:test:e2e # npm run test:e2e
mise //apps/web:build # npm run build
mise //apps/web:dev # npm run dev
mise //...:check # check every appRun the npm scripts in apps/web/, or add --workspace apps/web to run them from the root:
npm run dev # start the dev server at http://localhost:3000
npm test # run unit and browser tests once (Vitest)
npm run test:watch # run them in watch mode
npm run test:server # reset the test database and run the server tests (needs Docker)
npm run test:scripts # test workflow and lane shell scripts (fake curl/Docker, no network or database)
npm run test:e2e # run the server tests, then build, start and run end-to-end tests (Playwright)
npm run lint # lint with Biome; any warning fails the run
npm run typecheck # type-check with tsc --noEmit
npm run format # format with Biome (writes files)
npm run format:check # verify formatting without writing
npm run check # biome check (lint, format, import order; warnings fail), typecheck, test:scripts, then test (jsdom, Chromium and WebKit); stops at the first failure
npm run build # production build
npm run db:generate # generate SQL migrations in drizzle/ from src/db/tables/
npm run db:migrate # apply migrations to the database at DATABASE_URL
npm run manual:reindex # rebuild the Blender manual search index at DATABASE_URLBiome lints and formats the code; its settings are split across two files: the root biome.jsonc holds the policy for the whole repo, and apps/web/biome.jsonc extends it with the site's scoped overrides, whose globs are relative to apps/web/. npx biome check gives the same result at the root or in apps/web/. Tailwind class order is checked by the lint rule useSortedClasses, not by the formatter, so npm run format does not sort classes. To apply the sort, run npx biome lint --write --unsafe --only=nursery/useSortedClasses. The editor extension is listed in .vscode/extensions.json. Run npm run check before committing.
The app uses Neon Postgres through Drizzle ORM and the Neon serverless HTTP driver. Each feature defines its tables in src/db/tables/, which drizzle.config.ts names as the schema, and server code gets the client from getDb() in src/db/client.ts. Both the app and drizzle-kit read DATABASE_URL; see .env.example. Put it in apps/web/.env.local, which git ignores, or run vercel env pull .env.local in apps/web/ once the Vercel project is linked. .env.test, which the e2e and server tests read, lives in apps/web/ too: the orchestrator provides it with the database and site port pinned to the lane, or a developer copies .env.test.example by hand for a manual run. npm run db:generate works without DATABASE_URL; npm run db:migrate needs it.
Every pull request targets integration, not main. main is what production runs, and only scripts/deploy.sh moves it.
flowchart LR
pr[Pull request] -->|merge| integration
integration -->|integration.yml| preview[Preview build and smoke test]
integration -->|scripts/deploy.sh fast-forwards| main
main -->|deploy ticket starts release.yml| prod[Build, migrate, go live, smoke test]
- A PR merges into
integration. The workflow.github/workflows/integration.ymlbuilds the new head as a Vercel preview (vercel deploy, Preview environment variables) and runs the smoke test against it. It never migrates, because previews share the production database, so a preview of code that needs a pending migration may fail on the pages that read it. A failed preview opens or comments on an issue titledIntegration preview failed: <short sha>, labelledops. The workflow needs the repo secretVERCEL_AUTOMATION_BYPASS_SECRETto pass Vercel's Deployment Protection, as well as the release's Vercel secret and variables. scripts/deploy.sh [--dry-run] [--yes] [<commit>]releases a tested commit. It takes the head ofintegrationunless you name another commit on it, checks that its Integration preview passed and thatmainfast-forwards to it, lists what will ship, fast-forwardsmain(never a force-push), and opens a deploy ticket. A fix pushed straight tomainmust be merged back intointegrationfirst, or the script stops.- The deploy ticket starts the release described below.
Production is released, and migrated, by the GitHub Actions workflow .github/workflows/release.yml, never automatically. To release, run scripts/deploy.sh, or open a deploy ticket by hand: an issue labelled deploy (or add the label to an open issue). The workflow releases the head of main, comments the result on the ticket and closes it when the release passes its smoke test. Only someone with write access can start one. It builds on Vercel without going live (vercel deploy --prod --skip-domain), applies the pending migrations with drizzle-kit migrate while the previous build is still live, makes the new build live through Vercel's REST API (.github/scripts/vercel-promote.sh), and smoke-tests https://project-buddy-rust.vercel.app with apps/web/scripts/release/smoke.ts. Vercel's own Git deployments are off for every branch, previews included (apps/web/vercel.json), and no build migrates. The workflow needs the repo secrets VERCEL_TOKEN and PRODUCTION_DATABASE_URL and the repo variables VERCEL_ORG_ID and VERCEL_PROJECT_ID.
You can also run the Release workflow by hand (workflow_dispatch) from the Actions tab. When a step fails, the workflow opens an issue titled Production release failed: <short sha>, labelled ops, or comments on the one already open, and comments on the deploy ticket, which stays open. It names the failed step, the commit, the deployment and the run. If the build or the migration failed, production is still on the previous build; if going live or the smoke test failed, the new build may be live and is not working. A Vercel rollback (vercel rollback, or the dashboard) restores the previous code but never the schema.
A project has many chats, and a user can also have standalone chats in no project. Each chat has one buddy, and at most one project, both fixed when it starts. The signed-in /chat hub lists all owned chats and can filter them to an owned project; conversations, including standalone chats, open at /chats/[chatId]. See Chats for filtering, privacy and standalone-chat behavior. The tables live in src/db/tables/projects-and-chats.ts. src/app/api/chat/route.ts hands each new message to replyToChat in src/tutor/reply.ts, which streams Claude's reply through the Vercel AI SDK and saves both messages. The model is chosen once in src/tutor/model.ts, with the API key that src/env.ts reads from ANTHROPIC_API_KEY, and src/tutor/system-prompt.ts builds the system prompt, including the persona prompt. Set TUTOR_MODEL=mock to use a deterministic mock model instead of Claude, as the tests do; see .env.example.
src/errors/app-error.ts sorts every failure of a call to Anthropic or Neon into an AppErrorCase, such as out-of-credits or database-unreachable. The stores, the rate limits and searchManual run their queries through withAppErrors. The chat route passes model errors, including those that arrive mid-stream, to toAppError. toAppError logs the original error with its case, status, upstream message and request id, and this log line is what appears in the Vercel logs as [app-error] <case> in <operation>. The browser receives only the case and shows that case's fixed message, never the upstream text.
Only admins can invite new accounts from /invitations, linked from their Account page. Migration 0013 provisions or promotes me@modestprogress.com as the initial admin without creating a session; the owner signs in normally to prove ownership. Other accounts keep their data and default to the ordinary user role. The admin role grants invitation administration only.
An invitation email opens /setup, showing the fixed normalized recipient address. Opening it is read-only; one confirmation atomically creates an ordinary account, consumes the invitation and signs that browser in. Invitations expire after 30 minutes, work on any device, and are single-use even under concurrent confirmation. Successfully sending a replacement invalidates the old link; failed delivery leaves the old link usable. Existing addresses get normal login links instead. /signup explains invitations and can't accept the old shared code.
Existing users sign in by emailed link (src/accounts/login-links.ts). Normal login links remain reusable for 30 minutes, and only confirming the page sets an HTTP-only session cookie signed with SESSION_SECRET, valid for 30 days. src/proxy.ts admits /login, /signup, /login/link, and /setup without a session; other pages redirect to /login, and /api/* routes answer 401. Page and action authorization also checks the stored session epoch; invitation administration checks the stored role.
src/accounts/rate-limits.ts holds the limits, counted in the rate_limits table under the HMAC of each address, never the address itself. One connection (its first x-forwarded-for address) may ask for 3 links to one address in 15 minutes, and 20 links in an hour in all; one address gets at most 10 links in 15 minutes from every connection together. The page's answer is the same within or past a limit. Set SESSION_SECRET, RESEND_API_KEY and EMAIL_FROM_ADDRESS (the bare sender address, on a domain verified in Resend; email shows it under Hippy's name) in .env.local and in the Vercel project; see .env.example. Invitations and sign-in need DATABASE_URL too. Sign-up doesn't read the old INVITE_CODE setting.
The tutor searches the Blender Reference Manual through the manual_sections table. npm run manual:reindex makes a shallow git clone of the manual's latest stable release branch into a temporary directory, splits every RST file under manual/ into one section per heading, and replaces the table's contents in one transaction. It reads DATABASE_URL from the environment or .env.local, and the table must already exist (npm run db:migrate). Each section links to https://docs.blender.org/manual/en/latest/<path>.html#<anchor>, with the anchor Sphinx gives the heading. searchManual() in src/manual/search-manual.ts queries the index.
When a new stable Blender release ships, set MANUAL_BRANCH in src/manual/manual-source.ts to its release branch, such as blender-v5.4-release, and run npm run manual:reindex.
The script runs under Node's built-in TypeScript support, so modules it imports use relative imports with a .ts extension instead of the @/ alias.
Unit tests live in test/, in the folder their subject has under src/, as *.test.tsx and run with Vitest under jsdom. Browser tests, test/**/*.browser.test.tsx, run with Vitest in headless Chromium and WebKit through Playwright; npm test runs both. End-to-end tests live in e2e/ as *.spec.ts and run with Playwright. Each runner ignores the other's directory. End-to-end tests cover only visual regressions and the auth workflows (e2e-visual-and-auth-only), which apps/web/use-cases/use-cases.test.ts enforces against the auth cases in apps/web/use-cases/auth-cases.ts. Every other use case is tested in Vitest: by a server test against the test database if it needs a real server or database, and otherwise by a component or browser test. Server tests are test/**/*.server.test.ts, run with Vitest in Node by npm run test:server, which needs Docker. It resets and migrates the local database configured in the supplied .env.test first and keeps the reusable database/proxy afterwards; npm run test:e2e runs them before Playwright, and npm test never does. They make their users, data and sessions with the kit in test/test-database/, described in apps/web/AGENTS.md.
Before the first npm run check or e2e run, install the browsers once with npx playwright install chromium webkit. npm run test:e2e builds the site, checks the built font URLs and starts it on the configured E2E_PORT; it never reuses a server already running there. npm run check does not run e2e tests, to stay fast.
The orchestrator provides apps/web/.env.test, with DATABASE_URL and E2E_PORT pinned to its assigned lane. apps/web/scripts/test-env/setup.sh validates ISSUE_PIPELINE_LANE_SLOT=K (1–399), starts the shared Docker test server, provisions/reuses pb-lane-K-neon-proxy on 127.0.0.1:(24000+K) and database pb_lane_K, and atomically publishes the file from the committed example with site port 20000+10K. Existing lane data is not reset by setup. Without a slot, setup touches no container, database or .env.test.
Test commands only consume the supplied file. A missing .env.test is an error: no automatic copying, allocation or fallback to another database/port. Lane resources are reusable and are not torn down on a test command's exit. Separate active lanes have separate databases, proxies and ports; the orchestrator must not assign the same lane to independent concurrent runs. Build artifacts in one checkout are not isolated.
For a manual run, copy apps/web/.env.test.example to apps/web/.env.test by hand and configure a local test database and an unused site port. From apps/web, run npm run test-db:up to start the local server, then npm run test-db:reset to create/migrate that configured database. Never share a manual database or port between simultaneous runs. Safety guards require localhost/127.0.0.1, safe database names (including pb_lane_K, never the server's own databases), the mock tutor and file email outbox.
npm run test:serverresets/migrates the supplied database, then runs Vitest server tests.npm run test:playwright -- <file>runs one e2e file without server tests, using that database and site port. For example:npm run test:playwright -- e2e/sign-in.spec.ts. Its database must already be migrated.npm run test:e2erunstest:serverfollowed bytest:playwrightonly on success, both with the same configured environment.npm run test:visualruns server tests followed by only the visual Playwright projects;test:visual:updateupdates their baselines.npm run test:scriptsincludes deterministic lane tests usingfake-docker.shon PATH; no real Docker or database is used.
Explicit maintenance scripts/test-env/prune.sh requires ISSUE_PIPELINE_LANE_SLOTS=N (nonnegative whole count; zero prunes all identifiable lanes). It inventories the shared server before any removal and refuses uncertain inspection. Only canonical lane proxies/databases above N may be removed; retained issue resources and other namespaces are untouched. Names, shared container/network and port formulas live in lanes.sh. Do not run shared-server test-db:down while another checkout needs it.
Keep one full web suite scheduled at a time until this change is accepted and operator setup is separately made compatible. Merging repository scripts does not configure or install the pipeline's laneSlots, setup/prune/teardown wiring, nor authorize removal of retained resources. Operator setup remains separate. See Project practices. Fix failures before rerunning; a flake is a failure.
The visual Playwright projects, in e2e/visual/, screenshot every page and compare each shot with its baseline in the spec's <spec>.spec.ts-snapshots/ folder:
visual(Desktop Chrome): every theme, under Light and Dark, at 1280 and 390 px wide, named<page>-<theme>-<mode>-<width>-darwin.png.visual-iphone-15(WebKit) andvisual-pixel-7(Chromium): the default theme, Gravy Falls, under Light and Dark, on each phone as Playwright emulates it (screen, pixel ratio, touch and user agent), named<page>-gravy-falls-<mode>-<device>-darwin.png. The devices are listed ine2e/visual/shot-devices.ts. Install WebKit once withnpx playwright install webkit.
npm run test:e2e runs them beside the other projects, in parallel, since each test has its own account and no visual test writes anything another test reads; npm run test:visual runs only them. A failure writes the expected, actual and diff images to test-results/.
Each spec says how to reach its page with fixed data and which parts change between runs, such as dates; e2e/visual/visual-page.ts takes the shots:
- Signed in by cookie: a signed-in page's shots start with a session cookie made in the test rather than through the login form, and without
Secure, because WebKit dropsSecurecookies onhttp://localhost. The login form is tested in thechromiumproject. - Settled: a shot waits until nothing on the page is
aria-busy="true", such as a loading skeleton, and until the fonts have loaded. Thene2e/visual/fit-screen.tsmakes the screen as tall as the page, reading the page's height only once it holds for two frames and trying again until two fits agree. Code blocks keep a stand-in height until they are first on screen. - Whole-tile rasterization: Chromium visual projects disable partial rasterization. Clipped repaints can otherwise change rounded-corner pixels without a layout change, even in a stable screenshot. WebKit and auth tests keep their normal settings. See the #506 diagnosis and retained baseline images.
- Masked: each changing part is covered by a box, and must be found on the page first, so a change to its text fails the test instead of leaving a date in the shot. Specs find those parts, and every element, by role and by text from the copy modules in
src/ui/copy/, never by literal text.
Keeping the baselines:
- A change meant to change how the site looks: run
npm run test:visual:update, which resets/migrates the supplied test database and rewrites only the baselines that differ, look at the changed images, and commit them in the same commit as the change. A new page or state gets a spec ine2e/visual/and its baselines the same way. A new page also gets an entry inPHONE_PAGESintest/app/phone-pages-test-kit.tsx, from whichtest/app/phone-pages.touch.browser.test.tsxchecks every page at 320 and 390 px wide, with touch, in every theme, for sideways scrolling and touch targets smaller than 24 px (npm test). - Baselines come from Chromium and WebKit on macOS (the
-darwinsuffix), because fonts render differently on each platform. A run on Linux, such as in CI, would need its own baselines, made in Playwright's Docker image. - When two branches change the same baseline, don't merge the images: merge the code, run
npm run test:visual:updateon the merged code, and commit the new baselines.