Planned work — features, bugs, chores, tests — lives in
GitHub Issues, the project's single backlog. To find
something to work on, filter open issues by label (area:*, type:*, priority:*). The issue
format, the full label glossary, and the triage/won't-do flow are documented in
ISSUE-WORKFLOW.md. Reference the issue from your PR (fixes #NN) so it closes
on merge.
-
Node via nvm (or your version manager of choice) — any version satisfying the
enginesrange inpackage.json; Capacitor 8 requires Node ≥ 22. Development is supported on macOS and Linux (ADR-0062).nvm use 22
-
pnpm — the package manager (ADR-0119). Don't install it directly;
corepack enable pnpmputs it on PATH at the exact versionpackage.json'spackageManagerfield pins, and corepack is bundled with Node.npm run <script>still works against a pnpm-installed tree, so everynpm run …in these docs is correct as written.corepack enable pnpmRe-run that after every
nvm install. Corepack writes its shim into the active Node version'sbin/, so a Node upgrade lands you in a shell wherepnpm: command not found— the package manager is fine, the new Node just has no shim yet.Never
npm installornpm ciin this repo. Neither errors — both give you a working flatnode_modules— but both write apackage-lock.jsonthat resolves the tree independently ofpnpm-lock.yamland then drifts from it silently. Usepnpm install, orpnpm add <pkg>to add one.package-lock.jsonis gitignored so the mistake can't spread, andtools/tests/package-manager.test.mjsfails if any CI, hook, or bootstrap file starts installing with npm again. -
Netlify CLI (optional) — only needed to run the
/api/*serverless functions locally vianpm run dev:netlify. Install globally withnpm install -g netlify-cli. -
For native Android/iOS work, see the full toolchain setup in the mobile guide. (iOS needs macOS + full Xcode; no CocoaPods — the project uses Swift Package Manager.)
corepack enable pnpm # once per machine
pnpm install
npm run dev # http://localhost:5173Two generators run automatically before every build (the prebuild/prebuild:cap hooks):
gen:icon-names— generatesweb/src/lib/components/icon-names.d.tsfrom the SVG files inweb/src/lib/icons/gen:releases— generatesweb/src/lib/releases.jsonand the fastlane store changelogs fromreleases/*.md
To see every npm script with a one-line description, run:
npm run infoThe descriptions live in the scripts-info section of package.json; script naming follows
ADR-0019.
Adding a dependency? The
dependencies/devDependenciessplit is repurposed (ADR-0070):dependenciesholds what the Netlify web build needs (the app's runtime imports plus vite/SvelteKit/the adapter),devDependenciesholds local/CI-only tooling (Playwright, dprint, sharp, the Capacitor CLIs, …). Netlify installs with--prod, so a build-needed package filed underdevDependenciesfails the deploy. GitHub Actions installs everything, so the build itself cannot catch it;npm run lint:deps:prod(in CI's Quality job) checks the split instead.
None are required for local development. The app works fully offline without any API keys.
Where a variable goes is not a free choice — it follows from who reads it, and the two groups below load at different times. Getting this wrong fails silently: the variable is simply undefined and the feature behaves as if you never set it.
Read through $env/dynamic/private at request time, so web/.env works for all of them.
web/.env.example is the copy-to-web/.env template, with the one-time PAT setup steps.
| Variable | Read by | Purpose, and what breaks when unset |
|---|---|---|
ALLOWED_TOKENS_LIST |
server/tokens.ts |
Comma-separated managed access tokens. A one-time seed into Blobs on first read, not the live list — /admin owns it after that |
ADMIN_ACCESS_TOKEN |
server/admin.ts |
The /admin console secret, and the HMAC key for its derived session token |
OPENAI_API_KEY |
server/config.ts |
The credential /api/generate-image bills hosted generation to. Unset ⇒ /api/free-generation-grant also 503s |
USAGE_GRANT_ID_SECRET |
server/usage.ts |
HMAC key for opaque grant ids. Unset ⇒ generation works, but durable tallies and /admin usage stats are disabled |
GITHUB_ISSUE_TOKEN |
server/config.ts |
Fine-grained PAT that files feedback as issues. Unset ⇒ /api/report, /api/report-image, and the /feedback form action all 503 |
GITHUB_ISSUE_REPO |
server/config.ts |
Overrides the feedback repo (default KyleMit/splotch-feedback) |
REPORT_TOKEN_SECRET |
server/reportToken.ts |
Signs the report token bound to a generation. Unset ⇒ free-tier picture reports and every refusal report 503 |
GENERATE_DEADLINE_MS_OVERRIDE |
server/generationStart.ts |
Raises the synchronous generation deadline. The manual red-team suite is the only caller; production never sets it |
Build flags are read from process.env while the config module is evaluated, which happens before
Vite loads any .env file. An entry in web/.env or web/.env.local does nothing for these
flags — set them inline on the command (PUBLIC_ENABLE_DEV_HARNESS=true npm run dev) or export
them. Tooling credentials in this table also require an exported variable or explicitly loading the
file with Node's --env-file=web/.env option on the tool's entry point.
| Variable | Read by | Purpose |
|---|---|---|
CAPACITOR |
svelte.config.js, vite.config.ts |
true switches to adapter-static, disables the PWA plugin, sets __NATIVE_API_BASE__ |
PUBLIC_ENABLE_DEV_HARNESS |
vite.config.ts |
Compiles the /dev/* routes in, via the __DEV_HARNESS__ literal |
PERF_MARKS |
vite.config.ts |
Compiles the engine's performance.mark instrumentation in, for npm run perf:web |
TUNNEL_HOST |
vite.config.ts |
Adds the tunnel hostname to server.allowedHosts for cloud preview |
GEMINI_API_KEY |
tools/asset-gen/, model-eval |
Not read by the app. These read process.env directly with no .env loading, so a web/.env entry needs node --env-file |
CLAUDE_CODE_OAUTH_TOKEN |
Claude CLI | Optional plan authentication; the CLI does not load web/.env. An existing Claude plan login may already supply authentication |
REDTEAM_FIXTURE_KEY is a third case: the red-team entry points load web/.env themselves
(REDTEAM_ENV_FILE in tools/redteam/lib/fixture-crypto.mjs), so it and the red-team run's
OPENAI_API_KEY come from the shell or, failing that, web/.env. A repo-root .env is not read.
Two other tooling CLIs load their own credentials from ignored files:
npm run vectorizereadsVECTORIZER_IDandVECTORIZER_SECRET, orVECTORIZER_AUTHORIZATION, from the shell, root.env, andweb/.env. A nonempty authorization header takes precedence over the pair; configure one form. See the Vectorizer guide.npm run gen:sound-effectreadsELEVENLABS_API_KEYfrom the shell, then root.env.local, root.env, andweb/.env. See the ElevenLabs guide.
COREPACK_HOME, npm_config_cache, and PLAYWRIGHT_CHROMIUM are machine settings read from the
shell, not application settings loaded from web/.env. Local tools normally use their default cache
locations and Playwright's installed browser without these overrides. The /workspace/.cache paths
and /usr/bin/chromium override in the Codex Cloud guide are for that cloud
machine; do not copy them into a local .env or shell profile.
web/playwright.shared.ts declares the full private set the served app reads, with the
outbound-write credentials neutralised; it is the list to check against when adding a row here.
To test the AI flow locally, run npm run dev:netlify instead of npm run dev — this starts the
Netlify Dev server so the /api/* serverless functions are available. This requires the Netlify
CLI, which is installed globally (it is not a project dependency):
npm install -g netlify-cliIn production, AI access is granted per user: append one of the ALLOWED_TOKENS_LIST values to the
app URL as the ai_access_token query param (AI_ACCESS_TOKEN_PARAM in
web/src/lib/inviteLink.ts) — https://splotch.art/?ai_access_token=YOUR_TOKEN.
Splotch ships as two distinct build targets from the same source:
| Target | Command | Adapter | Server routes |
|---|---|---|---|
| Web (Netlify) | npm run build |
adapter-netlify |
/api/*, /admin included |
| Native (Capacitor) | npm run build:cap |
adapter-static |
excluded (strict: false) |
The switch is the CAPACITOR env var, read by both web/svelte.config.js (adapter selection) and
web/vite.config.ts (PWA plugin, __NATIVE_API_BASE__).
The SvelteKit app lives in web/ (web/src/, the Vite/SvelteKit/test configs,
web/netlify.toml, and the web/build/ output). The Capacitor native projects (android/,
ios/), capacitor.config.json (webDir: "web/build"), the single root
package.json/node_modules, and tools/ stay at the repo root. npm run dev:netlify runs
netlify dev --cwd web so netlify-cli's file watcher is scoped to web/ and never traverses the
large native trees (the cause of the EMFILE crash this layout fixes). All the npm scripts still
run from the repo root; the web toolchain is dispatched into web/ by tools/run-web-tool.mjs.
Production deploy. Netlify builds from the repo root (where
package.json+ the lockfile live). The rootnetlify.tomlbuild command runsnpm run build(which builds the app inweb/) thennode tools/stage-netlify-functions.mjs, which copiesweb/.netlify → .netlifyso Netlify sees the SSR function in.netlify/functions-internal; static assets publish directly fromweb/build. Localnetlify devusesweb/netlify.tomlinstead.
On native the AI button calls the hosted endpoint (https://splotch.art/api/generate-image) via
__NATIVE_API_BASE__. On web it uses a same-origin relative path.
To get the static build into the native projects (full toolchain setup, on-device testing, and the store release flow live in the mobile guide):
npm run cap:sync # static build + copy into the native projects
npm run cap:android # also open the Android project in Android StudioThe web app deploys to Netlify, which builds automatically on push using the settings in
netlify.toml (see the production-deploy note above for how the root/web/ layout is staged). A
manual deploy also works via the Netlify CLI: netlify deploy --prod.
npm run check # Svelte and TypeScript checks, including Netlify functions
npm run check:watch # web/ Svelte and TypeScript checks only; no Netlify functionsnpm test # unit + asset/store pipelines + repo-script + E2E (what CI runs on every push)
npm run test:unit:watch # Vitest watch mode
npm run test:e2e:headed # Playwright with browser visible (SLOWMO=500)
npm run test:e2e:ui # Playwright UI modeSee the testing guide for the full test strategy, including the native smoke tests
(test:android, test:ios).
Run with PUBLIC_ENABLE_DEV_HARNESS=true in the environment (see above — a .env entry will not
work) to unlock:
| Route | Purpose |
|---|---|
/dev |
Index of the dev harnesses |
/dev/engine |
Blank canvas with debug controls for testing the drawing engine |
The source of truth for this list is web/src/routes/dev/. The design-token styleguide is not a dev
harness — it ships as the public /design route (see the design skill).
The coding standards live in docs/CODING-STANDARDS.md: each rule with the incident that earned it
and what enforces it. Read it before adding, relaxing, or re-proposing a rule. The conventions below
are the day-to-day ones.
Svelte 5 runes only. Use $state, $derived, $effect, $props. No legacy stores
(writable, readable).
State lives in web/src/lib/state/, not in components. Components read state and call setters;
they don't own shared state.
The drawing engine is imperative. web/src/lib/drawing/engine.ts is a plain TypeScript module
(not a Svelte store). Components wire into it via callbacks on mount and call its exported functions
directly (setColor, clearCanvas, etc.).
Svelte actions for complex gestures. Drag interactions (drag-to-clear) and dialog wiring live in
web/src/lib/actions/, not inline in components.
No comments on obvious code. Add a comment only when the why is non-obvious — a hidden constraint, a workaround, a subtle invariant. Don't describe what the code does; the names do that.
Scoped styles. Component styles go in the component's <style> block. Use :global() only for
a class forwarded into a child component, cross-component state on html[data-*] or a sibling's
data attributes, elements rendered inside {@html}, or a class set imperatively via classList.
Pin every new global seam with a scoped compound (.picker :global(.picker-option-icon),
:global(html[data-drawer-open]) .actions-drawer); an entirely unpinned selector such as
:global(.gate-mascot) is forbidden because it relies on a globally unique class name.
No framework overhead in platform/index.ts. The platform detection module reads the Capacitor
global directly rather than importing @capacitor/core, so it evaluates safely during SSR without
pulling in native code.
Formatting is enforced in CI (ADR-0031, ADR-0057). Prettier formats source; dprint formats
markdown (asterisk bullets and emphasis, hard wrap at 100 — dprint.json). Run npm run format
before pushing, or install the recommended VS Code extensions (.vscode/extensions.json) to format
on save.
Agent instruction files are generated (ADR-0058). Every CLAUDE.md/AGENTS.md and most of the
.claude/skills/ + .agents/skills/ trees are generated by
ruler from .ruler/; managed runner-specific forks live
in .ruler/skill-forks/<runner>/. Don't edit generated files — edit their .ruler/** source, run
npm run ruler:apply, and commit the output; CI fails on drift (npm run ruler:check).
The direct-maintained exceptions, and the providers each ships for, are registered in
tools/ruler/lib/direct-provider-skills.mjs. Each registered Claude package and note lives under
.claude/, each Codex package and note under .agents/. They are independent provider
implementations: edit only the registered provider packages directly and never synchronize one from
the other.
Claude Code's Read(//tmp/**) permission intentionally uses an absolute-path double slash so
sessions can read scratch files under /tmp. Do not change it to Read(/tmp/**): that syntax is
project-relative, and the broad /tmp scope is deliberate for session scratch files.
- Docs-only images (README screenshots and the like) live in
docs/assets/, committed as optimized.webp— never raw PNGs. Regenerate the README hero withnpm run gen:readme-hero -- --port <unused-port>; source art and capture details are in the marketing-asset runbook. - Shipped PNGs under
web/static/get a WebP sibling before committing:node tools/asset-gen/convert-png-to-webp.mjs. - Committed run outputs (proof sheets, Lighthouse reports, model tests) belong in
/scrapbook, notdocs/.
- Drop an SVG into
web/src/lib/icons/— or intoweb/src/lib/icons/deferred/when only lazily loaded UI renders it (a settings section, an overlay, a route other than/, the styleguide), so its bytes stay off the startup path (ADR-0164). - Run
npm run gen:icon-names(it also runs automatically before every build). - Use
<Icon name="your-icon-name" />— thenameprop is type-checked against the generated union. A file that names a deferred icon also imports$lib/components/deferredIconsfor its side effect;deferredIcons.test.tssays so when one is missing.
Dependabot opens weekly npm and github-actions PRs, and a workflow has Claude review each one and post an approve-or-flag comment. That verdict is advisory — CI is still the gate, and a human still merges. The setup requires one manual step (an OAuth token in the Dependabot secret store, not the Actions one), and misconfigurations fail silently rather than loudly. See DEPENDABOT.md.
See releases/README.md for the authoritative procedure. Shipping has three ordered phases:
- The
cut-releaseskill (npm run release <version>) creates the version bump, tag, notes, and an intentionally artifact-free GitHub Release. - The
buildskill (npm run android:bundleandnpm run ios:ipa) produces the signed artifacts. - The
publish-artifactsskill (npm run release:publish) verifies their embedded versions and attaches them to the GitHub Release.
The tag-triggered Android and iOS launch smoke workflows verify that the native apps start; they do not build, upload, or deploy release artifacts.