Files
penpot/AGENTS.md
2026-03-09 16:23:28 +01:00

8.0 KiB
Raw Blame History

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 middleware
  • app.db.* Database layer
  • app.tasks.* Background job tasks
  • app.main Integrant system setup and entrypoint
  • app.loggers Internal loggers (auditlog, mattermost, etc) (do not be confused with app.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 store
  • app.util.* Utilities (DOM, HTTP, i18n, keyboard shortcuts)

Common:

  • app.common.types.* Shared data types for shapes, files, pages
  • app.common.schema Malli validation schemas
  • app.common.geom.* Geometry utilities
  • app.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