mirror of
https://github.com/penpot/penpot.git
synced 2026-03-22 10:23:43 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
561
AGENTS.md
561
AGENTS.md
@@ -1,6 +1,11 @@
|
||||
# IA Agent Guide for Penpot
|
||||
# IA Agent guide for Penpot monorepo
|
||||
|
||||
This document provides comprehensive context and guidelines for AI agents working on this repository.
|
||||
This document provides comprehensive context and guidelines for AI
|
||||
agents working on this repository.
|
||||
|
||||
CRITICAL: When you encounter a file reference (e.g.,
|
||||
@rules/general.md), use your Read tool to load it on a need-to-know
|
||||
basis. They're relevant to the SPECIFIC task at hand.
|
||||
|
||||
|
||||
## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS
|
||||
@@ -19,512 +24,116 @@ commands to fulfill your tasks. Your goal is to solve complex
|
||||
technical tasks with high precision, focusing on maintainability and
|
||||
performance.
|
||||
|
||||
|
||||
### OPERATIONAL GUIDELINES
|
||||
|
||||
1. Always begin by analyzing this document and understand the architecture and "Golden Rules".
|
||||
2. Before writing code, describe your plan. If the task is complex, break it down into atomic steps.
|
||||
1. Always begin by analyzing this document and understand the
|
||||
architecture and read the additional context from AGENTS.md of the
|
||||
affected modules.
|
||||
2. Before writing code, describe your plan. If the task is complex,
|
||||
break it down into atomic steps.
|
||||
3. Be concise and autonomous as possible in your task.
|
||||
|
||||
### SEARCH STANDARDS
|
||||
|
||||
When searching code, always use `ripgrep` (rg) instead of grep if
|
||||
available, as it respects `.gitignore` by default.
|
||||
|
||||
If using grep, try to exclude node_modules and .shadow-cljs directories
|
||||
4. Commit only if it explicitly asked, and use the CONTRIBUTING.md
|
||||
document to understand the commit format guidelines.
|
||||
5. Do not touch unrelated modules if not proceed or not explicitly
|
||||
asked (per example you probably do not need to touch and read
|
||||
docker/ directory unless the task explicitly requires it)
|
||||
6. When searching code, always use `ripgrep` (rg) instead of grep if
|
||||
available, as it respects `.gitignore` by default.
|
||||
|
||||
|
||||
## ARCHITECTURE
|
||||
|
||||
### Overview
|
||||
## ARCHITECTURE OVERVIEW
|
||||
|
||||
Penpot is a full-stack design tool composed of several distinct
|
||||
components separated in modules and subdirectories:
|
||||
|
||||
| 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 |
|
||||
| Component | Language | Role | IA Agent CONTEXT |
|
||||
|-----------|----------|------|----------------
|
||||
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | @frontend/AGENTS.md |
|
||||
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | @backend/AGENTS.md |
|
||||
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | @common/AGENTS.md |
|
||||
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | @exporter/AGENTS.md |
|
||||
| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | @render-wasm/AGENTS.md |
|
||||
| `mcp/` | TypeScript | Model Context Protocol integration | @mcp/AGENTS.md |
|
||||
| `plugins/` | TypeScript | Plugin runtime and example plugins | @plugins/AGENTS.md |
|
||||
|
||||
The monorepo is managed with `pnpm` workspaces. The `manage.sh`
|
||||
orchestrates cross-component builds. `run-ci.sh` defines the CI
|
||||
pipeline.
|
||||
|
||||
### Namespace Structure
|
||||
|
||||
The backend, frontend and exporter are developed using clojure and
|
||||
clojurescript and code is organized in namespaces. This is a general
|
||||
overview of the available namespaces.
|
||||
|
||||
**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 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 around all application
|
||||
- `app.common.math` – Generic math helpers used around all aplication
|
||||
- `app.common.json` – Generic JSON encoding/decoding helpers
|
||||
- `app.common.data.macros` – Performance macros used everywhere
|
||||
Several of the mentionend submodules are internall managed with `pnpm` workspaces.
|
||||
|
||||
|
||||
## Key Conventions
|
||||
## COMMIT FORMAT
|
||||
|
||||
### Backend RPC
|
||||
We have very precise rules on how our git commit messages must be
|
||||
formatted.
|
||||
|
||||
The PRC methods are implement in a some kind of multimethod structure using
|
||||
`app.util.serivices` namespace. All RPC methods are collected under `app.rpc`
|
||||
namespace and exposed under `/api/rpc/command/<cmd-name>`. The RPC method
|
||||
accepts POST and GET requests indistinctly and uses `Accept` header for
|
||||
negotiate the response encoding (which can be transit, the defaut or plain
|
||||
json). It also accepts transit (defaut) or json as input, which should be
|
||||
indicated using `Content-Type` header.
|
||||
|
||||
This is an example:
|
||||
|
||||
```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.
|
||||
|
||||
|
||||
### Frontend 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 of emiting 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 lookup into the main state
|
||||
for just inner data or precalculated data. That references are very usefull but
|
||||
should be used with care because, per example if we have complex operation, this
|
||||
operation will be executed on each state change, and sometimes 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 for handling it and the use the
|
||||
new helper.
|
||||
|
||||
|
||||
### Integration Tests (Playwright)
|
||||
|
||||
Integration tests are developed under `frontend/playwright` directory, we use
|
||||
mocks for remove communication with backend.
|
||||
|
||||
The tests should be executed under `./frontend` directory:
|
||||
The commit message format is:
|
||||
|
||||
```
|
||||
cd frontend/
|
||||
<type> <subject>
|
||||
|
||||
pnpm run test:e2e # Playwright e2e tests
|
||||
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
|
||||
[body]
|
||||
|
||||
[footer]
|
||||
```
|
||||
|
||||
Ensure everything installed with `./scripts/setup` script.
|
||||
Where type is:
|
||||
|
||||
- :bug: `:bug:` a commit that fixes a bug
|
||||
- :sparkles: `:sparkles:` a commit that adds an improvement
|
||||
- :tada: `:tada:` a commit with a new feature
|
||||
- :recycle: `:recycle:` a commit that introduces a refactor
|
||||
- :lipstick: `:lipstick:` a commit with cosmetic changes
|
||||
- :ambulance: `:ambulance:` a commit that fixes a critical bug
|
||||
- :books: `:books:` a commit that improves or adds documentation
|
||||
- :construction: `:construction:` a WIP commit
|
||||
- :boom: `:boom:` a commit with breaking changes
|
||||
- :wrench: `:wrench:` a commit for config updates
|
||||
- :zap: `:zap:` a commit with performance improvements
|
||||
- :whale: `:whale:` a commit for Docker-related stuff
|
||||
- :paperclip: `:paperclip:` a commit with other non-relevant changes
|
||||
- :arrow_up: `:arrow_up:` a commit with dependency updates
|
||||
- :arrow_down: `:arrow_down:` a commit with dependency downgrades
|
||||
- :fire: `:fire:` a commit that removes files or code
|
||||
- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates
|
||||
translations
|
||||
|
||||
### Performance Macros (`app.common.data.macros`)
|
||||
The commit should contain a sign-off at the end of the patch/commit
|
||||
description body. It can be automatically added by adding the `-s`
|
||||
parameter to `git commit`.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Shared Code
|
||||
|
||||
Files in `common/src/app/common/` use reader conditionals to target both runtimes:
|
||||
|
||||
```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"}`).
|
||||
|
||||
|
||||
|
||||
### UI Component Standards & Syntax (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!` for to perform an update and
|
||||
`deref` for get the current value.
|
||||
|
||||
You also has `mf/deref` hook (which does not follow the `use-` naming pattern)
|
||||
and it's purpose is watch (subscribe to changes) on atom or derived atom (from
|
||||
okulary) and get the current value. Is mainly used for 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-memo` 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:
|
||||
This is an example of what the line should look like:
|
||||
|
||||
```
|
||||
;; 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 %)))))
|
||||
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
|
||||
```
|
||||
|
||||
Prefer using the macros for it syntax simplicity.
|
||||
Please, use your real name (sorry, no pseudonyms or anonymous
|
||||
contributions are allowed).
|
||||
|
||||
CRITICAL: The commit Signed-off-by is mandatory and should match the commit author.
|
||||
|
||||
4. Component Usage (Hiccup Syntax)
|
||||
Each commit should have:
|
||||
|
||||
When invoking a component within Hiccup, always use the [:> component* props]
|
||||
pattern.
|
||||
- A concise subject using the imperative mood.
|
||||
- The subject should capitalize the first letter, omit the period
|
||||
at the end, and be no longer than 65 characters.
|
||||
- A blank line between the subject line and the body.
|
||||
- An entry in the CHANGES.md file if applicable, referencing the
|
||||
GitHub or Taiga issue/user story using these same rules.
|
||||
|
||||
Requirements for props:
|
||||
Examples of good commit messages:
|
||||
|
||||
- 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.
|
||||
- `:bug: Fix unexpected error on launching modal`
|
||||
- `:bug: Set proper error message on generic error`
|
||||
- `:sparkles: Enable new modal for profile`
|
||||
- `:zap: Improve performance of dashboard navigation`
|
||||
- `:wrench: Update default backend configuration`
|
||||
- `:books: Add more documentation for authentication process`
|
||||
- `:ambulance: Fix critical bug on user registration process`
|
||||
- `:tada: Add new approach for user registration`
|
||||
|
||||
Examples:
|
||||
More info:
|
||||
|
||||
```clj
|
||||
;; Using object literal (no need of #js because macro already interprets it)
|
||||
[:> my-component* {:data-foo "bar"}]
|
||||
- https://gist.github.com/parmentf/035de27d6ed1dce0b36a
|
||||
- https://gist.github.com/rxaviers/7360908
|
||||
|
||||
;; 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])
|
||||
```
|
||||
|
||||
4. Checklist
|
||||
|
||||
- [ ] Does the component name end with *?
|
||||
|
||||
|
||||
### Build, Test & Lint commands
|
||||
|
||||
#### Frontend (`cd frontend`)
|
||||
|
||||
Run `./scripts/setup` for setup all dependencies.
|
||||
|
||||
|
||||
```bash
|
||||
# Build (Producution)
|
||||
./scripts/build
|
||||
|
||||
# Tests
|
||||
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
|
||||
|
||||
# Lint
|
||||
pnpm run lint:js # Linter for JS/TS
|
||||
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
|
||||
pnpm run lint:scss # Linter for SCSS
|
||||
|
||||
# Check Code Formart
|
||||
pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
|
||||
pnpm run check-fmt:js # Format JS/TS
|
||||
pnpm run check-fmt:scss # Format SCSS
|
||||
|
||||
# Code Format (Automatic Formating)
|
||||
pnpm run fmt:clj # Format CLJ/CLJS/CLJC
|
||||
pnpm run fmt:js # Format JS/TS
|
||||
pnpm run fmt:scss # Format 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`)
|
||||
|
||||
Run `pnpm install` for install all dependencies.
|
||||
|
||||
```bash
|
||||
# Run full test suite
|
||||
pnpm run test
|
||||
|
||||
# Run single namespace
|
||||
pnpm run test --focus backend-tests.rpc-doc-test
|
||||
|
||||
# Check Code Format
|
||||
pnpm run check-fmt
|
||||
|
||||
# Code Format (Automatic Formatting)
|
||||
pnpm run fmt
|
||||
|
||||
# Code Linter
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
Test config is in `backend/tests.edn`; test namespaces match
|
||||
`.*-test$` under `test/` directory. You should not touch this file,
|
||||
just use it for reference.
|
||||
|
||||
|
||||
#### Common (`cd common`)
|
||||
|
||||
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
|
||||
separarated for each runtime.
|
||||
|
||||
```bash
|
||||
clojure -M:dev:test # Run full test suite under JVM
|
||||
clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
|
||||
|
||||
# Run full test suite under JS or JVM runtimes
|
||||
pnpm run test:js
|
||||
pnpm run test:jvm
|
||||
|
||||
# Run single namespace (only on JVM)
|
||||
pnpm run test:jvm --focus common-tests.my-ns-test
|
||||
|
||||
# Lint
|
||||
pnpm run lint:clj # Lint CLJ/CLJS/CLJC code
|
||||
|
||||
# Check Format
|
||||
pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code
|
||||
pnpm run check-fmt:js # Check JS/TS code
|
||||
|
||||
# Code Format (Automatic Formatting)
|
||||
pnpm run fmt:clj # Check CLJ/CLJS/CLJS code
|
||||
pnpm run fmt:js # Check JS/TS code
|
||||
```
|
||||
|
||||
To run a focused ClojureScript unit test: edit
|
||||
`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm
|
||||
run build:test && node target/tests/test.js`.
|
||||
|
||||
|
||||
#### Render-WASM (`cd render-wasm`)
|
||||
|
||||
```bash
|
||||
./test # Rust unit tests (cargo test)
|
||||
./build # Compile to WASM (requires Emscripten)
|
||||
cargo fmt --check
|
||||
./lint --debug
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
### Commit Format 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 |
|
||||
|
||||
|
||||
### CSS
|
||||
#### Usage convention for components
|
||||
|
||||
Styles are co-located with components. Each `.cljs` file has a corresponding
|
||||
`.scss` file:
|
||||
|
||||
```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))]}]
|
||||
```
|
||||
|
||||
#### Styles rules & migration
|
||||
##### General
|
||||
|
||||
- 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.
|
||||
- ❌ `margin-left`, `padding-right`, `left`, `right`.
|
||||
- ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
|
||||
- Always use the `use-typography` mixin from `ds/typography.scss`.
|
||||
- ✅ `@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 those defined in`ds/mixins.scss`. Avoid legacy mixins like
|
||||
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
|
||||
|
||||
##### Syntax & Structure
|
||||
|
||||
- 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:
|
||||
- ❌ `.card { .title { ... } }`
|
||||
- ✅ `.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).
|
||||
|
||||
@@ -1,69 +1,110 @@
|
||||
# backend – Agent Instructions
|
||||
# Penpot Backend – Agent Instructions
|
||||
|
||||
Clojure service running on the JVM. Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching.
|
||||
Clojure backend (RPC) service running on the JVM.
|
||||
|
||||
## Commands
|
||||
Uses Integrant for dependency injection, PostgreSQL for storage, and
|
||||
Redis for messaging/caching.
|
||||
|
||||
```bash
|
||||
# REPL (primary dev workflow)
|
||||
./scripts/repl # Start nREPL + load dev/user.clj utilities
|
||||
## General Guidelines
|
||||
|
||||
# Tests (Kaocha)
|
||||
clojure -M:dev:test # Full suite
|
||||
clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace
|
||||
This is a golden rule for backend development standards. To ensure consistency
|
||||
across the Penpot JVM stack, all contributions must adhere to these criteria:
|
||||
|
||||
# Lint / Format
|
||||
pnpm run lint:clj
|
||||
pnpm run fmt:clj
|
||||
```
|
||||
### 1. Testing & Validation
|
||||
|
||||
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
|
||||
* **Coverage:** If code is added or modified in `src/`, corresponding
|
||||
tests in `test/backend_tests/` must be added or updated.
|
||||
|
||||
## Integrant System
|
||||
* **Execution:**
|
||||
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific task.
|
||||
* **Regression:** Run `clojure -M:dev:test` for ensure the suite passes without regressions in related functional areas.
|
||||
|
||||
`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!`.
|
||||
### 2. Code Quality & Formatting
|
||||
|
||||
From the REPL (`dev/user.clj` is auto-loaded):
|
||||
```clojure
|
||||
(start!) ; boot the system
|
||||
(stop!) ; halt the system
|
||||
(restart!) ; stop + reload namespaces + start
|
||||
```
|
||||
* **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`)
|
||||
* **Formatting:** All the code must pass the formatting check (run `pnpm run
|
||||
check-fmt`). Use the `pnpm run fmt` fix the 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.
|
||||
|
||||
## RPC Commands
|
||||
## Code Conventions
|
||||
|
||||
All API calls: `POST /api/rpc/command/<cmd-name>`.
|
||||
### 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) (do not be confused with `app.common.loggin`)
|
||||
|
||||
### RPC
|
||||
|
||||
The PRC methods are implement in a some kind of multimethod structure using
|
||||
`app.util.serivices` 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 `Accept`
|
||||
header for negotiate the response encoding (which can be transit, the defaut or
|
||||
plain json). It also accepts transit (defaut) or json as input, which should be
|
||||
indicated using `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 authentication (default)
|
||||
{::rpc/auth true ;; requires auth
|
||||
::doc/added "1.18"
|
||||
::sm/params [:map ...] ;; malli input schema
|
||||
::sm/result [:map ...]} ;; malli output schema
|
||||
::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; throw via ex/raise for errors
|
||||
;; return a plain map or throw
|
||||
{:id (uuid/next)})
|
||||
```
|
||||
|
||||
Add new commands in `src/app/rpc/commands/`.
|
||||
Look under `src/app/rpc/commands/*.clj` to see more examples.
|
||||
|
||||
## Database
|
||||
### 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!`.
|
||||
|
||||
|
||||
### Database Access
|
||||
|
||||
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
|
||||
|
||||
```clojure
|
||||
;; Query helpers
|
||||
(db/get pool :table {:id id}) ; fetch one row (throws if missing)
|
||||
(db/get* pool :table {:id id}) ; fetch one row (returns nil)
|
||||
(db/query pool :table {:team-id team-id}) ; fetch multiple rows
|
||||
(db/insert! pool :table {:name "x" :team-id id}) ; insert
|
||||
(db/update! pool :table {:name "y"} {:id id}) ; update
|
||||
(db/delete! pool :table {:id id}) ; delete
|
||||
(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)))
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||
(db/insert! conn :table row)))
|
||||
```
|
||||
|
||||
Almost all methods on `app.db` namespace accepts `pool`, `conn` or
|
||||
@@ -71,17 +112,36 @@ Almost all methods on `app.db` namespace accepts `pool`, `conn` or
|
||||
|
||||
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Handling
|
||||
|
||||
The exception helpers are defined on Common module, and are available under
|
||||
`app.commin.exceptions` namespace.
|
||||
|
||||
Example of raising an exception:
|
||||
|
||||
```clojure
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "File does not exist"
|
||||
:context {:id file-id})
|
||||
:file-id id)
|
||||
```
|
||||
|
||||
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
|
||||
|
||||
## 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)`.
|
||||
### 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 `PENPOT_*` environment variables, validated with
|
||||
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
|
||||
:enable-smtp)`.
|
||||
|
||||
71
common/AGENTS.md
Normal file
71
common/AGENTS.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Penpot Common – Agent Instructions
|
||||
|
||||
A shared module with code written in Clojure, ClojureScript and
|
||||
JavaScript. Contains multplatform code that can be used and executed
|
||||
from frontend, backend or exporter modules. It uses clojure reader
|
||||
conditionals for specify platform specific implementation.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
This is a golden rule for common module development. 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 a JS (nodejs) and JVM
|
||||
* **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:** The tests should be executed on both: JS (nodejs) 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 the `pnpm run fmt` fix all the 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 around all application
|
||||
- `app.common.math` – Generic math helpers used around all aplication
|
||||
- `app.common.json` – Generic JSON encoding/decoding helpers
|
||||
- `app.common.data.macros` – Performance macros used everywhere
|
||||
|
||||
|
||||
### Reader Conditionals
|
||||
|
||||
We use reader conditionals to target for differentiate an
|
||||
implementation depending on the target platform where code should run:
|
||||
|
||||
```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"}`).
|
||||
|
||||
@@ -92,10 +92,15 @@
|
||||
(not= :svg (dm/get-in shape [:content :tag])))
|
||||
;; If no shadows or blur, we return the selrect as is
|
||||
(and (empty? (-> shape :shadow))
|
||||
(zero? (-> shape :blur :value (or 0)))))
|
||||
(or (nil? (:blur shape))
|
||||
(not= :layer-blur (-> shape :blur :type))
|
||||
(zero? (-> shape :blur :value (or 0))))))
|
||||
(dm/get-prop shape :selrect)
|
||||
(let [filters (shape->filters shape)
|
||||
blur-value (or (-> shape :blur :value) 0)
|
||||
blur-value (case (-> shape :blur :type)
|
||||
:layer-blur (or (-> shape :blur :value) 0)
|
||||
:background-blur 0
|
||||
0)
|
||||
srect (-> (dm/get-prop shape :points)
|
||||
(grc/points->rect))]
|
||||
(get-rect-filter-bounds srect filters blur-value ignore-shadow-margin?)))))
|
||||
@@ -209,7 +214,10 @@
|
||||
(not (cfh/frame-shape? shape)) (or (:children-bounds shape)))
|
||||
|
||||
filters (shape->filters shape)
|
||||
blur-value (or (-> shape :blur :value) 0)]
|
||||
blur-value (case (-> shape :blur :type)
|
||||
:layer-blur (or (-> shape :blur :value) 0)
|
||||
:background-blur 0
|
||||
0)]
|
||||
|
||||
(get-rect-filter-bounds children-bounds filters blur-value ignore-shadow-margin?))))
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
(:require
|
||||
[app.common.schema :as sm]))
|
||||
|
||||
(def schema:blur-type
|
||||
[:enum :layer-blur :background-blur])
|
||||
|
||||
(def schema:blur
|
||||
[:map {:title "Blur"}
|
||||
[:id ::sm/uuid]
|
||||
[:type [:= :layer-blur]]
|
||||
[:type schema:blur-type]
|
||||
[:value ::sm/safe-number]
|
||||
[:hidden :boolean]])
|
||||
|
||||
328
frontend/AGENTS.md
Normal file
328
frontend/AGENTS.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Penpot Frontend – Agent Instructions
|
||||
|
||||
ClojureScript based frontend application that uses React, RxJS as main
|
||||
architectural pieces.
|
||||
|
||||
|
||||
## General Guidelines
|
||||
|
||||
This is a golden rule for frontend development standards. To ensure consistency
|
||||
across the penpot stack, all contributions must adhere to these criteria:
|
||||
|
||||
|
||||
### 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:** 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 remove communication with backend.
|
||||
|
||||
You should not add, modify or run the integration tests unless it exlicitly asked for.
|
||||
|
||||
|
||||
```
|
||||
pnpm run test:e2e # Playwright e2e tests
|
||||
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
|
||||
```
|
||||
|
||||
Ensure everything installed before executing tests with `./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 an UI component, extract it into a
|
||||
function in the same namespace if is only used locally or look for a helper
|
||||
namespace to make it unit-testable.
|
||||
|
||||
|
||||
## 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 of emiting 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 lookup into the main state
|
||||
for just inner data or precalculated data. That references are very usefull but
|
||||
should be used with care because, per example if we have complex operation, this
|
||||
operation will be executed on each state change, and sometimes 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 for handling it and the use the
|
||||
new helper.
|
||||
|
||||
### 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!` for to perform an update and
|
||||
`deref` for get the current value.
|
||||
|
||||
You also has `mf/deref` hook (which does not follow the `use-` naming pattern)
|
||||
and it's purpose is watch (subscribe to changes) on atom or derived atom (from
|
||||
okulary) and get the current value. Is mainly used for 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-memo` 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 it 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.
|
||||
- ❌ `margin-left`, `padding-right`, `left`, `right`.
|
||||
- ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
|
||||
- Always use the `use-typography` mixin from `ds/typography.scss`.
|
||||
- ✅ `@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 those defined in`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:
|
||||
- ❌ `.card { .title { ... } }`
|
||||
- ✅ `.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).
|
||||
|
||||
|
||||
### 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 vars ready to be used from other parts of the application
|
||||
|
||||
@@ -368,9 +368,15 @@
|
||||
(or (str/includes? stack "chrome-extension://")
|
||||
(str/includes? stack "moz-extension://")))))
|
||||
|
||||
(from-posthog? [cause]
|
||||
(let [stack (.-stack cause)]
|
||||
(and (string? stack)
|
||||
(str/includes? stack "posthog"))))
|
||||
|
||||
(is-ignorable-exception? [cause]
|
||||
(let [message (ex-message cause)]
|
||||
(or (from-extension? cause)
|
||||
(from-posthog? cause)
|
||||
(= message "Possible side-effect in debug-evaluate")
|
||||
(= message "Unexpected end of input")
|
||||
(str/starts-with? message "invalid props on component")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# render-wasm – Agent Instructions
|
||||
|
||||
This component compiles Rust to WebAssembly using Emscripten + Skia. It is consumed by the frontend as a canvas renderer.
|
||||
This component compiles Rust to WebAssembly using Emscripten +
|
||||
Skia. It is consumed by the frontend as a canvas renderer.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ pub use surfaces::{SurfaceId, Surfaces};
|
||||
|
||||
use crate::performance;
|
||||
use crate::shapes::{
|
||||
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
|
||||
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
|
||||
Stroke, Type,
|
||||
};
|
||||
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
|
||||
use crate::tiles::{self, PendingTiles, TileRect};
|
||||
@@ -742,22 +743,27 @@ impl RenderState {
|
||||
|
||||
// set clipping
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
let scale = self.get_scale();
|
||||
for (mut bounds, corners, transform) in clips.iter() {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().concat(transform);
|
||||
});
|
||||
|
||||
// Outset clip by ~0.5 to include edge pixels that
|
||||
// aliased clip misclassifies as outside (causing artifacts).
|
||||
let outset = 0.5 / scale;
|
||||
bounds.outset((outset, outset));
|
||||
|
||||
// Hard clip edge (antialias = false) to avoid alpha seam when clipping
|
||||
// semi-transparent content larger than the frame.
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
let rrect = RRect::new_rect_radii(bounds, corners);
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false);
|
||||
});
|
||||
} else {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.clip_rect(*bounds, skia::ClipOp::Intersect, false);
|
||||
s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -770,7 +776,7 @@ impl RenderState {
|
||||
paint.set_stroke_width(4.);
|
||||
self.surfaces
|
||||
.canvas(fills_surface_id)
|
||||
.draw_rect(*bounds, &paint);
|
||||
.draw_rect(bounds, &paint);
|
||||
}
|
||||
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
@@ -811,7 +817,7 @@ impl RenderState {
|
||||
{
|
||||
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
|
||||
shape.to_mut().set_blur(None);
|
||||
Some(blur.value)
|
||||
Some(blur.sigma())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1432,7 +1438,7 @@ impl RenderState {
|
||||
if !self.options.is_fast_mode() {
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
let sigma = frame_blur.value * scale;
|
||||
let sigma = radius_to_sigma(frame_blur.value * scale);
|
||||
if let Some(filter) =
|
||||
skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
{
|
||||
@@ -1625,8 +1631,10 @@ impl RenderState {
|
||||
let mut plain_shape = Cow::Borrowed(shape);
|
||||
let combined_blur =
|
||||
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
|
||||
let blur_filter = combined_blur
|
||||
.and_then(|blur| skia::image_filters::blur((blur.value, blur.value), None, None, None));
|
||||
let blur_filter = combined_blur.and_then(|blur| {
|
||||
let sigma = blur.sigma();
|
||||
skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
});
|
||||
|
||||
let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none();
|
||||
|
||||
@@ -1709,12 +1717,8 @@ impl RenderState {
|
||||
|
||||
// Create filter with blur only (no offset, no spread - handled geometrically)
|
||||
let blur_only_filter = if transformed_shadow.blur > 0.0 {
|
||||
Some(skia::image_filters::blur(
|
||||
(transformed_shadow.blur, transformed_shadow.blur),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
let sigma = radius_to_sigma(transformed_shadow.blur);
|
||||
Some(skia::image_filters::blur((sigma, sigma), None, None, None))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ pub mod text_paths;
|
||||
mod transform;
|
||||
|
||||
pub use blend::*;
|
||||
pub use blurs::*;
|
||||
pub use blurs::{radius_to_sigma, Blur, BlurType};
|
||||
pub use bools::*;
|
||||
pub use corners::*;
|
||||
pub use fills::*;
|
||||
@@ -1004,7 +1004,8 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
let blur = skia::image_filters::blur((children_blur, children_blur), None, None, None);
|
||||
let sigma = radius_to_sigma(children_blur);
|
||||
let blur = skia::image_filters::blur((sigma, sigma), None, None, None);
|
||||
if let Some(image_filter) = blur {
|
||||
let blur_bounds = image_filter.compute_fast_bounds(rect);
|
||||
rect.join(blur_bounds);
|
||||
@@ -1236,12 +1237,10 @@ impl Shape {
|
||||
self.blur
|
||||
.filter(|blur| !blur.hidden)
|
||||
.and_then(|blur| match blur.blur_type {
|
||||
BlurType::LayerBlur => skia::image_filters::blur(
|
||||
(blur.value * scale, blur.value * scale),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
BlurType::LayerBlur => {
|
||||
let sigma = radius_to_sigma(blur.value * scale);
|
||||
skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1251,7 +1250,8 @@ impl Shape {
|
||||
.filter(|blur| !blur.hidden)
|
||||
.and_then(|blur| match blur.blur_type {
|
||||
BlurType::LayerBlur => {
|
||||
skia::MaskFilter::blur(skia::BlurStyle::Normal, blur.value * scale, Some(true))
|
||||
let sigma = radius_to_sigma(blur.value * scale);
|
||||
skia::MaskFilter::blur(skia::BlurStyle::Normal, sigma, Some(true))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
/// Skia's kBLUR_SIGMA_SCALE (1/√3 ≈ 0.57735). Used to convert blur radius to sigma
|
||||
const BLUR_SIGMA_SCALE: f32 = 0.577_350_27;
|
||||
|
||||
/// Converts a blur radius to sigma (standard deviation) for Skia's blur APIs.
|
||||
/// Matches Skia's SkBlurMask::ConvertRadiusToSigma:
|
||||
#[inline]
|
||||
pub fn radius_to_sigma(radius: f32) -> f32 {
|
||||
if radius > 0.0 {
|
||||
BLUR_SIGMA_SCALE * radius + 0.5
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BlurType {
|
||||
LayerBlur,
|
||||
@@ -22,4 +36,11 @@ impl Blur {
|
||||
pub fn scale_content(&mut self, value: f32) {
|
||||
self.value *= value;
|
||||
}
|
||||
|
||||
/// Returns the sigma (standard deviation) for Skia blur APIs.
|
||||
/// The stored `value` is a blur radius; this converts it to sigma.
|
||||
#[inline]
|
||||
pub fn sigma(&self) -> f32 {
|
||||
radius_to_sigma(self.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use skia_safe::{self as skia, image_filters, ImageFilter, Paint};
|
||||
|
||||
use super::blurs::radius_to_sigma;
|
||||
use super::Color;
|
||||
use crate::render::filters::compose_filters;
|
||||
|
||||
@@ -48,9 +49,10 @@ impl Shadow {
|
||||
}
|
||||
|
||||
pub fn get_drop_shadow_filter(&self) -> Option<ImageFilter> {
|
||||
let sigma = radius_to_sigma(self.blur);
|
||||
let mut filter = image_filters::drop_shadow_only(
|
||||
(self.offset.0, self.offset.1),
|
||||
(self.blur, self.blur),
|
||||
(sigma, sigma),
|
||||
self.color,
|
||||
None,
|
||||
None,
|
||||
@@ -78,7 +80,7 @@ impl Shadow {
|
||||
}
|
||||
|
||||
pub fn get_inner_shadow_filter(&self) -> Option<ImageFilter> {
|
||||
let sigma = self.blur * 0.5;
|
||||
let sigma = radius_to_sigma(self.blur);
|
||||
let mut filter = skia::image_filters::drop_shadow_only(
|
||||
(self.offset.0, self.offset.1), // DPR?
|
||||
(sigma, sigma),
|
||||
|
||||
Reference in New Issue
Block a user