You MUST read the development guide before starting. If you cannot read it, please ask for help.
We do development using docker-compose. Run docker compose ps to see if the dev server is running. If it is, then you can restart the dev server with touch tmp/restart.txt (but do not do this unless you added/removed a gem). If not, bring the containers up first with docker compose up -d.
IMPORTANT: Always use docker compose exec (not run) to execute commands in the existing container. run creates a new container each time; exec reuses the running one.
- Tests:
docker compose exec web rails test(all),docker compose exec web rails test test/models/user_test.rb(single file),docker compose exec web rails test test/models/user_test.rb -n test_method_name(single test) - Note: Limited test coverage - Lint:
docker compose exec web bundle exec rubocop(check),docker compose exec web bundle exec rubocop -A(auto-fix) - Console:
docker compose exec web rails c(interactive console) - Server:
docker compose exec web rails s -b 0.0.0.0(development server) - Database:
docker compose exec web rails db:migrate,docker compose exec web rails db:create,docker compose exec web rails db:schema:load,docker compose exec web rails db:seed - Security:
docker compose exec web bundle exec brakeman(security audit) - JS Security:
docker compose exec web bin/importmap audit(JS dependency scan) - Zeitwerk:
docker compose exec web bin/rails zeitwerk:check(autoloader check) - Swagger:
docker compose exec web bin/rails rswag:specs:swaggerize(generate API docs)
Before marking any task complete, you MUST check config/ci.rb and manually run the checks in that file which are relevant to your changes (with docker compose exec.)
Skip running checks which aren't relevant to your changes. However, at the very end of feature development, recommend the user to run all checks. If they say yes, run docker compose exec web bin/ci to run them all.
- Specs: All new API endpoints MUST include Rswag specs in
spec/requests/api/.... - Generation: After changing specs, run
bundle exec rake rswag:specs:swaggerizeto updateswagger/v1/swagger.yaml. - Validation: CI will fail if
swagger.yamlis out of sync with the specs (meaning you forgot to run the generation command).
- Start containers:
docker compose up -d(must be running before usingexec) - Interactive shell:
docker compose exec web /bin/bash - Initial setup:
docker compose exec web bin/rails db:create db:schema:load db:seed - Cleanup: Run commands with the
--remove-orphansflag to remove unused containers and images
- NEVER commit
config/database.ymlunless explicitly asked to - contains sensitive local/production database credentials - NEVER use
git add .- always add files individually to avoid accidentally committing unwanted files - Use
git add <specific-file>orgit add <directory>/for targeted commits
- Naming: snake_case files/methods/vars, PascalCase classes, 2-space indent
- Controllers: Inherit
ApplicationController, usebefore_action, strong params with.permit() - Models: Inherit
ApplicationRecord, extensive use of concerns/enums/scopes - Error Handling:
rescue => e+Rails.logger.error, graceful degradation in jobs - Imports: Use
includefor concerns,helper_methodfor view access - API: Namespace under
api/v1/, structured JSON responses with status codes - Jobs: GoodJob with 4 priority queues, inherit from
ApplicationJob, concurrency control for cache jobs - Auth:
ensure_authenticated!for APIs, token viaAuthorizationheader or?api_key= - CSS: Using Tailwind CSS, no inline styles, use utility classes. We define some custom classes in
config/tailwind.config.jsandapp/assets/tailwind/application.css.
On Inertia pages, use the <Button /> component for buttons, not <button> tags.
When linking to an Inertia page, use the <Link /> component instead of <a> tags.
Don't mirror Inertia props into local $state with a $effect that just copies them back for read-only display values. Props are already reactive — bind to user.foo directly (or pass it as value={user.foo}) instead of introducing a redundant let foo = $state(user.foo) + $effect(() => { foo = user.foo }). Only introduce local $state when you actually need state that diverges from the prop.
Exception — editable form state for Inertia forms. When the user edits the value locally (bind:value, bind:group, bind:checked), you legitimately need local $state, and you need a $effect to re-sync from props after a server validation error. On 422, Rails re-renders the same component with updated application/form props; if you only initialize once (let foo = $state(application.bar) or let foo = $state(untrack(() => application.bar))), the form fields will not reflect server-normalized values on re-render. Keep the $effect here — this is the legitimate use case, not the anti-pattern.
If svelte-check warns state_referenced_locally on a $state(prop) initializer that you genuinely want to read once (e.g. a tab-default chosen from a prop that never changes after mount), wrap the initializer in untrack(() => ...) from svelte to silence it. Do not use untrack to silence the warning on editable form fields — that hides a real bug; restore the $effect instead.
For computed values derived from props (const x = prop === "a" ? ... : ...), use $derived(...) instead of a bare const — otherwise state_referenced_locally will fire and the value won't update if the prop changes.
We use js_from_routes to generate TypeScript path helpers from Rails routes, instead of passing *_path strings down as Inertia props. Don't pass paths as props — derive them on the client.
- Don't:
render inertia: "Foo", props: { update_path: my_foo_update_path }
<Form action={update_path} method="patch">
- Do:
render inertia: "Foo", props: {}
<script lang="ts"> import { fooThings } from "../../api"; const updatePath = fooThings.update.path(); </script> <Form action={updatePath} method="patch">
For a route with URL params, pass them to .path():
fooThings.update.path({ id: 1 }); // -> "/foo_things/1"
fooThings.update.path({ query: { from: "x" } }); // -> "/foo_things?from=x"- Add the route's
as:name toEXPORTED_ROUTESinconfig/initializers/js_from_routes.rb. We use an explicit allowlist (notdefaults export: true) so the generatedapp/javascript/api/stays small and predictable. - In dev, refresh the page (Rails reloader regenerates) or run
docker compose exec web bin/rake js_from_routes:generate. Force regeneration withJS_FROM_ROUTES_FORCE=true. - Import from
app/javascript/api/<Namespace>Api.ts(one file per controller). All helpers are also re-exported fromapp/javascript/api/index.ts.
- The path needs the request host (e.g.
share_url: profile_project_url(...)for clipboard copy/link sharing). - The path is computed from data the client doesn't have (e.g.
LeaderboardPageCachebuilds per-rowprofile_pathserver-side and caches it). - The path is purely external (GitHub edit links, Slack channels, etc.) — those aren't Rails routes anyway.
Files under app/javascript/api/ are gitignored and regenerated on every build:
- Dev:
entrypoint.dev.shregenerates on container start, and the Rails reloader regenerates on subsequent route changes. - Production Docker build: regenerated in
Dockerfilebeforeassets:precompile. - CI: regenerated in the
frontendandtest_systemjobs before Vite/svelte-check run; thetestjob triggers regeneration via Rails boot.
After adding a route to EXPORTED_ROUTES, just refresh the page (or run docker compose exec web bin/rake js_from_routes:generate) — there's nothing to commit.
To change the default theme, update it in two places:
app/models/concerns/user_theme_configuration.rb—DEFAULT_THEMEconstant (controls the model default andtheme_metadatafallback)app/javascript/utils.ts—DEFAULT_THEMEexport (used byMarketingLayout.sveltefor unauthenticated pages, andAppearance.svelteas the pre-load fallback)
Valid theme values are the keys of the enum :theme in app/models/user.rb.
- Need to show users to a human (search UI, picker)? →
fuzzy_ranked_search - Need to find IDs to filter something else by? →
search_identity