From c27449e4f00cdb2dda36431125e8ec3613b861e3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 17 Mar 2026 07:39:54 +0100 Subject: [PATCH 1/7] :bug: Fix visible halos in big shadows --- render-wasm/src/render.rs | 21 ++++++++++----------- render-wasm/src/shapes.rs | 18 +++++++++--------- render-wasm/src/shapes/blurs.rs | 21 +++++++++++++++++++++ render-wasm/src/shapes/shadows.rs | 6 ++++-- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ce5781ca63..821253357a 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}; @@ -811,7 +812,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 +1433,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 +1626,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 +1712,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), From 1a59017e1c9f22e800439fbbe631b7e9299a69e0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 17 Mar 2026 18:41:06 +0100 Subject: [PATCH 2/7] :bug: Ignore posthog exceptions in unhandled exception handler (#8629) PostHog recorder throws errors like 'Cannot assign to read only property 'assert' of object' which are unrelated to the application and should be ignored to prevent noise in error reporting. --- frontend/src/app/main/errors.cljs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index fe5a82518e..abed794099 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -337,9 +337,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") From 757fb8e21d2741691caa5b5ad4d523af02af4440 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Tue, 17 Mar 2026 18:48:06 +0100 Subject: [PATCH 3/7] :sparkles: Reduce instructions transferred at MCP connection to a minimum (#8649) * :sparkles: Reduce instructions transferred at MCP connection to a minimum Force on-demand loading of the 'Penpot High-Level Overview', which was previously transferred in the MCP server's instructions. This greatly reduces the number of tokens for users who will not actually interact with Penpot, allowing the MCP server to remain enabled for such users without wasting too many tokens. Resolves #8647 * :paperclip: Update Serena project --- mcp/.serena/project.yml | 9 ++++++++ mcp/packages/server/data/base_instructions.md | 2 ++ .../server/src/ConfigurationLoader.ts | 23 +++++++++++++------ mcp/packages/server/src/PenpotMcpServer.ts | 17 +++++++++----- .../server/src/tools/HighLevelOverviewTool.ts | 2 +- 5 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 mcp/packages/server/data/base_instructions.md diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index a0a980e806..abb5cab52e 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -141,3 +141,12 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/mcp/packages/server/data/base_instructions.md b/mcp/packages/server/data/base_instructions.md new file mode 100644 index 0000000000..10245a2b45 --- /dev/null +++ b/mcp/packages/server/data/base_instructions.md @@ -0,0 +1,2 @@ +You have access to Penpot tools in order to interact with Penpot designs. +Before working with these tools, be sure to read the 'Penpot High-Level Overview' via the `high_level_overview` tool. diff --git a/mcp/packages/server/src/ConfigurationLoader.ts b/mcp/packages/server/src/ConfigurationLoader.ts index 390522ff24..2b4b11288e 100644 --- a/mcp/packages/server/src/ConfigurationLoader.ts +++ b/mcp/packages/server/src/ConfigurationLoader.ts @@ -4,15 +4,12 @@ import { createLogger } from "./logger.js"; /** * Configuration loader for prompts and server settings. - * - * Handles loading and parsing of YAML configuration files, - * providing type-safe access to configuration values with - * appropriate fallbacks for missing files or values. */ export class ConfigurationLoader { private readonly logger = createLogger("ConfigurationLoader"); private readonly baseDir: string; - private initialInstructions: string; + private readonly initialInstructions: string; + private readonly baseInstructions: string; /** * Creates a new configuration loader instance. @@ -22,6 +19,7 @@ export class ConfigurationLoader { constructor(baseDir: string) { this.baseDir = baseDir; this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md")); + this.baseInstructions = this.loadFileContent(join(this.baseDir, "data", "base_instructions.md")); } private loadFileContent(filePath: string): string { @@ -32,11 +30,22 @@ export class ConfigurationLoader { } /** - * Gets the initial instructions for the MCP server. + * Gets the initial instructions for the MCP server corresponding to the + * 'Penpot High-Level Overview' * - * @returns The initial instructions string, or undefined if not configured + * @returns The initial instructions string */ public getInitialInstructions(): string { return this.initialInstructions; } + + /** + * Gets the base instructions which shall be provided to clients when connecting to + * the MCP server + * + * @returns The initial instructions string + */ + public getBaseInstructions(): string { + return this.baseInstructions; + } } diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 2c4a2cc792..4cc08e43b3 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -56,7 +56,8 @@ export class PenpotMcpServer { public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer; private apiDocs: ApiDocs; - private initialInstructions: string; + private readonly penpotHighLevelOverview: string; + private readonly connectionInstructions: string; /** * Manages session-specific context, particularly user tokens for each request. @@ -82,10 +83,11 @@ export class PenpotMcpServer { this.configLoader = new ConfigurationLoader(process.cwd()); this.apiDocs = new ApiDocs(); - // prepare initial instructions + // prepare instructions let instructions = this.configLoader.getInitialInstructions(); instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); - this.initialInstructions = instructions; + this.penpotHighLevelOverview = instructions; + this.connectionInstructions = this.configLoader.getBaseInstructions(); this.tools = this.initTools(); @@ -124,8 +126,11 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } - public getInitialInstructions(): string { - return this.initialInstructions; + /** + * Retrieves the high-level overview instructions explaining core Penpot usage. + */ + public getHighLevelOverviewInstructions(): string { + return this.penpotHighLevelOverview; } /** @@ -163,7 +168,7 @@ export class PenpotMcpServer { private createMcpServer(): McpServer { const server = new McpServer( { name: "penpot", version: "1.0.0" }, - { instructions: this.getInitialInstructions() } + { instructions: this.connectionInstructions } ); for (const tool of this.tools) { diff --git a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts index ada8829771..b16edcb68b 100644 --- a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts +++ b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts @@ -21,6 +21,6 @@ export class HighLevelOverviewTool extends Tool { } protected async executeCore(args: EmptyToolArgs): Promise { - return new TextResponse(this.mcpServer.getInitialInstructions()); + return new TextResponse(this.mcpServer.getHighLevelOverviewInstructions()); } } From 0d2ec687d270eae01e31e432b7554255b026e3b5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 18 Mar 2026 09:53:22 +0100 Subject: [PATCH 4/7] :bug: Fix unexpected corner case between SES hardening and transit (#8663) * Revert ":bug: Fix plugin sandbox freezing CLJS Proxy constructor breaking Transit encoding" This reverts commit 27a934dcfd579093b066c78d67eba782ba6229cb. * :bug: Fix unexpected corner case between SES hardening and transit The cause of the issue is a race condition between plugin loading and the first time js/Date objects are encoded using transit. Transit encoder populates the prototype of the Date object the first time a Date instance is encoded, but if SES freezes the Date prototype before transit, an strange exception will be raised on encoding any object that contains Date instances. Example of the exception: Cannot define property transit$guid$4a57baf3-8824-4930-915a-fa905479a036, object is not extensible --- frontend/src/app/main.cljs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 870f8a82bb..7bf4afecc7 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -8,6 +8,8 @@ (:require [app.common.data.macros :as dm] [app.common.logging :as log] + [app.common.time :as ct] + [app.common.transit :as t] [app.common.types.objects-map] [app.common.uuid :as uuid] [app.config :as cf] @@ -100,6 +102,15 @@ (defn ^:export init [options] + ;; WORKAROUND: we set this really not usefull property for signal a + ;; sideffect and prevent GCC remove it. We need it because we need + ;; to populate the Date prototype with transit related properties + ;; before SES hardning is applied on loading MCP plugin + (unchecked-set js/globalThis "penpotStartDate" + (-> (ct/now) + (t/encode-str) + (t/decode-str))) + ;; Before initializing anything, check if the browser has loaded ;; stale JS from a previous deployment. If so, do a hard reload so ;; the browser fetches fresh assets matching the current index.html. @@ -110,7 +121,6 @@ (do (some-> (unchecked-get options "defaultTranslations") (i18n/set-default-translations)) - (mw/init!) (i18n/init) (cur/init-styles) From 0484d23b128fe0f808f9988ea2a01d6b441f8f4a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 17 Mar 2026 16:18:18 +0100 Subject: [PATCH 5/7] :bug: Fix clipped rounded corners artifacts --- render-wasm/src/render.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ce5781ca63..f1611ec2b2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -742,22 +742,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 +775,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| { From d2422e3a217f809276fab6329cdbe1b3b8eb9982 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 17 Mar 2026 09:16:09 +0100 Subject: [PATCH 6/7] :sparkles: Add background blur type support to common schema --- common/src/app/common/geom/shapes/bounds.cljc | 14 +++++++++++--- common/src/app/common/types/shape/blur.cljc | 5 ++++- 2 files changed, 15 insertions(+), 4 deletions(-) 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]]) From 2d616cf9c0a8782c6e7e5ac7e75e92dc58594373 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 18 Mar 2026 14:59:38 +0100 Subject: [PATCH 7/7] :books: Add better organization for AGENTS.md file (#8675) --- AGENTS.md | 561 +++++++----------------------------------- backend/AGENTS.md | 148 +++++++---- common/AGENTS.md | 71 ++++++ frontend/AGENTS.md | 328 ++++++++++++++++++++++++ render-wasm/AGENTS.md | 3 +- 5 files changed, 590 insertions(+), 521 deletions(-) create mode 100644 common/AGENTS.md create mode 100644 frontend/AGENTS.md 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/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/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