Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh
2026-03-18 15:00:40 +01:00
12 changed files with 665 additions and 552 deletions

561
AGENTS.md
View File

@@ -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).

View File

@@ -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
View 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"}`).

View File

@@ -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?))))

View File

@@ -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
View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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
};

View File

@@ -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))
}
})
}

View File

@@ -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)
}
}

View File

@@ -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),