diff --git a/AGENTS.md b/AGENTS.md index 15da56f2ac..59c4ac0d26 100644 --- a/AGENTS.md +++ b/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/`. 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/ + -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 ``` -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: ` ` - -``` -:bug: Fix unexpected error on launching modal - -Optional body explaining the why. - -Signed-off-by: Fullname -``` - -**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). diff --git a/backend/AGENTS.md b/backend/AGENTS.md index f0b4a7314c..278df26e52 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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/`. +### 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/`. + +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)`. diff --git a/common/AGENTS.md b/common/AGENTS.md new file mode 100644 index 0000000000..996a7f4953 --- /dev/null +++ b/common/AGENTS.md @@ -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"}`). + diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc index a6a6608ba4..0646d8a5f0 100644 --- a/common/src/app/common/geom/shapes/bounds.cljc +++ b/common/src/app/common/geom/shapes/bounds.cljc @@ -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?)))) diff --git a/common/src/app/common/types/shape/blur.cljc b/common/src/app/common/types/shape/blur.cljc index 3627ce0e67..cc77366631 100644 --- a/common/src/app/common/types/shape/blur.cljc +++ b/common/src/app/common/types/shape/blur.cljc @@ -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]]) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000000..b6f63794cc --- /dev/null +++ b/frontend/AGENTS.md @@ -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 + diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index f09c323bee..2327911e6f 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -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") diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md index 378122985c..dfe9c3def9 100644 --- a/render-wasm/AGENTS.md +++ b/render-wasm/AGENTS.md @@ -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 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ce5781ca63..271a7bc38d 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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 }; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index ef12164896..390391e11b 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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)) } }) } diff --git a/render-wasm/src/shapes/blurs.rs b/render-wasm/src/shapes/blurs.rs index 4232f0ee1c..543e11efa8 100644 --- a/render-wasm/src/shapes/blurs.rs +++ b/render-wasm/src/shapes/blurs.rs @@ -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) + } } diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 71e09a493c..6cfa912659 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -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 { + 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 { - 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),