Ramp-up guide for new contributors. The README covers the elevator pitch and basic build; this file covers how the codebase actually fits together, the invariants you must not break, and the gotchas that cost time to rediscover.
Reading order for day one: this file → docs/PLAN.md (architecture rationale)
→ docs/adr/0002 (core owns the document) → crates/robotstudio-core/src/engine.rs
→ apps/frontend/src/transport/index.ts. That path explains 80% of the design.
A Rust core owns the entire document and all domain logic; three interchangeable shells (Tauri desktop, wasm-in-browser, test mock) drive it through one JSON command interface; the React frontend is a thin view that never holds document truth.
Everything else follows from that sentence:
- The core is a pure state machine (ADR 0002). The document, undo/redo,
snapping, the checker, licensing, telemetry — all live in
robotstudio-core::Engine. The frontend sends commands and renders theDocumentViewit gets back. If you find yourself computing document state in TypeScript, stop — that logic belongs in Rust. - The core is clock-free, I/O-free, and wasm-clean (ADR 0003). No
std::time, no file access, no network inrobotstudio-core. Shells supply time (e.g.today_dayfor license checks) and do all I/O. This is what makes the browser build and deterministic tests possible. - One transport seam.
apps/frontend/src/transport/index.tsdefinesCoreTransportwith three implementations: Tauri IPC, wasm, and a mock for unit tests. Nothing outsidesrc/transport/may import@tauri-apps/apior the wasm package. This rule is what keeps the frontend runnable in a plain browser and in jsdom.
crates/ Rust workspace (all logic)
robotstudio-core/ Engine, document, commands, snap, checker glue,
licensing, telemetry, bundle verification, math
robotstudio-rsp/ .rsp part-package parsing + schema validation
robotstudio-rules/ Data-driven rule engine (checker rule definitions)
robotstudio-kin/ Kinematics: FK/IK for the supported arm families
robotstudio-sim/ Deterministic sim: cycle check, live run, collision
robotstudio-export/ URDF/USDA/STEP-outline/JSON exporters
robotstudio-bom/ BOM derivation + CSV
robotstudio-wasm/ wasm-bindgen bindings over the core (browser shell)
apps/
desktop/src-tauri/ Tauri 2 shell: IPC commands, dialogs, resources,
license persistence, crash log, close-confirm
frontend/ React 18 + Vite 6 + TS strict + R3F + Zustand
e2e/ Playwright suite (runs against the BUILT frontend)
catalog/ 200 parts / 58 vendors — ALL GENERATED, see below
tools/build_catalog.py The generator; catalog/parts is its output
rules.json Checker rule data (CHK families)
spec/ Open .rsp/.rse spec: JSON Schemas + exemplars
templates/ Starter projects (.robotstudio files)
environments/ .rse environments for Live Run
behaviors/ Sim behavior definitions (e.g. sortation)
scripts/ Build/sync/validation tooling (see below)
docs/ PLAN, ADRs 0001–0016, budgets, telemetry policy,
milestone logs (M0–M5), install-test procedure
docs-site/ Generated spec site (build-spec-site.mjs)
_clickable-prototype/ The original HTML UX reference — the look/feel
source of truth; not shipped
PRD.md is scope truth; ROADMAP.md is status truth (legend at the top —
note 🟢 means "verified on-device by the maintainer" and is set only by them).
crates/robotstudio-core/src/:
doc.rs—Document: instances, connections, scenario. The serialized form is the file format (cell-project-1, ADR 0009): field order is append-only, every top-level field is required (no serde defaults), and the format is product-name-neutral by design.command.rs/engine.rs— the command pattern. Every mutation is aCommandapplied throughEngine::apply, which returns an outcome + fresh view and pushes undo state. Add features as new commands, not new mutation paths.snap.rs— interface-based snapping (candidates, capture resolution, orientation cycling). The drag UX in the frontend is a thin driver over this.registry.rs— the part registry, populated from.rsppackages at boot.view.rs— everything serialized to the frontend (DocumentViewetc.). View payloads are camelCase; command payloads are snake_case (serde defaults). The TS mirror lives inapps/frontend/src/core/types.ts— keep the two in sync by hand.checker.rs+robotstudio-rules— data-driven checks (CHK-* families) defined incatalog/rules.json, evaluated after every commit.license.rs— offline Ed25519 license keys (ADR 0015). See "Licensing".telemetry.rs— event log, drained by shells (ADR 0016). See "Telemetry".bundle.rs— signed catalog-bundle manifest verification (ADR 0014).world.rs,math.rs,system.rs— transforms/placement, math helpers, and the electrical/data system graph.
The STEP sidecar (ADR 0005) is designed but not built — the only place
unsafe would ever be allowed. The main workspace denies unsafe_code,
unwrap_used, dbg_macro, and todo at the workspace level; test modules
opt out of unwrap locally.
apps/frontend/src/:
state/— Zustand stores.doc.tsis the big one (view, checker, BOM, selection, dirty/save/open, toasts); alsodrag.ts(placement gestures),ui.ts(view/pane/prefs/telemetry consent),live.ts(Live Run),license.ts,theme.ts.components/— the shell chrome (TitleBar, Rail, CatalogPanel, SidePanel, StatusBar, modals) and the R3FStage/CenterView.transport/— the seam (see above). The wasm transport also bootstraps the catalog: it prefers one/catalog/combined.jsonfetch (200 parts would otherwise be ~1200 requests — this took the E2E suite from timing out to ~20s) with per-part fallback.core/types.ts— hand-maintained TS mirror of the core's JSON surface.- TS is strict;
anyis banned (lint-enforced). Keep it that way.
State rule of thumb: document truth lives in the core; UI-only flags (hidden/locked instances, open panels, drag state) live in stores and are never persisted into the document (ADR 0002).
catalog/parts/is entirely generated. Never hand-edit a part.python3 catalog/tools/build_catalog.pyregenerates all 200 parts from the definitions in that script (rm -rf catalog/partsfirst is safe). Add or fix parts by editing the generator. Placeholder meshes come fromscripts/make-placeholder-glb.py(note: cylinders take"size": [radius, height]).- After changing catalog/templates/environments, run
node scripts/sync-web-assets.mjsto refresh the frontend's static copies, includingcombined.json. templates/*.robotstudioare ordinary project files; the frontend fetches them at boot for the template picker.environments/hold.rsepackages (manifest + zones + geometry) consumed by Live Run (M4).spec/is the published, versioned spec for third parties — schemas are CC-BY-4.0, exemplar parts included.node scripts/build-spec-site.mjsrenders it todocs-site/.
Prerequisites: Rust (pinned by rust-toolchain.toml), Node 20+, pnpm 9+,
and for the wasm harness wasm-bindgen-cli matching the workspace's
wasm-bindgen version.
pnpm install
# Rust: the full local gate
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo check --workspace --lib --target wasm32-unknown-unknown
# Frontend
pnpm --filter frontend test # vitest (unit, jsdom + mock transport)
pnpm --filter frontend lint
pnpm --filter frontend build # tsc --noEmit && vite build
# Wasm bindings (needed before browser/E2E work if the core changed)
./scripts/build-wasm.sh
# E2E — see the gotcha below
pnpm --filter frontend build && pnpm --filter e2e test
# Run the desktop app (long-lived — run in your own terminal)
pnpm --filter desktop dev
# Repo invariant checks
./scripts/check-product-name.sh
python3 scripts/validate-exports.py # exporter output validationThree layers, each with a distinct job:
- Rust unit tests (~90) — domain logic, in-crate. Determinism matters:
the sim is tested for identical output across runs/platforms
(
golden-runlegs in CI). - Vitest (
apps/frontend/src/*.test.tsx) — chrome behavior against the mock transport (makeMockTransport(overrides)+setTransportForTests). jsdom; the R3F stage no-ops (canvas warnings in output are normal). - Playwright (
apps/e2e/tests/, 35 specs) — the real product in a browser via the wasm core, served byvite previewon :4173.helpers.tsis the toolkit:appReady/startEmpty/openTemplate, real-pointer drag helpers, and read-only store bridges (window.__appDebug,__stageDebug). Gestures always go through real pointer/keyboard events; the bridges only observe.
The E2E gotcha (learn this now): Playwright serves the built frontend.
After any frontend change, run pnpm --filter frontend build before
pnpm --filter e2e test, or you will debug stale code. Similarly, core
changes need ./scripts/build-wasm.sh first.
Other test conventions: appReady seeds hints-dismissed-v1 so onboarding
hints don't block unrelated specs (onboarding.spec.ts covers them unseeded);
perf.spec.ts runs in its own serial project after the parallel suite
because frame-time budgets need an uncontended CPU (docs/budgets.md holds
the measured numbers).
- Product display name appears in exactly three code locations
(ADR 0009, enforced by
scripts/check-product-name.sh): corePRODUCT_NAME,vite.config.ts,tauri.conf.jsonproductName. Everywhere else derive it (Rust:format!("{PRODUCT_NAME} …"); TS:__PRODUCT_NAME__). File formats and the spec never contain it — the product may be renamed before launch. - No
unsafe(workspace deny), nounwrapin library paths, nodbg!/todo!. No TSany. - Transport isolation: only
src/transport/touches Tauri or wasm APIs. - Document format is append-only and every persisted format is product-neutral.
- Core stays clock-free/I-O-free/wasm-clean; shells inject time and do I/O.
- Secrets never enter the repo — the license issuer's private seed lives outside; only public keys are embedded.
- Telemetry is content-free (ADR 0016,
docs/telemetry.md): event names and counts only — never search queries, never file paths, never part freetext beyond catalog IDs.part-searchrecords a result count. Dev builds never persist telemetry (cfg!(debug_assertions)guard).
Licensing (ADR 0015). Key = base64url(payload-json).base64url(ed25519-sig),
verified offline against ISSUER_KEYS in license.rs. Free tier needs no key;
is_pro() is true in Valid and Grace (30 days past expiry). Issue dev keys
with cargo run -p robotstudio-core --example license_tool -- issue <seed-hex> <licensee> <days> (test seed 0707…07, kid dev-2026). The embedded
dev-2026 key is a designed launch blocker — it must be rotated before any
public build (procedure in docs/milestones/M5.md).
Telemetry (ADR 0016). Core appends TelemetryEvents to a bounded log;
shells drain every 60s gated on user consent (default off unless
VITE_TELEMETRY_DEFAULT=on at build time). There is deliberately no uploader
and no backend — desktop appends JSONL to a ~1MB-bounded local file, browser
keeps a localStorage ring.
Catalog bundles (ADR 0014). Distribution is static hosting: a signed
bundle-manifest.json (same Ed25519 trust root as licensing) built by
scripts/build-bundle.py, signed with license_tool sign-bundle, checked at
boot by sync.ts when VITE_CATALOG_BUNDLE_URL is set; silent no-op offline.
Per-file download/install is deferred (helper file_matches exists in core).
Project files. Save/Open lives in the transport seam
(saveProjectToFile/openProjectFromFile): native dialogs on desktop (driven
from Rust via tauri-plugin-dialog — the webview needs no plugin permissions),
download/upload in the browser. Dirty state lives in the doc store and is
mirrored to the desktop shell (set_dirty) for the native close-confirm;
the browser uses beforeunload.
Desktop resource lookup. The shell finds catalog/, environments/,
templates/ by walking up from the exe and cwd, plus the bundled locations
(../Resources on macOS, ../lib/robotstudio-desktop on Linux). CATALOG_DIR
env overrides. If parts don't load in a packaged build, start here.
.github/workflows/:
ci.yml— Rust tests/clippy/fmt, wasm check, frontend unit + E2E, and the cross-OS golden-run determinism leg.exports.yml— exporter output validation against real toolchains.release.yml— onv*tags: tauri-action builds dmg/app/msi/nsis/deb/ appimage on a 3-OS matrix into a draft release; Apple signing activates only when theAPPLE_*secrets exist. Tags containing-mare marked prerelease.
Milestone history: v0.0.1-m0 … v0.4.0-m4, v1.0.0. Each milestone has a
log in docs/milestones/ recording judgment calls and deviations —
read M5's for the current launch checklist and open decisions.
- Rebuild before E2E (frontend and wasm) — see Testing above.
catalog/partsis generator output; editbuild_catalog.py, then rerun it andsync-web-assets.mjs. Part IDs must be regex-safe (no slashes in PN- derived IDs) and slugs unique — the generator's uniqueness check is your friend.- The empty/default project JSON must carry all six top-level document
fields (
schema,name,nextInstance,instances,connections,scenario) — the format has no serde defaults. - Playwright strict mode: prefer
data-testids; watch for accessible-name collisions ("New" vs "New…"). - In Tauri commands, blocking dialog APIs must run off the main thread — make
those commands
async. On close-confirm, callwindow.destroy(), notclose(), or the handler re-enters and re-prompts. - The webview's
window.confirmis not reliable across wry platforms — desktop confirms go through theconfirm_discardcommand instead. - jsdom canvas "Not implemented" warnings in vitest output are expected noise from the R3F stage probe.
- serde enum tags in this codebase are kebab-case; the TS mirror must match
exactly (
state: "grace",days_leftsnake_case inside view payloads that serialize structs directly — checktypes.tsprecedent before assuming).