This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a community fork. develop is maintained as upstream/main + a small patch series of thematic commits (listed in COMMUNITY_CHANGES.md), rebased on top of upstream at every sync. New PRs are squash-merged on develop, then folded into the relevant patch (or added as a new one, updating the list in COMMUNITY_CHANGES.md) at the next upstream sync. When a contribution is accepted upstream, drop it from the series and from the list.
bundleVersion (in packages/protocol/src/schema.ts) is a contract hash of the schema. The fork's self-hosting patches change the schema, so its hash differs from the (upstream-generated) stamps committed in fixtures/*/.webstudio/data.json, failing fixtures.test.ts. After changing the bundle schema — or after an upstream sync — run pnpm fixtures:restamp and commit. The sync-upstream workflow already does this automatically after a clean rebase; pnpm fixtures:restamp --check fails on drift (CI).
The officially-published webstudio npm CLI is built from upstream's schema, so it sends the upstream hash — any fork-only field added to publishedProjectBundle that stays in the hash breaks that CLI against a self-hosted fork instance for every version, not just old ones (see assertCliBundleVersion in apps/builder/app/services/build-router.server.ts). Fork-only additive fields the CLI itself never reads/writes (e.g. customDomains, server->publisher only) must be .omit()-ed from the schema passed to createContractVersion so the hash stays aligned with upstream's.
Because both sides re-generate that stamp, it conflicts on most syncs even though it is not real content. The sync-upstream workflow therefore auto-resolves rebase conflicts that touch only fixtures/*/.webstudio/data.json (takes the upstream stamp, then re-stamps), and only opens a manual-conflict issue when a real code file also conflicts. These fixture files are force-tracked despite the .webstudio ignore rule — the negations in .gitignore keep the pre-commit hook from choking on re-stamps.
Webstudio is an Open Source Visual Development Platform. This is a pnpm monorepo with three workspace types: apps/, packages/, and fixtures/.
- Node.js: 22 (see
.nvmrc) - Package manager: pnpm 9.14.4 (do not use npm or yarn)
# Development
pnpm dev # Start builder app dev server (Remix + Vite)
# Build
pnpm build # Build all packages (excludes fixtures)
# Testing & Checks
pnpm checks # Run tests + typecheck + lint + fixtures (full CI)
pnpm test # Run tests across all packages
pnpm typecheck # TypeScript type-check all packages
pnpm lint # ESLint with max-warnings 0
# Formatting
pnpm format # Prettier format all TS/TSX/MD files
# Single test file (from repo root)
pnpm --filter='@webstudio-is/builder' test -- run path/to/file.test.ts
# Or from apps/builder/
pnpm test -- run path/to/file.test.tscd apps/builder
pnpm dev # Dev server (also pre-builds http-client)
pnpm dev:auth # Dev server with OAuth debug logging
pnpm build # Production build
pnpm typecheck # Uses tsgo (TypeScript native preview) for speed
pnpm test # vitest runThe builder requires PostgreSQL + PostgREST running locally. apps/builder/backend/ provides both: a base compose.yaml plus one override per mode (compose.development.yaml for day-to-day dev, compose.test.yaml / compose.snapshot.yaml / compose.supabase.yaml for e2e). Drive it with pnpm backend <mode> <docker compose args> — never docker compose directly, since the mode selects which override file is layered on.
Windows setup: Docker runs in WSL; all pnpm commands run in PowerShell. Do not mix — node_modules installed from Windows contains Windows binaries and will break if used from WSL, and vice versa.
1. Configure pnpm to use Git Bash — create webstudio-fork/.npmrc (gitignored, not committed):
script-shell=C:\Program Files\Git\bin\bash.exe
This lets pnpm run build scripts that use rm -rf on Windows. Requires Git for Windows.
2. Install dependencies (PowerShell, repo root):
pnpm install3. After pulling upstream changes — upstream's pnpm-lock.yaml may not include direct dep specifiers added by this fork (e.g. compression). Regenerate the lockfile before building:
pnpm install --no-frozen-lockfile4. Build internal packages (PowerShell, repo root — first time only, or after pulling upstream changes):
pnpm -r --filter='./packages/**' build
pnpm build(without filter) will fail onpackages/clidue to a known issue with the proprietary animation package banner. This does not affect the dev server.
4. Start the containers (WSL):
cd /path/to/webstudio-fork/apps/builder
pnpm backend development up -dThis starts:
- PostgreSQL 15 (Supabase image) published on
PGPORT(55432in.env) - PostgREST v12 published on
POSTGREST_PORT(55433in.env)
Credentials come from the committed apps/builder/.env (POSTGRES_USER=supabase_admin,
POSTGRES_PASSWORD=pass, POSTGRES_DB=webstudio), and POSTGREST_API_KEY is empty
locally — the local PostgREST container needs no JWT. Do not set POSTGRES_USER in
the compose file: the Supabase image bootstraps its own roles as supabase_admin and
overriding it breaks the image's init scripts.
If you have a local PostgreSQL service on Windows, stop it first:
Stop-Service postgresql-x64-17
5. Run migrations (PowerShell, repo root — first time only, or after schema changes):
pnpm migrations migrate6. Start the builder (PowerShell, from apps/builder):
$env:DOCKER_DEV="true"; pnpm dev
DOCKER_DEV=truemakes Vite bind on0.0.0.0instead ofwstd.dev. Must be set as a shell variable —.env.developmentis not read byvite.config.tsat startup.
7. Open the builder: https://localhost:5173
- Bypass the certificate warning (cert is for
wstd.dev, notlocalhost) - Login with the secret from
AUTH_SECRETin.env.development(default:1234) - First load is slow — Vite pre-bundles dependencies. Wait, then refresh the page.
# WSL
pnpm backend development up -d # from apps/builder# PowerShell, from apps/builder
$env:DOCKER_DEV="true"; pnpm devAlways delete from PowerShell (fast), then reinstall from PowerShell:
Remove-Item -Recurse -Force node_modules
pnpm install
pnpm -r --filter='./packages/**' build| File | Purpose |
|---|---|
apps/builder/.env |
Default config (DB URL, PostgREST URL, DEV_LOGIN=true) |
apps/builder/.env.development |
Local overrides (AUTH_SECRET=1234, DOCKER_DEV=true) |
apps/builder/.npmrc |
pnpm hoisting config — forces react-router-dom top-level symlink in pnpm deploy --prod (Docker image fix) |
Both env files are committed. Edit .env.development for machine-specific overrides.
Session/CSRF/returnTo cookies (session.server.ts, builder-session.server.ts,
csrf-session.server.ts, cookie.server.ts) use the __Host- prefix, which
browsers only accept over HTTPS — this is why local dev serves the builder at
https://localhost:5173 with a self-signed cert instead of plain HTTP. For
deployments that can't put TLS in front of the app (e.g. testing
webstudio-self-host's docker-compose.yml on bare http://localhost), set
ALLOW_INSECURE_COOKIES=true to drop both the __Host- prefix and the
Secure flag together (secureCookieName() in env.server.ts). This is
refused at startup when DEPLOYMENT_ENVIRONMENT=production — never set it on
a real deployment.
pnpm --filter "@webstudio-is/builder" --prod deploy /standalone only creates top-level
node_modules symlinks for apps/builder's direct dependencies. A package used at
runtime only as a transitive dependency (e.g. pulled in by a workspace package like
@webstudio-is/project-build) stays nested in the pnpm virtual store with no top-level
symlink, so Node's ESM resolution fails from the bundled server output
(ERR_MODULE_NOT_FOUND) even though the package built fine. Seen with react-router-dom
(fixed via manual symlink in the Dockerfile) and parse5 (fixed by declaring it as a
direct dependency in apps/builder/package.json). If a similar error shows up for another
package, check whether it's declared directly in apps/builder/package.json first.
scripts/verify-standalone-deps.mjs checks for this automatically: it scans the
compiled server bundle for bare imports and confirms each resolves from a given
pruned node_modules. It uses a small string/comment/regex-literal-aware scanner,
not a plain regex — the bundle vendors a JS parser (acorn, via Vite) whose own
source contains text that looks like import syntax without being any (e.g.
kw("import", startsExpr) keyword tables, or from as an ordinary parameter
name), which a regex has no way to tell apart from real code since it can't
see "inside a string literal". .github/workflows/verify-standalone-deps.yml
runs it against the Dockerfile's builder stage on every PR into develop (fast
feedback, non-blocking) and on every push to develop. docker-publish.yml
doesn't build on that same push trigger directly — it watches
verify-standalone-deps's runs via a workflow_run trigger and only builds/
publishes an image once one of them succeeds on develop, gated by
github.event.workflow_run.conclusion == 'success'. That decouples the two
workflows entirely: docker-publish.yml doesn't even start until the check has
already passed, instead of racing it or (as two earlier iterations of this did)
gating on a needs: chain — GitHub Actions' default success() job condition
walks the whole transitive needs graph, not just direct needs:, so a check
job skipped by design on non-push events silently skipped every job downstream
of it too. Since workflow_run fires after develop's tip may have already
moved past the verified commit, the build job explicitly checks out
github.event.workflow_run.head_sha rather than the default (which would grab
develop's current tip) — otherwise a push landing in that gap could slip an
unverified commit into the published image.
The publisher bakes the fork's CLI at a specific commit, so a builder branch can
only be publish-tested against a publisher built from the same commit. On every
PR from a branch in this repo, docker-publish.yml's publisher-test-image
job (after merge) dispatches webstudio-publisher's docker-publish.yml with
builder_ref=<PR branch> and a nonce (pr<N>-<run_id>-<attempt>), finds that
run via the nonce in its run name, waits for it, then upserts one PR comment
(marker <!-- test-images -->) listing both image tags —
builder:<branch> and webstudio-publisher:builder-<branch> — to plug into
BUILDER_IMAGE / PUBLISHER_IMAGE on the Coolify test stack. Re-runs edit the
same comment. docker-cleanup.yml dispatches the publisher's cleanup for
builder-<branch> when the PR closes. All of it needs the
PUBLISHER_DISPATCH_APP_* secrets (the same GitHub App the develop→publisher
trigger uses) and no-ops for forked PRs; add the skip-publisher-test label to
opt a PR out.
apps/builder/ # Main visual builder UI (Remix + Vite)
packages/ # 33 shared packages
fixtures/ # Template projects for deployment targets
Remix fullstack application. The app/ directory structure:
canvas/— Iframe canvas rendering the user's page for editingbuilder/— Builder UI panels (style, settings, navigator, topbar, etc.)features/— Feature modules (style-panel, settings-panel, breakpoints, etc.)shared/— Shared builder utilities (stores, asset management, etc.)
dashboard/— Project dashboardroutes/— Remix routesshared/— App-wide utilities
The builder communicates with the canvas via postMessage. State is managed with nanostores.
| Package | Purpose |
|---|---|
@webstudio-is/sdk |
Core data model and Zod schemas for Webstudio projects |
@webstudio-is/react-sdk |
Runtime API for rendering Webstudio projects in React |
@webstudio-is/design-system |
UI component library (Radix UI + Stitches) |
@webstudio-is/css-engine |
CSS generation and runtime styling |
@webstudio-is/css-data / html-data |
CSS/HTML specs and metadata |
@webstudio-is/sdk-components-react |
Default component library |
@webstudio-is/sdk-components-react-radix |
Radix UI component integrations |
@webstudio-is/trpc-interface |
tRPC router definitions (client-server API) |
@webstudio-is/prisma-client |
Prisma ORM database layer |
@webstudio-is/cli |
CLI for linking/syncing/building projects |
Packages use a custom "webstudio" export condition to split code between design-time (builder) and runtime (user's site). Tests resolve this with conditions: ["webstudio", "browser"].
- Filenames: kebab-case required (
unicorn/filename-case) - React imports: No default imports — use named imports (
import { useState } from "react", notimport React from "react") - Functions: Expression style required (
const foo = () => {}, notfunction foo() {}) - Node imports: Use
node:protocol (node:path, notpath) - Console: Only
console.warn,console.error,console.infoallowed (noconsole.log) - Equality: Always
===(except null comparisons)
- Framework: Vitest with jsdom environment
- Test files live alongside source as
*.test.ts/*.test.tsx - Path alias
~resolves toapps/builder/app/in builder tests - Coverage via
@vitest/coverage-v8
- Shared base config in
packages/tsconfig - Target ES2023, strict mode enabled
- Builder uses
tsgo(TypeScript native preview) fortypecheckspeed - Generated files in
__generated__/directories are excluded from lint
nano-staged runs Prettier on staged *.{ts,tsx,js,json,css,md} files automatically.