8.0 KiB
Penpot – Copilot Instructions
Architecture Overview
Penpot is a full-stack design tool composed of several distinct components:
| Component | Language | Role |
|---|---|---|
frontend/ |
ClojureScript + SCSS | Single-page React app (design editor) |
backend/ |
Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis |
common/ |
Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities |
exporter/ |
ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) |
render-wasm/ |
Rust → WebAssembly | High-performance canvas renderer using Skia |
mcp/ |
TypeScript | Model Context Protocol integration |
plugins/ |
TypeScript | Plugin runtime and example plugins |
The monorepo is managed with pnpm workspaces. The manage.sh
orchestrates cross-component builds. run-ci.sh defines the CI
pipeline.
Build, Test & Lint Commands
Frontend (cd frontend)
Run ./scripts/setup for setup all dependencies.
# Dev
pnpm run watch:app # Full dev build (WASM + CLJS + assets)
# Production Build
./scripts/build
# Tests
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
pnpm run watch:test # Watch + auto-rerun on change
pnpm run test:e2e # Playwright e2e tests
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
# Lint
pnpm run lint:js # format and linter check for JS
pnpm run lint:clj # format and linter check for CLJ
pnpm run lint:scss # prettier check for SCSS
# Code formatting
pnpm run fmt:clj # Format CLJ
pnpm run fmt:js # prettier for JS
pnpm run fmt:scss # prettier for SCSS
To run a focused ClojureScript unit test: edit
test/frontend_tests/runner.cljs to narrow the test suite, then pnpm run build:test && node target/tests/test.js.
Backend (cd backend)
# Tests (Kaocha)
clojure -M:dev:test # Full suite
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
# Lint / Format
pnpm run lint:clj
pnpm run fmt:clj
Test config is in backend/tests.edn; test namespaces match .*-test$ under test/.
Common (cd common)
pnpm run test # Build + run node target/tests/test.js
pnpm run watch:test # Watch mode
pnpm run lint:clj
pnpm run fmt:clj
Render-WASM (cd render-wasm)
./test # Rust unit tests (cargo test)
./build # Compile to WASM (requires Emscripten)
cargo fmt --check
./lint --debug
Key Conventions
Namespace Structure
Backend:
app.rpc.commands.*– RPC command implementations (auth,files,teams, etc.)app.http.*– HTTP routes and middlewareapp.db.*– Database layerapp.tasks.*– Background job tasksapp.main– Integrant system setup and entrypointapp.loggers– Internal loggers (auditlog, mattermost, etc) (do not be confused withapp.common.loggin)
Frontend:
app.main.ui.*– React UI components (workspace,dashboard,viewer)app.main.data.*– Potok event handlers (state mutations + side effects)app.main.refs– Reactive subscriptions (okulary lenses)app.main.store– Potok event storeapp.util.*– Utilities (DOM, HTTP, i18n, keyboard shortcuts)
Common:
app.common.types.*– Shared data types for shapes, files, pagesapp.common.schema– Malli validation schemasapp.common.geom.*– Geometry utilitiesapp.common.data.macros– Performance macros used everywhere
Backend RPC Commands
All API calls go through a single RPC endpoint: POST /api/rpc/command/<cmd-name>.
(sv/defmethod ::my-command
{::rpc/auth true ;; requires auth
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map or throw
{:id (uuid/next)})
Frontend State Management (Potok)
State is a single atom managed by a Potok store. Events implement protocols:
(defn my-event [data]
(ptk/reify ::my-event
ptk/UpdateEvent
(update [_ state] ;; synchronous state transition
(assoc state :key data))
ptk/WatchEvent
(watch [_ state stream] ;; async: returns an observable
(->> (rp/cmd! :some-rpc-command params)
(rx/map success-event)
(rx/catch error-handler)))
ptk/EffectEvent
(effect [_ state _] ;; pure side effects (DOM, logging)
(.focus (dom/get-element "id")))))
Dispatch with (st/emit! (my-event data)). Read state via reactive
refs: (deref refs/selected-shapes). Prefer helpers from
app.util.dom instead of using direct dom calls, if no helper is
available, prefer adding a new helper for handling it and the use the
new helper.
CSS Modules Pattern
Styles are co-located with components. Each .cljs file has a corresponding .scss file:
;; In the component namespace:
(require '[app.main.style :as stl])
;; In the render function:
[:div {:class (stl/css :container :active)}]
;; Conditional:
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
;; When you need concat an existing class:
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
Performance Macros (app.common.data.macros)
Always prefer these macros over their clojure.core equivalents — they compile to faster JavaScript:
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
(dm/str "a" "b" "c") ;; string concatenation
Shared Code (cljc)
Files in common/src/app/common/ use reader conditionals to target both runtimes:
#?(:clj (import java.util.UUID)
:cljs (:require [cljs.core :as core]))
Both frontend and backend depend on common as a local library (penpot/common {:local/root "../common"}).
Component Definition (Rumext / React)
The codebase has several kind of components, some of them use legacy
syntax. The current and the most recent syntax uses * suffix on the
name. This indicates to the mf/defc macro apply concrete rules on
how props should be treated.
(mf/defc my-component*
{::mf/wrap [mf/memo]} ;; React.memo
[{:keys [name on-click]}]
[:div {:class (stl/css :root)
:on-click on-click}
name])
Hooks: (mf/use-state), (mf/use-effect), (mf/use-memo) – analgous to react hooks.
The component usage should always follow the [:> my-component* props], where props should be a map literal or symbol pointing to
javascript props objects. The javascript props object can be created
manually #js {:data-foo "bar"} or using mf/spread-object helper
macro.
Commit Guidelines
Format: <emoji-code> <subject>
:bug: Fix unexpected error on launching modal
Optional body explaining the why.
Signed-off-by: Fullname <email>
Subject rules: imperative mood, capitalize first letter, no
trailing period, ≤ 80 characters. Add an entry to CHANGES.md if
applicable.
Code patches must include a DCO sign-off (git commit -s).
| Emoji | Emoji-Code | Use for |
|---|---|---|
| 🐛 | :bug: |
Bug fix |
| ✨ | :sparkles: |
Improvement |
| 🎉 | :tada: |
New feature |
| ♻️ | :recycle: |
Refactor |
| 💄 | :lipstick: |
Cosmetic changes |
| 🚑 | :ambulance: |
Critical bug fix |
| 📚 | :books: |
Docs |
| 🚧 | :construction: |
WIP |
| 💥 | :boom: |
Breaking change |
| 🔧 | :wrench: |
Config update |
| ⚡ | :zap: |
Performance |
| 🐳 | :whale: |
Docker |
| 📎 | :paperclip: |
Other non-relevant changes |
| ⬆️ | :arrow_up: |
Dependency upgrade |
| ⬇️ | :arrow_down: |
Dependency downgrade |
| 🔥 | :fire: |
Remove files or code |
| 🌐 | :globe_with_meridians: |
Translations |