mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 05:28:43 +00:00
Merge remote-tracking branch 'origin/main' into staging
This commit is contained in:
commit
c7fae1f353
140
AGENTS.md
140
AGENTS.md
@ -1,93 +1,79 @@
|
|||||||
# AI Agent Guide
|
# AI AGENT GUIDE
|
||||||
|
|
||||||
This document provides the core context and operating guidelines for AI agents
|
## CRITICAL: Read module memories BEFORE writing any code
|
||||||
working in this repository.
|
|
||||||
|
|
||||||
## Before You Start
|
Do this **before planning, before coding, before touching any file**:
|
||||||
|
|
||||||
Before responding to any user request, you must:
|
1. Read `critical-info` (use `serena_read_memory critical-info` or read `.serena/memories/critical-info.md`).
|
||||||
|
It describes the project structure and tells you which modules exist.
|
||||||
|
2. From `critical-info`, identify which modules your task affects.
|
||||||
|
3. Read each affected module's **core memory** — the name is `<module>/core`
|
||||||
|
(e.g. `frontend/core`, `backend/core`, `common/core`).
|
||||||
|
4. If the core memory references deeper `mem:` memories relevant to your task, read those too.
|
||||||
|
|
||||||
1. Read this file completely.
|
**STOP: Do not proceed until you have read the core memory of every affected module.**
|
||||||
2. Identify which modules are affected by the task.
|
Skipping this step is the #1 cause of incorrect or incomplete work.
|
||||||
3. Load the `AGENTS.md` file **only** for each affected module (see the
|
|
||||||
architecture table below). Not all modules have an `AGENTS.md` — verify the
|
|
||||||
file exists before attempting to read it.
|
|
||||||
4. Do **not** load `AGENTS.md` files for unrelated modules.
|
|
||||||
|
|
||||||
## Role: Senior Software Engineer
|
---
|
||||||
|
|
||||||
|
# Memory system
|
||||||
|
|
||||||
|
Memories are the **primary project guidance** — not docs or readme files.
|
||||||
|
They are dense, agent-oriented notes: terse bullets, invariants, no prose.
|
||||||
|
|
||||||
|
## Entry point
|
||||||
|
|
||||||
|
Start at `critical-info` (the graph root). It describes the project structure,
|
||||||
|
module dependency graph, and references section-level core memories.
|
||||||
|
|
||||||
|
## Progressive discovery model
|
||||||
|
|
||||||
|
Memories form a **reference graph**, not a flat list:
|
||||||
|
|
||||||
|
```
|
||||||
|
critical-info ← read first (graph root)
|
||||||
|
└─ <section>/core ← top-level memory per section (e.g. frontend/core, backend/core)
|
||||||
|
└─ <topic> ← focused memories (e.g. frontend/handling-errors-and-debugging)
|
||||||
|
└─ ... ← deeper memories as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
When working on a task:
|
||||||
|
1. Read `critical-info` to identify which sections are affected.
|
||||||
|
2. Read the affected section's `core` memory for an overview.
|
||||||
|
3. Follow `mem:` references in the core memory to focused memories relevant to your task.
|
||||||
|
4. Continue following references deeper as needed.
|
||||||
|
|
||||||
|
## Accessing memories
|
||||||
|
|
||||||
|
- **If `serena_read_memory` / `serena_list_memories` tools are available**: use them.
|
||||||
|
`serena_read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`).
|
||||||
|
- **If tools are NOT available**: read the filesystem directly.
|
||||||
|
Memory name `mem:foo/bar` maps to file `.serena/memories/foo/bar.md`.
|
||||||
|
|
||||||
|
## Cross-reference convention
|
||||||
|
|
||||||
|
Memories reference other memories with `mem:<section>/<name>` inside backticks.
|
||||||
|
Example: `mem:common/changes-architecture`.
|
||||||
|
When you encounter a `mem:` reference relevant to your task, read that memory next.
|
||||||
|
|
||||||
|
## Topic/folder organization
|
||||||
|
|
||||||
|
Memories are grouped into folders that mirror project modules or topics:
|
||||||
|
`backend/`, `common/`, `frontend/`, `render-wasm/`, `exporter/`, `workflow/`, etc.
|
||||||
|
Each folder's top-level memory is `<folder>/core`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role: Senior Software Engineer
|
||||||
|
|
||||||
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
|
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
|
||||||
permission to navigate the codebase, modify files, and execute commands to
|
permission to navigate the codebase, modify files, and execute commands to
|
||||||
fulfill your tasks. Your goal is to solve complex technical tasks with high
|
fulfill your tasks. Your goal is to solve complex technical tasks with high
|
||||||
precision while maintaining a strong focus on maintainability and performance.
|
precision while maintaining a strong focus on maintainability and performance.
|
||||||
|
|
||||||
### Operational Guidelines
|
## Operational Guidelines
|
||||||
|
|
||||||
1. Before writing code, describe your plan. If the task is complex, break it
|
1. Before writing code, describe your plan. If the task is complex, break it
|
||||||
down into atomic steps.
|
down into atomic steps.
|
||||||
2. Be concise and autonomous.
|
2. Be concise and autonomous.
|
||||||
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
||||||
4. Commit only when explicitly asked. Follow the commit format rules in
|
|
||||||
`CONTRIBUTING.md`.
|
|
||||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
|
||||||
`.gitignore` by default.
|
|
||||||
|
|
||||||
## GitHub Operations
|
|
||||||
|
|
||||||
To obtain the list of repository members/collaborators:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
|
|
||||||
```
|
|
||||||
|
|
||||||
To obtain the list of open PRs authored by members:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
|
||||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
|
||||||
($members | split("|")) as $m |
|
|
||||||
.[] | select(.author.login as $a | $m | index($a)) |
|
|
||||||
"\(.number)\t\(.author.login)\t\(.title)"
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
To obtain the list of open PRs from external contributors (non-members):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
|
|
||||||
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
|
|
||||||
($members | split("|")) as $m |
|
|
||||||
.[] | select(.author.login as $a | $m | index($a) | not) |
|
|
||||||
"\(.number)\t\(.author.login)\t\(.title)"
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Penpot is an open-source design tool composed of several modules:
|
|
||||||
|
|
||||||
| Directory | Language | Purpose | Has `AGENTS.md` |
|
|
||||||
|-----------|----------|---------|:----------------:|
|
|
||||||
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes |
|
|
||||||
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes |
|
|
||||||
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes |
|
|
||||||
| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes |
|
|
||||||
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No |
|
|
||||||
| `mcp/` | TypeScript | Model Context Protocol integration | No |
|
|
||||||
| `plugins/` | TypeScript | Plugin runtime and example plugins | No |
|
|
||||||
|
|
||||||
Some submodules use `pnpm` workspaces. The root `package.json` and
|
|
||||||
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
|
|
||||||
|
|
||||||
### Module Dependency Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend ──> common
|
|
||||||
backend ──> common
|
|
||||||
exporter ──> common
|
|
||||||
frontend ──> render-wasm (loads compiled WASM)
|
|
||||||
```
|
|
||||||
|
|
||||||
`common` is referenced as a local dependency (`{:local/root "../common"}`) by
|
|
||||||
both `frontend` and `backend`. Changes to `common` can therefore affect multiple
|
|
||||||
modules — test across consumers when modifying shared code.
|
|
||||||
|
|||||||
@ -1,262 +0,0 @@
|
|||||||
# Penpot Backend – Agent Instructions
|
|
||||||
|
|
||||||
Clojure backend (RPC) service running on the JVM.
|
|
||||||
|
|
||||||
Uses Integrant for dependency injection, PostgreSQL for storage, and
|
|
||||||
Redis for messaging/caching.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
To ensure consistency across the Penpot JVM stack, all contributions must adhere
|
|
||||||
to these criteria.
|
|
||||||
|
|
||||||
IMPORTANT: all CLI commands should be executed under backend/
|
|
||||||
subdirectory for make them work correctly.
|
|
||||||
|
|
||||||
### 1. Testing & Validation
|
|
||||||
|
|
||||||
* **Coverage:** If code is added or modified in `src/`, corresponding
|
|
||||||
tests in `test/backend_tests/` must be added or updated.
|
|
||||||
|
|
||||||
* **Execution:**
|
|
||||||
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
|
|
||||||
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
|
|
||||||
|
|
||||||
### 2. Code Quality & Formatting
|
|
||||||
|
|
||||||
* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root)
|
|
||||||
* **Formatting:** All the code must pass the formatting check (run `pnpm run
|
|
||||||
check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty"
|
|
||||||
diffs caused by unrelated whitespace changes.
|
|
||||||
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
|
|
||||||
performance-critical paths to avoid reflection overhead.
|
|
||||||
|
|
||||||
## Code Conventions
|
|
||||||
|
|
||||||
### Namespace Overview
|
|
||||||
|
|
||||||
The source is located under `src` directory and this is a general overview of
|
|
||||||
namespaces structure:
|
|
||||||
|
|
||||||
- `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.) (not to be confused with `app.common.logging`)
|
|
||||||
|
|
||||||
### RPC
|
|
||||||
|
|
||||||
The RPC methods are implemented using a multimethod-like structure via the
|
|
||||||
`app.util.services` namespace. The main RPC methods are collected under
|
|
||||||
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
|
|
||||||
|
|
||||||
The RPC method accepts POST and GET requests indistinctly and uses the `Accept`
|
|
||||||
header to negotiate the response encoding (which can be Transit — the default —
|
|
||||||
or plain JSON). It also accepts Transit (default) or JSON as input, which should
|
|
||||||
be indicated using the `Content-Type` header.
|
|
||||||
|
|
||||||
The main convention is: use `get-` prefix on RPC name when we want READ
|
|
||||||
operation.
|
|
||||||
|
|
||||||
Example of RPC method definition:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(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)})
|
|
||||||
```
|
|
||||||
|
|
||||||
Look under `src/app/rpc/commands/*.clj` to see more examples.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
|
|
||||||
|
|
||||||
|
|
||||||
### Integrant System
|
|
||||||
|
|
||||||
The `src/app/main.clj` declares the system map. Each key is a component; values
|
|
||||||
are config maps with `::ig/ref` for dependencies. Components implement
|
|
||||||
`ig/init-key` / `ig/halt-key!`.
|
|
||||||
|
|
||||||
|
|
||||||
### Connecting to the Database
|
|
||||||
|
|
||||||
Two PostgreSQL databases are used in this environment:
|
|
||||||
|
|
||||||
| Database | Purpose | Connection string |
|
|
||||||
|---------------|--------------------|----------------------------------------------------|
|
|
||||||
| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` |
|
|
||||||
| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` |
|
|
||||||
|
|
||||||
**Interactive psql session:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# development DB
|
|
||||||
psql "postgresql://penpot:penpot@postgres/penpot"
|
|
||||||
|
|
||||||
# test DB
|
|
||||||
psql "postgresql://penpot:penpot@postgres/penpot_test"
|
|
||||||
```
|
|
||||||
|
|
||||||
**One-shot query (non-interactive):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Useful psql meta-commands:**
|
|
||||||
|
|
||||||
```
|
|
||||||
\dt -- list all tables
|
|
||||||
\d <table> -- describe a table (columns, types, constraints)
|
|
||||||
\di -- list indexes
|
|
||||||
\q -- quit
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Migrations table:** Applied migrations are tracked in the `migrations` table
|
|
||||||
> with columns `module`, `step`, and `created_at`. When renaming a migration
|
|
||||||
> logical name, update this table in both databases to match the new name;
|
|
||||||
> otherwise the runner will attempt to re-apply the migration on next startup.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example: fix a renamed migration entry in the test DB
|
|
||||||
psql "postgresql://penpot:penpot@postgres/penpot_test" \
|
|
||||||
-c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Access (Clojure)
|
|
||||||
|
|
||||||
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
;; Query helpers
|
|
||||||
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
|
|
||||||
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
|
|
||||||
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
|
|
||||||
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
|
|
||||||
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
|
|
||||||
(db/delete! cfg-or-pool :table {:id id}) ; delete
|
|
||||||
|
|
||||||
;; Run multiple statements/queries on single connection
|
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
||||||
(db/insert! conn :table row1)
|
|
||||||
(db/insert! conn :table row2))
|
|
||||||
|
|
||||||
|
|
||||||
;; Transactions
|
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
|
||||||
(db/insert! conn :table row)))
|
|
||||||
```
|
|
||||||
|
|
||||||
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
|
|
||||||
`cfg` as params.
|
|
||||||
|
|
||||||
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
|
||||||
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
The exception helpers are defined on Common module, and are available under
|
|
||||||
`app.common.exceptions` namespace.
|
|
||||||
|
|
||||||
Example of raising an exception:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(ex/raise :type :not-found
|
|
||||||
:code :object-not-found
|
|
||||||
:hint "File does not exist"
|
|
||||||
:file-id id)
|
|
||||||
```
|
|
||||||
|
|
||||||
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
|
|
||||||
|
|
||||||
|
|
||||||
### Performance Macros (`app.common.data.macros`)
|
|
||||||
|
|
||||||
Always prefer these macros over their `clojure.core` equivalents — they provide
|
|
||||||
optimized implementations:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(dm/select-keys m [:a :b]) ;; faster than core/select-keys
|
|
||||||
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
|
||||||
(dm/str "a" "b" "c") ;; string concatenation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
|
|
||||||
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
|
|
||||||
:enable-smtp)`.
|
|
||||||
|
|
||||||
|
|
||||||
### Background Tasks
|
|
||||||
|
|
||||||
Background tasks live in `src/app/tasks/`. Each task is an Integrant component
|
|
||||||
that exposes a `::handler` key and follows this three-method pattern:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(defmethod ig/assert-key ::handler ;; validate config at startup
|
|
||||||
[_ params]
|
|
||||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
|
||||||
|
|
||||||
(defmethod ig/expand-key ::handler ;; inject defaults before init
|
|
||||||
[k v]
|
|
||||||
{k (assoc v ::my-option default-value)})
|
|
||||||
|
|
||||||
(defmethod ig/init-key ::handler ;; return the task fn
|
|
||||||
[_ cfg]
|
|
||||||
(fn [_task] ;; receives the task row from the worker
|
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
|
||||||
;; … do work …
|
|
||||||
))))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wiring a new task** requires two changes in `src/app/main.clj`:
|
|
||||||
|
|
||||||
1. **Handler config** – add an entry in `system-config` with the dependencies:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
:app.tasks.my-task/handler
|
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Registry + cron** – register the handler name and schedule it:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
;; in ::wrk/registry ::wrk/tasks map:
|
|
||||||
:my-task (ig/ref :app.tasks.my-task/handler)
|
|
||||||
|
|
||||||
;; in worker-config ::wrk/cron ::wrk/entries vector:
|
|
||||||
{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight
|
|
||||||
:task :my-task}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow):
|
|
||||||
|
|
||||||
| Expression | Meaning |
|
|
||||||
|------------------------------|--------------------|
|
|
||||||
| `"0 0 0 * * ?"` | Daily at midnight |
|
|
||||||
| `"0 0 */6 * * ?"` | Every 6 hours |
|
|
||||||
| `"0 */5 * * * ?"` | Every 5 minutes |
|
|
||||||
|
|
||||||
**Time helpers** (`app.common.time`):
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(ct/now) ;; current instant
|
|
||||||
(ct/duration {:hours 1}) ;; java.time.Duration
|
|
||||||
(ct/minus (ct/now) some-duration) ;; subtract duration from instant
|
|
||||||
```
|
|
||||||
|
|
||||||
`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL
|
|
||||||
interval object suitable for use in SQL queries:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds"
|
|
||||||
```
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# Penpot Common – Agent Instructions
|
|
||||||
|
|
||||||
A shared module with code written in Clojure, ClojureScript, and
|
|
||||||
JavaScript. Contains multiplatform code that can be used and executed
|
|
||||||
from the frontend, backend, or exporter modules. It uses Clojure reader
|
|
||||||
conditionals to specify platform-specific implementations.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
To ensure consistency across the Penpot stack, all contributions must adhere to
|
|
||||||
these criteria:
|
|
||||||
|
|
||||||
### 1. Testing & Validation
|
|
||||||
|
|
||||||
If code is added or modified in `src/`, corresponding tests in
|
|
||||||
`test/common_tests/` must be added or updated.
|
|
||||||
|
|
||||||
* **Environment:** Tests should run in both JS (Node.js) and JVM environments.
|
|
||||||
* **Location:** Place tests in the `test/common_tests/` directory, following the
|
|
||||||
namespace structure of the source code (e.g., `app.common.colors` ->
|
|
||||||
`common-tests.colors-test`).
|
|
||||||
* **Execution:** Tests should be executed on both JS (Node.js) and JVM environments:
|
|
||||||
* **Isolated:**
|
|
||||||
* JS: To run a focused ClojureScript unit test: edit the
|
|
||||||
`test/common_tests/runner.cljs` to narrow the test suite, then
|
|
||||||
`pnpm run test:js`.
|
|
||||||
* JVM: `pnpm run test:jvm --focus common-tests.my-ns-test`
|
|
||||||
* **Regression:**
|
|
||||||
* JS: Run `pnpm run test:js` without modifications on the runner (preferred)
|
|
||||||
* JVM: Run `pnpm run test:jvm`
|
|
||||||
|
|
||||||
### 2. Code Quality & Formatting
|
|
||||||
|
|
||||||
* **Linting:** All code changes must pass linter checks:
|
|
||||||
* Run `pnpm run lint:clj` for CLJ/CLJS/CLJC
|
|
||||||
* **Formatting:** All code changes must pass the formatting check
|
|
||||||
* Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC
|
|
||||||
* Run `pnpm run check-fmt:js` for JS
|
|
||||||
* Use `pnpm run fmt` to fix all formatting issues (`pnpm run
|
|
||||||
fmt:clj` or `pnpm run fmt:js` for isolated formatting fix).
|
|
||||||
|
|
||||||
## Code Conventions
|
|
||||||
|
|
||||||
### Namespace Overview
|
|
||||||
|
|
||||||
The source is located under `src` directory and this is a general overview of
|
|
||||||
namespaces structure:
|
|
||||||
|
|
||||||
- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas
|
|
||||||
- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli
|
|
||||||
- `app.common.geom.*` – Geometry and shape transformation helpers
|
|
||||||
- `app.common.data` – Generic helpers used across the entire application
|
|
||||||
- `app.common.math` – Generic math helpers used across the entire application
|
|
||||||
- `app.common.json` – Generic JSON encoding/decoding helpers
|
|
||||||
- `app.common.data.macros` – Performance macros used everywhere
|
|
||||||
|
|
||||||
|
|
||||||
### Reader Conditionals
|
|
||||||
|
|
||||||
We use reader conditionals to differentiate implementations depending on the
|
|
||||||
target platform where the code runs:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
#?(: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"}`).
|
|
||||||
|
|
||||||
@ -1,371 +0,0 @@
|
|||||||
# Penpot Frontend – Agent Instructions
|
|
||||||
|
|
||||||
ClojureScript-based frontend application that uses React and RxJS as its main
|
|
||||||
architectural pieces.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
### 1. Testing & Validation
|
|
||||||
|
|
||||||
#### Unit Tests
|
|
||||||
|
|
||||||
If code is added or modified in `src/`, corresponding tests in
|
|
||||||
`test/frontend_tests/` must be added or updated.
|
|
||||||
|
|
||||||
* **Environment:** Tests should run in a Node.js or browser-isolated
|
|
||||||
environment without requiring the full application state or a
|
|
||||||
running backend. Test are developed using cljs.test.
|
|
||||||
* **Mocks & Stubs:** * Use proper mocks for any side-effecting
|
|
||||||
functions (e.g., API calls, storage access).
|
|
||||||
* Avoid testing through the UI (DOM); we have e2e tests for that.
|
|
||||||
* Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test.
|
|
||||||
* **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real
|
|
||||||
network calls. Use synchronous mocks for asynchronous workflows where
|
|
||||||
possible.
|
|
||||||
* **Location:** Place tests in the `test/frontend_tests/` directory, following the
|
|
||||||
namespace structure of the source code (e.g., `app.utils.timers` ->
|
|
||||||
`frontend-tests.util-timers-test`).
|
|
||||||
* **Execution:**
|
|
||||||
* **Isolated:** To run a focused ClojureScript unit test: edit the
|
|
||||||
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run
|
|
||||||
test`.
|
|
||||||
* **Regression:** To run `pnpm run test` without modifications on the runner (preferred)
|
|
||||||
|
|
||||||
|
|
||||||
#### Integration Tests (Playwright)
|
|
||||||
|
|
||||||
Integration tests are developed under `frontend/playwright` directory, we use
|
|
||||||
mocks for remote communication with the backend.
|
|
||||||
|
|
||||||
You should not add, modify or run the integration tests unless explicitly asked.
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm run test:e2e # Playwright e2e tests
|
|
||||||
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure everything is installed before executing tests with the `./scripts/setup` script.
|
|
||||||
|
|
||||||
|
|
||||||
### 2. Code Quality & Formatting
|
|
||||||
|
|
||||||
* **Linting:** All code changes must pass linter checks:
|
|
||||||
* Run `pnpm run lint:clj` for CLJ/CLJS/CLJC
|
|
||||||
* Run `pnpm run lint:js` for JS
|
|
||||||
* Run `pnpm run lint:scss` for SCSS
|
|
||||||
* **Formatting:** All code changes must pass the formatting check
|
|
||||||
* Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC
|
|
||||||
* Run `pnpm run check-fmt:js` for JS
|
|
||||||
* Run `pnpm run check-fmt:scss` for SCSS
|
|
||||||
* Use the `pnpm run fmt` fix all the formatting issues (`pnpm run fmt:clj`,
|
|
||||||
`pnpm run fmt:js` or `pnpm run fmt:scss` for isolated formatting fix)
|
|
||||||
|
|
||||||
### 3. Implementation Rules
|
|
||||||
|
|
||||||
* **Logic vs. View:** If logic is embedded in a UI component, extract it into a
|
|
||||||
function in the same namespace if it is only used locally, or look for a helper
|
|
||||||
namespace to make it unit-testable.
|
|
||||||
|
|
||||||
### 4. Stack Trace Analysis
|
|
||||||
|
|
||||||
When analyzing production stack traces (minified code), you can generate a
|
|
||||||
production bundle locally to map the minified code back to the source.
|
|
||||||
|
|
||||||
**To build the production bundle:**
|
|
||||||
|
|
||||||
Run: `pnpm run build:app`
|
|
||||||
|
|
||||||
The compiled files and their corresponding source maps will be generated in
|
|
||||||
`resources/public/js`.
|
|
||||||
|
|
||||||
**Analysis Tips:**
|
|
||||||
|
|
||||||
- **Source Maps:** Use the `.map` files generated in `resources/public/js` with
|
|
||||||
tools like `source-map-lookup` or browser dev tools to resolve minified
|
|
||||||
locations.
|
|
||||||
- **Bundle Inspection:** If the issue is related to bundle size or unexpected
|
|
||||||
code inclusion, inspect the generated modules in `resources/public/js`.
|
|
||||||
- **Shadow-CLJS Reports:** For more detailed analysis of what is included in the
|
|
||||||
bundle, you can run shadow-cljs build reports (consult `shadow-cljs.edn` for
|
|
||||||
build IDs like `main` or `worker`).
|
|
||||||
|
|
||||||
|
|
||||||
## Code Conventions
|
|
||||||
|
|
||||||
### Namespace Overview
|
|
||||||
|
|
||||||
The source is located under `src` directory and this is a general overview of
|
|
||||||
namespaces structure:
|
|
||||||
|
|
||||||
- `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)
|
|
||||||
|
|
||||||
|
|
||||||
### State Management (Potok)
|
|
||||||
|
|
||||||
State is a single atom managed by a Potok store. Events implement protocols
|
|
||||||
(funcool/potok library):
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(defn my-event
|
|
||||||
"doc string"
|
|
||||||
[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)
|
|
||||||
(dom/focus (dom/get-element "id")))))
|
|
||||||
```
|
|
||||||
|
|
||||||
The state is located under `app.main.store` namespace where we have
|
|
||||||
the `emit!` function responsible for emitting events.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```cljs
|
|
||||||
(ns some.ns
|
|
||||||
(:require
|
|
||||||
[app.main.data.my-events :refer [my-event]]
|
|
||||||
[app.main.store :as st]))
|
|
||||||
|
|
||||||
(defn on-click
|
|
||||||
[event]
|
|
||||||
(st/emit! (my-event)))
|
|
||||||
```
|
|
||||||
|
|
||||||
On `app.main.refs` we have reactive references which look up the main state
|
|
||||||
for inner data or precalculated data. These references are very useful but
|
|
||||||
should be used with care because, for example, if we have a complex operation,
|
|
||||||
this operation will be executed on each state change. Sometimes it is better to
|
|
||||||
have simple references and use React `use-memo` for more granular memoization.
|
|
||||||
|
|
||||||
Prefer helpers from `app.util.dom` instead of using direct DOM calls. If no
|
|
||||||
helper is available, prefer adding a new helper and then using it.
|
|
||||||
|
|
||||||
### UI Components (React & Rumext: mf/defc)
|
|
||||||
|
|
||||||
The codebase contains various component patterns. When creating or refactoring
|
|
||||||
components, follow the Modern Syntax rules outlined below.
|
|
||||||
|
|
||||||
#### 1. The * Suffix Convention
|
|
||||||
|
|
||||||
The most recent syntax uses a * suffix in the component name (e.g.,
|
|
||||||
my-component*). This suffix signals the mf/defc macro to apply specific rules
|
|
||||||
for props handling and destructuring and optimization.
|
|
||||||
|
|
||||||
#### 2. Component Definition
|
|
||||||
|
|
||||||
Modern components should use the following structure:
|
|
||||||
|
|
||||||
```clj
|
|
||||||
(mf/defc my-component*
|
|
||||||
{::mf/wrap [mf/memo]} ;; Equivalent to React.memo
|
|
||||||
[{:keys [name on-click]}] ;; Destructured props
|
|
||||||
[:div {:class (stl/css :root)
|
|
||||||
:on-click on-click}
|
|
||||||
name])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Hooks
|
|
||||||
|
|
||||||
Use the mf namespace for hooks to maintain consistency with the macro's
|
|
||||||
lifecycle management. These are analogous to standard React hooks:
|
|
||||||
|
|
||||||
```clj
|
|
||||||
(mf/use-state) ;; analogous to React.useState adapted to cljs semantics
|
|
||||||
(mf/use-effect) ;; analogous to React.useEffect
|
|
||||||
(mf/use-memo) ;; analogous to React.useMemo
|
|
||||||
(mf/use-fn) ;; analogous to React.useCallback
|
|
||||||
```
|
|
||||||
|
|
||||||
The `mf/use-state` in difference with React.useState, returns an atom-like
|
|
||||||
object, where you can use `swap!` or `reset!` to perform an update and
|
|
||||||
`deref` to get the current value.
|
|
||||||
|
|
||||||
You also have the `mf/deref` hook (which does not follow the `use-` naming
|
|
||||||
pattern) and its purpose is to watch (subscribe to changes on) an atom or
|
|
||||||
derived atom (from okulary) and get the current value. It is mainly used to
|
|
||||||
subscribe to lenses defined in `app.main.refs` or private lenses defined in
|
|
||||||
namespaces.
|
|
||||||
|
|
||||||
Rumext also comes with improved syntax macros as alternative to `mf/use-effect`
|
|
||||||
and `mf/use-memo` functions. Examples:
|
|
||||||
|
|
||||||
|
|
||||||
Example for `mf/with-effect` macro:
|
|
||||||
|
|
||||||
```clj
|
|
||||||
;; Using functions
|
|
||||||
(mf/use-effect
|
|
||||||
(mf/deps team-id)
|
|
||||||
(fn []
|
|
||||||
(st/emit! (dd/initialize team-id))
|
|
||||||
(fn []
|
|
||||||
(st/emit! (dd/finalize team-id)))))
|
|
||||||
|
|
||||||
;; The same effect but using mf/with-effect
|
|
||||||
(mf/with-effect [team-id]
|
|
||||||
(st/emit! (dd/initialize team-id))
|
|
||||||
(fn []
|
|
||||||
(st/emit! (dd/finalize team-id))))
|
|
||||||
```
|
|
||||||
|
|
||||||
Example for `mf/with-memo` macro:
|
|
||||||
|
|
||||||
```
|
|
||||||
;; Using functions
|
|
||||||
(mf/use-memo
|
|
||||||
(mf/deps projects team-id)
|
|
||||||
(fn []
|
|
||||||
(->> (vals projects)
|
|
||||||
(filterv #(= team-id (:team-id %))))))
|
|
||||||
|
|
||||||
;; Using the macro
|
|
||||||
(mf/with-memo [projects team-id]
|
|
||||||
(->> (vals projects)
|
|
||||||
(filterv #(= team-id (:team-id %)))))
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer using the macros for their syntax simplicity.
|
|
||||||
|
|
||||||
|
|
||||||
#### 4. Component Usage (Hiccup Syntax)
|
|
||||||
|
|
||||||
When invoking a component within Hiccup, always use the [:> component* props]
|
|
||||||
pattern.
|
|
||||||
|
|
||||||
Requirements for props:
|
|
||||||
|
|
||||||
- Must be a map literal or a symbol pointing to a JavaScript props object.
|
|
||||||
- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```clj
|
|
||||||
;; Using object literal (no need of #js because macro already interprets it)
|
|
||||||
[:> my-component* {:data-foo "bar"}]
|
|
||||||
|
|
||||||
;; Using object literal (no need of #js because macro already interprets it)
|
|
||||||
(let [props #js {:data-foo "bar"
|
|
||||||
:className "myclass"}]
|
|
||||||
[:> my-component* props])
|
|
||||||
|
|
||||||
;; Using the spread helper
|
|
||||||
(let [props (mf/spread-object base-props {:extra "data"})]
|
|
||||||
[:> my-component* props])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Styles
|
|
||||||
|
|
||||||
##### Styles on component code
|
|
||||||
Styles are co-located with components. Each `.cljs` file has a corresponding
|
|
||||||
`.scss` file.
|
|
||||||
|
|
||||||
Example of clojurescript code for reference classes defined on styles (we use
|
|
||||||
CSS modules pattern):
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
;; 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))]}]
|
|
||||||
```
|
|
||||||
|
|
||||||
##### General rules for styling
|
|
||||||
|
|
||||||
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
|
|
||||||
variables and get the already defined properties from `_sizes.scss`. The SCSS
|
|
||||||
variables are allowed and still used, just prefer properties if they are
|
|
||||||
already defined.
|
|
||||||
- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss"
|
|
||||||
as *; padding: px2rem(23);`.
|
|
||||||
- Do **not** create new SCSS variables for one-off values.
|
|
||||||
- Use physical directions with logical ones to support RTL/LTR naturally:
|
|
||||||
- Avoid: `margin-left`, `padding-right`, `left`, `right`.
|
|
||||||
- Prefer: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
|
|
||||||
- Always use the `use-typography` mixin from `ds/typography.scss`:
|
|
||||||
- Example: `@include t.use-typography("title-small");`
|
|
||||||
- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`.
|
|
||||||
- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or
|
|
||||||
legacy color variables.
|
|
||||||
- Use mixins only from `ds/mixins.scss`. Avoid legacy mixins like
|
|
||||||
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
|
|
||||||
- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file,
|
|
||||||
try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as
|
|
||||||
*;` (Use `as *` to expose variables directly).
|
|
||||||
- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors:
|
|
||||||
- Avoid: `.card { .title { ... } }`
|
|
||||||
- Prefer: `.card-title { ... }`
|
|
||||||
- Leverage component-level CSS variables for state changes (hover/focus) instead
|
|
||||||
of rewriting properties.
|
|
||||||
|
|
||||||
##### Checklist
|
|
||||||
|
|
||||||
- [ ] No references to `common/refactor/`
|
|
||||||
- [ ] All `@import` converted to `@use` (only if refactoring)
|
|
||||||
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
|
|
||||||
- [ ] Typography implemented via `use-typography()` mixin.
|
|
||||||
- [ ] Hardcoded pixel values wrapped in `px2rem()`.
|
|
||||||
- [ ] Selectors are flat (no deep nesting).
|
|
||||||
|
|
||||||
|
|
||||||
### Translations (`tr`) and Memoization
|
|
||||||
|
|
||||||
`(tr "some.key")` resolves the translation string from the **currently active
|
|
||||||
locale at call time**. This has two consequences:
|
|
||||||
|
|
||||||
- **Never call `(tr ...)` at namespace level** (inside a `def` or `defonce`).
|
|
||||||
Doing so would freeze the label to the locale active at module load time and
|
|
||||||
break runtime language switching.
|
|
||||||
- **Always call `(tr ...)` at render time** — either directly in the component
|
|
||||||
body or inside a `mf/with-memo` / `mf/use-memo` block.
|
|
||||||
|
|
||||||
When a component renders a **static list of options** whose labels come from
|
|
||||||
`(tr ...)` (e.g. radio button options, select options), wrap the vector in
|
|
||||||
`mf/with-memo []` with no dependencies. This ensures the vector and its
|
|
||||||
`(tr ...)` calls are evaluated once per component mount instead of on every
|
|
||||||
render, while still respecting the render-time requirement:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(let [options (mf/with-memo []
|
|
||||||
[{:value "top" :label (tr "some.key.top")}
|
|
||||||
{:value "center" :label (tr "some.key.center")}
|
|
||||||
{:value "bottom" :label (tr "some.key.bottom")}])]
|
|
||||||
...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Macros (`app.common.data.macros`)
|
|
||||||
|
|
||||||
Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript:
|
|
||||||
|
|
||||||
```clojure
|
|
||||||
(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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
`src/app/config.clj` reads globally defined variables and exposes precomputed
|
|
||||||
configuration values ready to be used from other parts of the application.
|
|
||||||
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# render-wasm – Agent Instructions
|
|
||||||
|
|
||||||
This component compiles Rust to WebAssembly using Emscripten +
|
|
||||||
Skia. It is consumed by the frontend as a canvas renderer.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build # Compile Rust → WASM (requires Emscripten environment)
|
|
||||||
./watch # Incremental rebuild on file change
|
|
||||||
./test # Run Rust unit tests (cargo test)
|
|
||||||
./lint # clippy -D warnings
|
|
||||||
cargo fmt --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Run a single test:
|
|
||||||
```bash
|
|
||||||
cargo test my_test_name # by test function name
|
|
||||||
cargo test shapes:: # by module prefix
|
|
||||||
```
|
|
||||||
|
|
||||||
Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server).
|
|
||||||
|
|
||||||
## Build Environment
|
|
||||||
|
|
||||||
The `_build_env` script sets required env vars (Emscripten paths,
|
|
||||||
`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is
|
|
||||||
configured to 256 MB initial with geometric growth.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**Global state** — a single `unsafe static mut State` accessed
|
|
||||||
exclusively through `with_state!` / `with_state_mut!` macros. Never
|
|
||||||
access it directly.
|
|
||||||
|
|
||||||
**Tile-based rendering** — only 512×512 tiles within the viewport
|
|
||||||
(plus a pre-render buffer) are drawn each frame. Tiles outside the
|
|
||||||
range are skipped.
|
|
||||||
|
|
||||||
**Two-phase updates** — shape data is written via exported setter
|
|
||||||
functions (called from ClojureScript), then a single `render_frame()`
|
|
||||||
triggers the actual Skia draw calls.
|
|
||||||
|
|
||||||
**Shape hierarchy** — shapes live in a flat pool indexed by UUID;
|
|
||||||
parent/child relationships are tracked separately.
|
|
||||||
|
|
||||||
## Key Source Modules
|
|
||||||
|
|
||||||
| Path | Role |
|
|
||||||
|------|------|
|
|
||||||
| `src/lib.rs` | WASM exports — all functions callable from JS |
|
|
||||||
| `src/state.rs` | Global `State` struct definition |
|
|
||||||
| `src/render/` | Tile rendering pipeline, Skia surface management |
|
|
||||||
| `src/shapes/` | Shape types and Skia draw logic per shape |
|
|
||||||
| `src/wasm/` | JS interop helpers (memory, string encoding) |
|
|
||||||
|
|
||||||
## Frontend Integration
|
|
||||||
|
|
||||||
The WASM module is loaded by `app.render-wasm.*` namespaces in the
|
|
||||||
frontend. ClojureScript calls exported Rust functions to push shape
|
|
||||||
data, then calls `render_frame`. Do not change export function
|
|
||||||
signatures without updating the corresponding ClojureScript bridge.
|
|
||||||
Loading…
x
Reference in New Issue
Block a user