From aee555ff6e8211d6ac7bf3e6e25d9806b618ec11 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 8 Jul 2026 11:02:22 +0200 Subject: [PATCH 01/63] :books: Update API types for MCP server for new release --- mcp/packages/server/data/api_types.yml | 1480 ++++++++++++++++-------- 1 file changed, 981 insertions(+), 499 deletions(-) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index de532d4eb9..0b53f1dfc0 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -11,7 +11,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number; hidden: boolean }, + options?: { width: number; height: number; hidden?: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -73,7 +73,7 @@ Penpot: generateFontFaces(shapes: Shape[]): Promise; openViewer(): void; createPage(): Page; - openPage(page: string | Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): Promise; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -97,11 +97,11 @@ Penpot: Properties: ui: |- ``` - ui: { + readonly ui: { open: ( name: string, url: string, - options?: { width: number; height: number; hidden: boolean }, + options?: { width: number; height: number; hidden?: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -112,7 +112,7 @@ Penpot: Type Declaration - * open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void + * open: ( name: string, url: string, options?: { width: number; height: number; hidden?: boolean },) => void Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter. @@ -122,6 +122,13 @@ Penpot: penpot.ui.open('Plugin name', 'url', {width: 150, height: 300}); ``` * size: { width: number; height: number } | null + + The current size of the modal, or `null` if the plugin UI is not open. + + Example + ``` + const size = penpot.ui.size;console.log(size); + ``` * resize: (width: number, height: number) => void Resizes the plugin UI. @@ -136,19 +143,19 @@ Penpot: Example ``` - this.sendMessage({ type: 'example-type', content: 'data we want to share' }); + penpot.ui.sendMessage({ type: 'example-type', content: 'data we want to share' }); ``` * onMessage: (callback: (message: T) => void) => void - This is usually used in the `plugin.ts` file in order to handle the data sent by our plugin + This is usually used in the `plugin.ts` file in order to handle the messages sent from the plugin UI. Example ``` - penpot.ui.onMessage((message) => {if(message.type === 'example-type' { ...do something })}); + penpot.ui.onMessage((message) => { if (message.type === 'example-type') { ...do something } }); ``` utils: |- ``` - utils: ContextUtils + readonly utils: ContextUtils ``` Provides access to utility functions and context-specific operations. @@ -331,7 +338,9 @@ Penpot: * selectionchange: event emitted when the current selection changes. The callback will receive the list of ids for the new selection * themechange: event emitted when the user changes its theme. The callback will receive the new theme (currently: either `dark` or `light`) - * documentsaved: event emitted after the document is saved in the backend. + * filechange: event emitted when a different file is opened. The callback will receive the new file. + * contentsave: event emitted after the file content is saved in the backend. The callback receives no arguments. + * finish: event emitted when the current file is closed. The callback will receive the id of the closed file. Type Parameters @@ -355,7 +364,7 @@ Penpot: Example ``` - penpot.on('pagechange', () => {...do something}). + penpot.on('pagechange', () => {...do something}); ``` off: |- ``` @@ -508,7 +517,7 @@ Penpot: Example ``` - const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and if the selected shape is a group,if (selected.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.group(penpotShapesArray[0]);} + const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and that the selected shape is a groupif (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.ungroup(penpotShapesArray[0]);} ``` createRectangle: |- ``` @@ -643,11 +652,11 @@ Penpot: * text: string - The text content for the Text shape. + The text content for the Text shape. Must be a non-empty string. Returns Text | null - Returns the new created shape, if the shape wasn't created can return null. + Returns the new created shape. Returns null if an empty string is provided or the shape couldn't be created. Example ``` @@ -663,8 +672,12 @@ Penpot: Parameters * shapes: Shape[] + + the shapes to generate the markup for * options: { type?: "html" | "svg" } + `type` of the markup to generate. Defaults to `'html'`. + Returns string Example @@ -688,8 +701,13 @@ Penpot: Parameters * shapes: Shape[] + + the shapes to generate the styles for * options: { type?: "css"; withPrelude?: boolean; includeChildren?: boolean } + `type` of the styles to generate (defaults to `'css'`), `withPrelude` to include the style prelude + (defaults to `false`) and `includeChildren` to also generate the styles for the shape children (defaults to `true`). + Returns string Example @@ -727,15 +745,28 @@ Penpot: createPage(): Page ``` - Creates a new page. Requires `content:write` permission. + Creates a new page and returns it. Requires `content:write` permission. + + IMPORTANT: creating a page does **not** make it the active page. + To build content inside the new page, activate it first with + Context.openPage (and `await` it) before mutate shapes: Returns Page + + Example + ``` + const page = penpot.createPage();page.name = 'New Page';await penpot.openPage(page); // make the new page active firstconst board = penpot.createBoard();board.resize(375, 812);page.root.appendChild(board); + ``` openPage: |- ``` - openPage(page: string | Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): Promise ``` - Changes the current open page to given page. Requires `content:read` permission. + Changes the current open page to the given page, making it the **active page**. + The active page is the one all shape creation and structural operations + (`createBoard`, `appendChild`, `insertChild`, property setters, etc.) act upon, + so call this (and `await` it) after Context.createPage before adding + shapes to the newly created page. Requires `content:read` permission. Parameters @@ -746,11 +777,11 @@ Penpot: if true opens the page in a new window, defaults to false - Returns void + Returns Promise Example ``` - context.openPage(page); + await context.openPage(page); ``` alignHorizontal: |- ``` @@ -965,7 +996,6 @@ Blur: ``` interface Blur { id?: string; - type?: "layer-blur"; value?: number; hidden?: boolean; } @@ -980,13 +1010,6 @@ Blur: ``` The optional unique identifier for the blur effect. - type: |- - ``` - type?: "layer-blur" - ``` - - The optional type of the blur effect. - Currently, only 'layer-blur' is supported. value: |- ``` value?: number @@ -1053,6 +1076,7 @@ Board: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -1078,6 +1102,7 @@ Board: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -1139,6 +1164,7 @@ Board: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -1156,7 +1182,7 @@ Board: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -1188,7 +1214,7 @@ Board: showInViewMode: boolean ``` - WHen true the board will be displayed in the view mode + When true the board will be displayed in the view mode grid: |- ``` readonly grid?: GridLayout @@ -1221,7 +1247,7 @@ Board: The horizontal sizing behavior of the board. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'auto': The container fits the content. verticalSizing: |- ``` @@ -1231,7 +1257,7 @@ Board: The vertical sizing behavior of the board. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'auto': The container fits the content. fills: |- ``` @@ -1309,17 +1335,13 @@ Board: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -1356,6 +1378,12 @@ Board: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -1426,6 +1454,14 @@ Board: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -1473,9 +1509,7 @@ Board: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -1619,27 +1653,31 @@ Board: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5 + const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5; ``` addRulerGuide: |- ``` addRulerGuide(orientation: RulerGuideOrientation, value: number): RulerGuide ``` - Creates a new ruler guide. + Creates a new ruler guide attached to the board. Parameters * orientation: RulerGuideOrientation + + `horizontal` or `vertical` * value: number + the position of the guide relative to the board + Returns RulerGuide removeRulerGuide: |- ``` removeRulerGuide(guide: RulerGuide): void ``` - Removes the `guide` from the current page. + Removes the `guide` from the board. Parameters @@ -1889,12 +1927,27 @@ Board: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -1907,7 +1960,7 @@ Board: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -1976,7 +2029,7 @@ Board: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -2040,6 +2093,10 @@ Board: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -2079,7 +2136,7 @@ Board: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -2089,7 +2146,7 @@ Board: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -2172,6 +2229,7 @@ VariantContainer: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -2197,6 +2255,7 @@ VariantContainer: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -2258,6 +2317,7 @@ VariantContainer: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -2275,7 +2335,7 @@ VariantContainer: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -2306,7 +2366,7 @@ VariantContainer: showInViewMode: boolean ``` - WHen true the board will be displayed in the view mode + When true the board will be displayed in the view mode grid: |- ``` readonly grid?: GridLayout @@ -2339,7 +2399,7 @@ VariantContainer: The horizontal sizing behavior of the board. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'auto': The container fits the content. verticalSizing: |- ``` @@ -2349,7 +2409,7 @@ VariantContainer: The vertical sizing behavior of the board. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'auto': The container fits the content. fills: |- ``` @@ -2431,17 +2491,13 @@ VariantContainer: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -2478,6 +2534,12 @@ VariantContainer: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -2548,6 +2610,14 @@ VariantContainer: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -2595,9 +2665,7 @@ VariantContainer: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -2741,27 +2809,31 @@ VariantContainer: Example ``` - const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5 + const board = penpot.createBoard();const grid = board.addGridLayout();// You can change the grid properties as follows.grid.alignItems = "center";grid.justifyItems = "start";grid.rowGap = 10;grid.columnGap = 10;grid.verticalPadding = 5;grid.horizontalPadding = 5; ``` addRulerGuide: |- ``` addRulerGuide(orientation: RulerGuideOrientation, value: number): RulerGuide ``` - Creates a new ruler guide. + Creates a new ruler guide attached to the board. Parameters * orientation: RulerGuideOrientation + + `horizontal` or `vertical` * value: number + the position of the guide relative to the board + Returns RulerGuide removeRulerGuide: |- ``` removeRulerGuide(guide: RulerGuide): void ``` - Removes the `guide` from the current page. + Removes the `guide` from the board. Parameters @@ -3011,12 +3083,27 @@ VariantContainer: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -3029,7 +3116,7 @@ VariantContainer: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -3098,7 +3185,7 @@ VariantContainer: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -3162,6 +3249,10 @@ VariantContainer: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -3201,7 +3292,7 @@ VariantContainer: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -3211,7 +3302,7 @@ VariantContainer: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -3281,6 +3372,7 @@ Boolean: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -3306,6 +3398,7 @@ Boolean: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -3367,6 +3460,7 @@ Boolean: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -3384,7 +3478,7 @@ Boolean: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -3403,10 +3497,10 @@ Boolean: readonly type: "boolean" ``` - The type of the shape, which is always 'bool' for boolean operation shapes. + The type of the shape, which is always 'boolean' for boolean operation shapes. content: |- ``` - content: string + readonly content: string ``` The content of the boolean shape, defined as the path string. @@ -3416,13 +3510,13 @@ Boolean: Use either `d` or `commands`. d: |- ``` - d: string + readonly d: string ``` The content of the boolean shape, defined as the path string. commands: |- ``` - commands: PathCommand[] + readonly commands: PathCommand[] ``` The content of the boolean shape, defined as an array of path commands. @@ -3494,17 +3588,13 @@ Boolean: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -3541,6 +3631,12 @@ Boolean: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -3611,6 +3707,14 @@ Boolean: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -3658,9 +3762,7 @@ Boolean: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -4025,12 +4127,27 @@ Boolean: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -4043,7 +4160,7 @@ Boolean: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -4112,7 +4229,7 @@ Boolean: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -4176,6 +4293,10 @@ Boolean: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -4215,7 +4336,7 @@ Boolean: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -4225,7 +4346,7 @@ Boolean: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -4265,7 +4386,7 @@ CloseOverlay: interface CloseOverlay { type: "close-overlay"; destination?: Board; - animation: Animation; + animation?: Animation; } ``` @@ -4286,10 +4407,10 @@ CloseOverlay: The overlay to be closed with this action. animation: |- ``` - readonly animation: Animation + readonly animation?: Animation ``` - Animation displayed with this interaction. + Animation displayed with this interaction. Omit it to close with no transition. Color: overview: |- Interface Color @@ -4760,7 +4881,7 @@ CommonLayout: The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. verticalSizing: |- @@ -4771,7 +4892,7 @@ CommonLayout: The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. Methods: @@ -4845,7 +4966,7 @@ Context: removeListener(listenerId: symbol): void; openViewer(): void; createPage(): Page; - openPage(page: string | Page, newWindow?: boolean): void; + openPage(page: string | Page, newWindow?: boolean): Promise; alignHorizontal( shapes: Shape[], direction: "center" | "left" | "right", @@ -5138,7 +5259,7 @@ Context: Example ``` - const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and if the selected shape is a group,if (selected.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.group(penpotShapesArray[0]);} + const penpotShapesArray = penpot.selection;// We need to make sure that something is selected, and that the selected shape is a groupif (penpotShapesArray.length && penpot.utils.types.isGroup(penpotShapesArray[0])) { penpot.ungroup(penpotShapesArray[0]);} ``` createRectangle: |- ``` @@ -5273,11 +5394,11 @@ Context: * text: string - The text content for the Text shape. + The text content for the Text shape. Must be a non-empty string. Returns Text | null - Returns the new created shape, if the shape wasn't created can return null. + Returns the new created shape. Returns null if an empty string is provided or the shape couldn't be created. Example ``` @@ -5293,8 +5414,12 @@ Context: Parameters * shapes: Shape[] + + the shapes to generate the markup for * options: { type?: "html" | "svg" } + `type` of the markup to generate. Defaults to `'html'`. + Returns string Example @@ -5318,8 +5443,13 @@ Context: Parameters * shapes: Shape[] + + the shapes to generate the styles for * options: { type?: "css"; withPrelude?: boolean; includeChildren?: boolean } + `type` of the styles to generate (defaults to `'css'`), `withPrelude` to include the style prelude + (defaults to `false`) and `includeChildren` to also generate the styles for the shape children (defaults to `true`). + Returns string Example @@ -5401,15 +5531,28 @@ Context: createPage(): Page ``` - Creates a new page. Requires `content:write` permission. + Creates a new page and returns it. Requires `content:write` permission. + + IMPORTANT: creating a page does **not** make it the active page. + To build content inside the new page, activate it first with + Context.openPage (and `await` it) before mutate shapes: Returns Page + + Example + ``` + const page = penpot.createPage();page.name = 'New Page';await penpot.openPage(page); // make the new page active firstconst board = penpot.createBoard();board.resize(375, 812);page.root.appendChild(board); + ``` openPage: |- ``` - openPage(page: string | Page, newWindow?: boolean): void + openPage(page: string | Page, newWindow?: boolean): Promise ``` - Changes the current open page to given page. Requires `content:read` permission. + Changes the current open page to the given page, making it the **active page**. + The active page is the one all shape creation and structural operations + (`createBoard`, `appendChild`, `insertChild`, property setters, etc.) act upon, + so call this (and `await` it) after Context.createPage before adding + shapes to the newly created page. Requires `content:read` permission. Parameters @@ -5420,11 +5563,11 @@ Context: if true opens the page in a new window, defaults to false - Returns void + Returns Promise Example ``` - context.openPage(page); + await context.openPage(page); ``` alignHorizontal: |- ``` @@ -5889,6 +6032,7 @@ Ellipse: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -5914,6 +6058,7 @@ Ellipse: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -5975,6 +6120,7 @@ Ellipse: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -5992,7 +6138,7 @@ Ellipse: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -6008,8 +6154,10 @@ Ellipse: Properties: type: |- ``` - type: "ellipse" + readonly type: "ellipse" ``` + + The type of the shape, which is always 'ellipse' for ellipse shapes. fills: |- ``` fills: Fill[] @@ -6072,17 +6220,13 @@ Ellipse: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -6119,6 +6263,12 @@ Ellipse: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -6189,6 +6339,14 @@ Ellipse: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -6236,9 +6394,7 @@ Ellipse: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -6548,12 +6704,27 @@ Ellipse: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -6566,7 +6737,7 @@ Ellipse: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -6635,7 +6806,7 @@ Ellipse: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -6699,6 +6870,10 @@ Ellipse: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -6738,7 +6913,7 @@ Ellipse: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -6748,7 +6923,7 @@ Ellipse: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -6807,48 +6982,50 @@ EventsMap: Properties: pagechange: |- ``` - pagechange: Page + readonly pagechange: Page ``` The `pagechange` event is triggered when the active page in the project is changed. filechange: |- ``` - filechange: File + readonly filechange: File ``` - The `filechange` event is triggered when there are changes in the current file. + The `filechange` event is triggered when a different file is opened. + The callback will receive the new file. selectionchange: |- ``` - selectionchange: string[] + readonly selectionchange: string[] ``` The `selectionchange` event is triggered when the selection of elements changes. This event passes a list of identifiers of the selected elements. themechange: |- ``` - themechange: Theme + readonly themechange: Theme ``` The `themechange` event is triggered when the application theme is changed. finish: |- ``` - finish: string + readonly finish: string ``` - The `finish` event is triggered when some operation is finished. + The `finish` event is triggered when the current file is closed. + The callback will receive the id of the closed file. shapechange: |- ``` - shapechange: Shape + readonly shapechange: Shape ``` This event will trigger whenever the shape in the props change. It's mandatory to send with the props an object like `{ shapeId: '' }` contentsave: |- ``` - contentsave: void + readonly contentsave: void ``` - The `contentsave` event will trigger when the content file changes. + The `contentsave` event will trigger after the file content is saved in the backend. Export: overview: |- Interface Export @@ -6913,6 +7090,7 @@ File: ): Promise>; findVersions(criteria?: { createdBy: User }): Promise; saveVersion(label: string): Promise; + validate(): FileValidationError[]; getPluginData(key: string): string; setPluginData(key: string, value: string): void; getPluginDataKeys(): string[]; @@ -6938,19 +7116,19 @@ File: The `id` property is a unique identifier for the file. name: |- ``` - name: string + readonly name: string ``` The `name` for the file revn: |- ``` - revn: number + readonly revn: number ``` The `revn` will change for every document update pages: |- ``` - pages: Page[] + readonly pages: Page[] ``` List all the pages for the current file @@ -6963,12 +7141,19 @@ File: ): Promise> ``` + Export the current file to an archive. + Parameters * exportType: "penpot" | "zip" * libraryExportType: "all" | "merge" | "detach" Returns Promise> + + Example + ``` + const exportedData = await file.export('penpot', 'all'); + ``` findVersions: |- ``` findVersions(criteria?: { createdBy: User }): Promise @@ -6994,6 +7179,22 @@ File: * label: string Returns Promise + validate: |- + ``` + validate(): FileValidationError[] + ``` + + Runs the referential-integrity validation on the file and returns the list + of errors found. An empty array means the file is valid. Useful to detect + inconsistencies (dangling references, broken components/variants, …) that + the backend would otherwise reject when the file is saved. + + Returns FileValidationError[] + + Example + ``` + const errors = file.validate();if (errors.length) console.error(errors); + ``` getPluginData: |- ``` getPluginData(key: string): string @@ -7122,6 +7323,50 @@ File: ``` const sharedKeys = shape.getSharedPluginDataKeys('exampleNamespace');console.log(sharedKeys); ``` +FileValidationError: + overview: |- + Interface FileValidationError + ============================= + + A single referential-integrity error reported by File.validate. + + ``` + interface FileValidationError { + code: string; + hint: string; + shapeId: string | null; + pageId: string | null; + } + ``` + + Referenced by: File + members: + Properties: + code: |- + ``` + readonly code: string + ``` + + The validation error code (e.g. `'variant-component-bad-name'`, + `'child-not-found'`). + hint: |- + ``` + readonly hint: string + ``` + + A human-readable description of the error. + shapeId: |- + ``` + readonly shapeId: string | null + ``` + + The id of the offending shape, when the error is attached to one. + pageId: |- + ``` + readonly pageId: string | null + ``` + + The id of the page the offending shape lives in, when applicable. FileVersion: overview: |- Interface FileVersion @@ -7175,6 +7420,11 @@ FileVersion: restore(): void ``` + Restores the current version and replaces the content of the active file + for the contents of this version. + Requires the `content:write` permission. + Warning: Calling this will close the plugin because the workspace will reload + Returns void remove: |- ``` @@ -7258,7 +7508,7 @@ Flags: Interface Flags =============== - This subcontext allows the API o change certain defaults + This subcontext allows the API to change certain defaults ``` interface Flags { @@ -7275,9 +7525,9 @@ Flags: naturalChildOrdering: boolean ``` - If `true` the .children property will be always sorted in the z-index ordering. - Also, appendChild method will be append the children in the top-most position. - The insertchild method is changed acordingly to respect this ordering. + If `true` the .children property will always be sorted in the z-index ordering. + Also, the appendChild method will append the children in the top-most position. + The insertChild method is changed accordingly to respect this ordering. Defaults to false throwValidationErrors: |- ``` @@ -7286,7 +7536,8 @@ Flags: If `true` the validation errors will throw an exception instead of displaying an error in the debugger console. - Defaults to false + Defaults to `true` for plugins whose manifest declares `"version": 2` (or higher) + and to `false` for v1 plugins (or manifests that omit the version field). FlexLayout: overview: |- Interface FlexLayout @@ -7470,7 +7721,7 @@ FlexLayout: The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. verticalSizing: |- @@ -7481,7 +7732,7 @@ FlexLayout: The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. dir: |- @@ -7605,43 +7856,43 @@ Font: Properties: name: |- ``` - name: string + readonly name: string ``` This property holds the human-readable name of the font. fontId: |- ``` - fontId: string + readonly fontId: string ``` The unique identifier of the font. fontFamily: |- ``` - fontFamily: string + readonly fontFamily: string ``` The font family of the font. fontStyle: |- ``` - fontStyle?: "normal" | "italic" | null + readonly fontStyle?: "normal" | "italic" | null ``` The default font style of the font. fontVariantId: |- ``` - fontVariantId: string + readonly fontVariantId: string ``` The default font variant ID of the font. fontWeight: |- ``` - fontWeight: string + readonly fontWeight: string ``` The default font weight of the font. variants: |- ``` - variants: FontVariant[] + readonly variants: FontVariant[] ``` An array of font variants available for the font. @@ -7712,25 +7963,25 @@ FontVariant: Properties: name: |- ``` - name: string + readonly name: string ``` The name of the font variant. fontVariantId: |- ``` - fontVariantId: string + readonly fontVariantId: string ``` The unique identifier of the font variant. fontWeight: |- ``` - fontWeight: string + readonly fontWeight: string ``` The font weight of the font variant. fontStyle: |- ``` - fontStyle: "normal" | "italic" + readonly fontStyle: "normal" | "italic" ``` The font style of the font variant. @@ -7757,7 +8008,7 @@ FontsContext: Properties: all: |- ``` - all: Font[] + readonly all: Font[] ``` An array containing all available fonts. @@ -8037,7 +8288,7 @@ GridLayout: The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. verticalSizing: |- @@ -8048,7 +8299,7 @@ GridLayout: The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fix': The containers has its own intrinsic fixed size. + * 'fix': The container has its own intrinsic fixed size. * 'fill': The container fills the available space. Only can be set if it's inside another layout. * 'auto': The container fits the content. dir: |- @@ -8327,6 +8578,7 @@ Group: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -8352,6 +8604,7 @@ Group: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -8415,6 +8668,7 @@ Group: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -8432,7 +8686,7 @@ Group: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -8512,17 +8766,13 @@ Group: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -8559,6 +8809,12 @@ Group: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -8629,6 +8885,14 @@ Group: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -8676,9 +8940,7 @@ Group: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. fills: |- ``` fills: Fill[] | "mixed" @@ -9060,12 +9322,27 @@ Group: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -9078,7 +9355,7 @@ Group: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -9147,7 +9424,7 @@ Group: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -9211,6 +9488,10 @@ Group: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -9250,7 +9531,7 @@ Group: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -9260,7 +9541,7 @@ Group: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -9294,7 +9575,7 @@ GuideColumn: Interface GuideColumn ===================== - Represents a goard guide for columns in Penpot. + Represents a board guide for columns in Penpot. This interface includes properties for defining the type, visibility, and parameters of column guides within a board. ``` @@ -9310,19 +9591,19 @@ GuideColumn: Properties: type: |- ``` - type: "column" + readonly type: "column" ``` The type of the guide, which is always 'column' for column guides. display: |- ``` - display: boolean + readonly display: boolean ``` Specifies whether the column guide is displayed. params: |- ``` - params: GuideColumnParams + readonly params: GuideColumnParams ``` The parameters defining the appearance and layout of the column guides. @@ -9350,13 +9631,13 @@ GuideColumnParams: Properties: color: |- ``` - color: { color: string; opacity: number } + readonly color: { color: string; opacity: number } ``` The color configuration for the column guides. type: |- ``` - type?: "center" | "left" | "right" | "stretch" + readonly type?: "center" | "left" | "right" | "stretch" ``` The optional alignment type of the column guides. @@ -9367,25 +9648,25 @@ GuideColumnParams: * 'right': Columns align to the right. size: |- ``` - size?: number + readonly size?: number ``` The optional size of each column. margin: |- ``` - margin?: number + readonly margin?: number ``` The optional margin between the columns and the board edges. itemLength: |- ``` - itemLength?: number + readonly itemLength?: number ``` The optional length of each item within the columns. gutter: |- ``` - gutter?: number + readonly gutter?: number ``` The optional gutter width between columns. @@ -9410,19 +9691,19 @@ GuideRow: Properties: type: |- ``` - type: "row" + readonly type: "row" ``` The type of the guide, which is always 'row' for row guides. display: |- ``` - display: boolean + readonly display: boolean ``` Specifies whether the row guide is displayed. params: |- ``` - params: GuideColumnParams + readonly params: GuideColumnParams ``` The parameters defining the appearance and layout of the row guides. @@ -9448,19 +9729,19 @@ GuideSquare: Properties: type: |- ``` - type: "square" + readonly type: "square" ``` The type of the guide, which is always 'square' for square guides. display: |- ``` - display: boolean + readonly display: boolean ``` Specifies whether the square guide is displayed. params: |- ``` - params: GuideSquareParams + readonly params: GuideSquareParams ``` The parameters defining the appearance and layout of the square guides. @@ -9484,13 +9765,13 @@ GuideSquareParams: Properties: color: |- ``` - color: { color: string; opacity: number } + readonly color: { color: string; opacity: number } ``` The color configuration for the square guides. size: |- ``` - size?: number + readonly size?: number ``` The optional size of each square guide. @@ -9549,6 +9830,11 @@ Image: Represents an image shape in Penpot. This interface extends `ShapeBase` and includes properties specific to image shapes. + Deprecated + + Image shapes exist only for backward compatibility with old files. + New images are embedded in a `Fill` via its `fillImage` (an `ImageData`). + ``` interface Image { type: "image"; @@ -9575,6 +9861,7 @@ Image: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -9600,6 +9887,7 @@ Image: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -9661,6 +9949,7 @@ Image: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -9678,7 +9967,7 @@ Image: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -9694,8 +9983,10 @@ Image: Properties: type: |- ``` - type: "image" + readonly type: "image" ``` + + The type of the shape, which is always 'image' for image shapes. fills: |- ``` fills: Fill[] @@ -9758,17 +10049,13 @@ Image: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -9805,6 +10092,12 @@ Image: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -9875,6 +10168,14 @@ Image: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -9922,9 +10223,7 @@ Image: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -10234,12 +10533,27 @@ Image: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -10252,7 +10566,7 @@ Image: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -10321,7 +10635,7 @@ Image: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -10385,6 +10699,10 @@ Image: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -10424,7 +10742,7 @@ Image: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -10434,7 +10752,7 @@ Image: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -11331,7 +11649,7 @@ LibraryComponent: transformInVariant(): void ``` - Creates a new Variant from this standard Component. It creates a VariantContainer, transform this Component into a VariantComponent, duplicates it, and creates a + Creates a new Variant from this standard Component. It creates a VariantContainer, transforms this Component into a VariantComponent, duplicates it, and creates a set of properties based on the component name and path. Similar to doing it with the contextual menu or the shortcut on the Penpot interface @@ -11515,13 +11833,13 @@ LibraryVariantComponent: readonly variantProps: { [property: string]: string } ``` - A list of the variants props of this VariantComponent. Each property have a key and a value + A list of the variant props of this VariantComponent. Each property has a key and a value variantError: |- ``` variantError: string ``` - If this VariantComponent has an invalid name, that does't follow the structure [property]=[value], [property]=[value] + If this VariantComponent has an invalid name, that doesn't follow the structure [property]=[value], [property]=[value] this field stores that invalid name id: |- ``` @@ -11586,7 +11904,7 @@ LibraryVariantComponent: transformInVariant(): void ``` - Creates a new Variant from this standard Component. It creates a VariantContainer, transform this Component into a VariantComponent, duplicates it, and creates a + Creates a new Variant from this standard Component. It creates a VariantContainer, transforms this Component into a VariantComponent, duplicates it, and creates a set of properties based on the component name and path. Similar to doing it with the contextual menu or the shortcut on the Penpot interface @@ -11609,8 +11927,12 @@ LibraryVariantComponent: Parameters * pos: number + + The position of the property to update * value: string + The new value of the property + Returns void getPluginData: |- ``` @@ -11991,7 +12313,7 @@ LibraryTypography: name: string; path: string; fontId: string; - fontFamilies: string; + fontFamily: string; fontVariantId: string; fontSize: string; fontWeight: string; @@ -12049,12 +12371,12 @@ LibraryTypography: ``` The unique identifier of the font used in the typography element. - fontFamilies: |- + fontFamily: |- ``` - fontFamilies: string + fontFamily: string ``` - The font families of the typography element. + The font family of the typography element. fontVariantId: |- ``` fontVariantId: string @@ -12294,7 +12616,7 @@ LocalStorage: Proxy for the local storage. Only elements owned by the plugin can be stored and accessed. Warning: other plugins won't be able to access this information but - the user could potentialy access the data through the browser information. + the user could potentially access the data through the browser information. ``` interface LocalStorage { @@ -12327,7 +12649,7 @@ LocalStorage: ``` Set the data given the key. If the value already existed it - will be overriden. The value will be stored in a string representation. + will be overridden. The value will be stored in a string representation. Requires the `allow:localstorage` permission. Parameters @@ -12620,6 +12942,7 @@ Page: name: string; rulerGuides: RulerGuide[]; root: Shape; + remove(): void; getShapeById(id: string): Shape | null; findShapes( criteria?: { @@ -12685,10 +13008,10 @@ Page: readonly rulerGuides: RulerGuide[] ``` - The ruler guides attached to the board + The ruler guides attached to the page root: |- ``` - root: Shape + readonly root: Shape ``` The root shape of the current page. Will be the parent shape of all the shapes inside the document. @@ -12700,6 +13023,22 @@ Page: The interaction flows defined for the page. Methods: + remove: |- + ``` + remove(): void + ``` + + Removes the page from the file. The last remaining page of the file + cannot be removed. If the removed page is the active one, another page + is activated. + Requires `content:write` permission. + + Returns void + + Example + ``` + const page = penpot.createPage();page.remove(); + ``` getShapeById: |- ``` getShapeById(id: string): Shape | null @@ -12740,7 +13079,7 @@ Page: ``` Finds all shapes on the page. - Optionaly it gets a criteria object to search for specific criteria + Optionally it gets a criteria object to search for specific criteria Parameters @@ -12815,9 +13154,15 @@ Page: Parameters * orientation: RulerGuideOrientation + + `horizontal` or `vertical` * value: number + + the position of the guide in absolute coordinates * board: Board + if provided, the guide will be attached to the board + Returns RulerGuide removeRulerGuide: |- ``` @@ -12836,8 +13181,7 @@ Page: addCommentThread(content: string, position: Point): Promise ``` - Creates a new comment thread in the `position`. Optionaly adds - it into the `board`. + Creates a new comment thread in the `position`. Returns the thread created. Requires the `comment:write` permission. @@ -13046,6 +13390,7 @@ Path: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -13071,6 +13416,7 @@ Path: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -13132,6 +13478,7 @@ Path: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -13149,7 +13496,7 @@ Path: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -13174,7 +13521,7 @@ Path: content: string ``` - The content of the boolean shape, defined as the path string. + The content of the path shape, defined as the path string. Deprecated @@ -13184,13 +13531,13 @@ Path: d: string ``` - The content of the boolean shape, defined as the path string. + The content of the path shape, defined as the path string. commands: |- ``` commands: PathCommand[] ``` - The content of the boolean shape, defined as an array of path commands. + The content of the path shape, defined as an array of path commands. fills: |- ``` fills: Fill[] @@ -13253,17 +13600,13 @@ Path: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -13300,6 +13643,12 @@ Path: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -13370,6 +13719,14 @@ Path: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -13417,9 +13774,7 @@ Path: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -13743,12 +14098,27 @@ Path: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -13761,7 +14131,7 @@ Path: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -13830,7 +14200,7 @@ Path: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -13894,6 +14264,10 @@ Path: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -13933,7 +14307,7 @@ Path: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -13943,7 +14317,7 @@ Path: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -14339,7 +14713,7 @@ Push: readonly easing?: "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" ``` - Function that the dissolve effect will follow for the interpolation. + Function that the push effect will follow for the interpolation. Defaults to `linear` Rectangle: overview: |- @@ -14375,6 +14749,7 @@ Rectangle: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -14400,6 +14775,7 @@ Rectangle: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -14461,6 +14837,7 @@ Rectangle: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -14478,7 +14855,7 @@ Rectangle: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -14560,17 +14937,13 @@ Rectangle: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -14607,6 +14980,12 @@ Rectangle: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -14677,6 +15056,14 @@ Rectangle: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -14724,9 +15111,7 @@ Rectangle: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. strokes: |- ``` strokes: Stroke[] @@ -15036,12 +15421,27 @@ Rectangle: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -15054,7 +15454,7 @@ Rectangle: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -15123,7 +15523,7 @@ Rectangle: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -15187,6 +15587,10 @@ Rectangle: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -15226,7 +15630,7 @@ Rectangle: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -15236,7 +15640,7 @@ Rectangle: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -15278,6 +15682,7 @@ RulerGuide: orientation: RulerGuideOrientation; position: number; board?: Board; + remove(): void; } ``` @@ -15295,14 +15700,23 @@ RulerGuide: position: number ``` - `position` is the position in the axis in absolute positioning. If this is a board - guide will return the positioning relative to the board. + `position` is the position in the axis in absolute coordinates. If this is a board + guide it will return the position relative to the board. board: |- ``` board?: Board ``` If the guide is attached to a board this will retrieve the board shape + Methods: + remove: |- + ``` + remove(): void + ``` + + Removes the guide from its page. + + Returns void Shadow: overview: |- Interface Shadow @@ -15411,6 +15825,7 @@ ShapeBase: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -15436,6 +15851,7 @@ ShapeBase: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -15499,6 +15915,7 @@ ShapeBase: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -15516,7 +15933,7 @@ ShapeBase: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; } @@ -15591,17 +16008,13 @@ ShapeBase: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -15638,6 +16051,12 @@ ShapeBase: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -15708,6 +16127,14 @@ ShapeBase: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -15755,9 +16182,7 @@ ShapeBase: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. fills: |- ``` fills: Fill[] | "mixed" @@ -16073,12 +16498,27 @@ ShapeBase: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -16091,7 +16531,7 @@ ShapeBase: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -16160,7 +16600,7 @@ ShapeBase: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -16224,6 +16664,10 @@ ShapeBase: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -16263,7 +16707,7 @@ ShapeBase: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -16273,7 +16717,7 @@ ShapeBase: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -16358,7 +16802,7 @@ Slide: readonly easing?: "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" ``` - Function that the dissolve effect will follow for the interpolation. + Function that the slide effect will follow for the interpolation. Defaults to `linear`. Stroke: overview: |- @@ -16479,6 +16923,7 @@ SvgRaw: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -16504,6 +16949,7 @@ SvgRaw: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -16567,6 +17013,7 @@ SvgRaw: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -16584,7 +17031,7 @@ SvgRaw: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; type: "svg-raw"; @@ -16653,17 +17100,13 @@ SvgRaw: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -16700,6 +17143,12 @@ SvgRaw: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -16770,6 +17219,14 @@ SvgRaw: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -16817,9 +17274,7 @@ SvgRaw: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. fills: |- ``` fills: Fill[] | "mixed" @@ -16901,8 +17356,10 @@ SvgRaw: The interactions for the current shape. type: |- ``` - type: "svg-raw" + readonly type: "svg-raw" ``` + + The type of the shape, which is always 'svg-raw' for raw SVG shapes. Methods: getPluginData: |- ``` @@ -17139,12 +17596,27 @@ SvgRaw: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -17157,7 +17629,7 @@ SvgRaw: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -17226,7 +17698,7 @@ SvgRaw: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -17290,6 +17762,10 @@ SvgRaw: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -17329,7 +17805,7 @@ SvgRaw: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -17339,7 +17815,7 @@ SvgRaw: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -17400,6 +17876,7 @@ Text: proportionLock: boolean; constraintsHorizontal: "center" | "left" | "right" | "leftright" | "scale"; constraintsVertical: "center" | "top" | "bottom" | "scale" | "topbottom"; + fixedWhenScrolling: boolean; borderRadius: number; borderRadiusTopLeft: number; borderRadiusTopRight: number; @@ -17425,6 +17902,7 @@ Text: | "luminosity"; shadows: Shadow[]; blur?: Blur; + backgroundBlur?: Blur; exports: Export[]; boardX: number; boardY: number; @@ -17488,6 +17966,7 @@ Text: component(): LibraryComponent | null; detach(): void; swapComponent(component: LibraryComponent): void; + resetOverrides(): void; switchVariant(pos: number, value: string): void; combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; @@ -17505,7 +17984,7 @@ Text: delay?: number, ): Interaction; removeInteraction(interaction: Interaction): void; - applyToken(token: Token, properties: TokenProperty[] | undefined): void; + applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; type: "text"; @@ -17592,17 +18071,13 @@ Text: readonly bounds: Bounds ``` - Returns - - Returns the bounding box surrounding the current shape + The bounding box surrounding the current shape center: |- ``` readonly center: Point ``` - Returns - - Returns the geometric center of the shape + The geometric center of the shape blocked: |- ``` blocked: boolean @@ -17639,6 +18114,12 @@ Text: ``` The vertical constraints applied to the shape. + fixedWhenScrolling: |- + ``` + fixedWhenScrolling: boolean + ``` + + Indicates whether the shape stays fixed in place while scrolling. borderRadius: |- ``` borderRadius: number @@ -17709,6 +18190,14 @@ Text: ``` The blur effect applied to the shape. + backgroundBlur: |- + ``` + backgroundBlur?: Blur + ``` + + The background blur effect applied to the shape. + Background blur creates a blur effect on the content behind the shape, + rather than on the shape's own content. exports: |- ``` exports: Export[] @@ -17756,9 +18245,7 @@ Text: rotation: number ``` - Returns - - Returns the rotation in degrees of the shape with respect to it's center. + The rotation in degrees of the shape with respect to its center. fills: |- ``` fills: Fill[] | "mixed" @@ -17938,7 +18425,7 @@ Text: verticalAlign: "center" | "top" | "bottom" | null ``` - The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used. + The vertical alignment of the text shape. textBounds: |- ``` readonly textBounds: { x: number; y: number; width: number; height: number } @@ -18182,12 +18669,27 @@ Text: swapComponent(component: LibraryComponent): void ``` - TODO + Swaps the component instance for another component, keeping the overrides + when possible. Similar to the "swap component" action on the Penpot interface. + The current shape must be a component copy instance. Parameters * component: LibraryComponent + The new component to replace the current one + + Returns void + resetOverrides: |- + ``` + resetOverrides(): void + ``` + + Resets the overrides of the component copy, restoring all its attributes + (and those of its children) to the ones in the linked main component. + Similar to the "reset overrides" action on the Penpot interface. + The current shape must be a component copy instance. + Returns void switchVariant: |- ``` @@ -18200,7 +18702,7 @@ Text: * pos: number - The position of the poroperty to update + The position of the property to update * value: string The new value of the property @@ -18269,7 +18771,7 @@ Text: Angle in degrees to rotate. * center: { x: number; y: number } | null - Center of the transform rotation. If not send will use the geometri center of the shapes. + Center of the transform rotation. If not sent it will use the geometric center of the shape. Returns void @@ -18333,6 +18835,10 @@ Text: Adds a new interaction to the shape. + If the interaction starts a flow (for example a `navigate-to` action) and + the shape's board is not already part of any flow, a new flow starting at + that board is created automatically, matching the behavior of the editor. + Parameters * trigger: Trigger @@ -18372,7 +18878,7 @@ Text: ``` applyToken: |- ``` - applyToken(token: Token, properties: TokenProperty[] | undefined): void + applyToken(token: Token, properties?: TokenProperty[]): void ``` Applies one design token to one or more properties of the shape. @@ -18382,7 +18888,7 @@ Text: * token: Token is the Token to apply - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -18769,11 +19275,8 @@ TokenBase: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; } ``` @@ -18851,7 +19354,7 @@ TokenBase: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -18861,7 +19364,7 @@ TokenBase: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -18874,7 +19377,7 @@ TokenBase: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -18883,7 +19386,7 @@ TokenBase: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenBorderRadius: @@ -18902,11 +19405,8 @@ TokenBorderRadius: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "borderRadius"; value: string; resolvedValue: number | undefined; @@ -18995,7 +19495,7 @@ TokenBorderRadius: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19005,7 +19505,7 @@ TokenBorderRadius: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19018,7 +19518,7 @@ TokenBorderRadius: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19027,7 +19527,7 @@ TokenBorderRadius: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenShadowValue: @@ -19035,6 +19535,8 @@ TokenShadowValue: Interface TokenShadowValue ========================== + The value of a TokenShadow in its composite form. + ``` interface TokenShadowValue { color: string; @@ -19090,6 +19592,8 @@ TokenShadowValueString: Interface TokenShadowValueString ================================ + The value of a TokenShadow in its composite of strings form. + ``` interface TokenShadowValueString { color: string; @@ -19162,11 +19666,8 @@ TokenShadow: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "shadow"; value: string | TokenShadowValueString[]; resolvedValue: TokenShadowValue[] | undefined; @@ -19257,7 +19758,7 @@ TokenShadow: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19267,7 +19768,7 @@ TokenShadow: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19280,7 +19781,7 @@ TokenShadow: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19289,7 +19790,7 @@ TokenShadow: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenColor: @@ -19308,11 +19809,8 @@ TokenColor: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "color"; value: string; resolvedValue: string | undefined; @@ -19374,8 +19872,10 @@ TokenColor: readonly resolvedValue: string | undefined ``` - The value as defined in the token itself. - It's a rgb color or a reference. + The value calculated by finding all tokens with the same name in active sets + and resolving the references. + + It's a rgb color, or undefined if no value has been found in active sets. Methods: duplicate: |- ``` @@ -19399,7 +19899,7 @@ TokenColor: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19409,7 +19909,7 @@ TokenColor: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19422,7 +19922,7 @@ TokenColor: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19431,7 +19931,7 @@ TokenColor: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenDimension: @@ -19450,11 +19950,8 @@ TokenDimension: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "dimension"; value: string; resolvedValue: number | undefined; @@ -19543,7 +20040,7 @@ TokenDimension: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19553,7 +20050,7 @@ TokenDimension: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19566,7 +20063,7 @@ TokenDimension: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19575,7 +20072,7 @@ TokenDimension: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenFontFamilies: @@ -19594,11 +20091,8 @@ TokenFontFamilies: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "fontFamilies"; value: string | string[]; resolvedValue: string[] | undefined; @@ -19690,7 +20184,7 @@ TokenFontFamilies: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19700,7 +20194,7 @@ TokenFontFamilies: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19713,7 +20207,7 @@ TokenFontFamilies: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19722,7 +20216,7 @@ TokenFontFamilies: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenFontSizes: @@ -19741,11 +20235,8 @@ TokenFontSizes: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "fontSizes"; value: string; resolvedValue: number | undefined; @@ -19834,7 +20325,7 @@ TokenFontSizes: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19844,7 +20335,7 @@ TokenFontSizes: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -19857,7 +20348,7 @@ TokenFontSizes: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -19866,7 +20357,7 @@ TokenFontSizes: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenFontWeights: @@ -19885,11 +20376,8 @@ TokenFontWeights: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "fontWeights"; value: string; resolvedValue: string | undefined; @@ -19979,7 +20467,7 @@ TokenFontWeights: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -19989,7 +20477,7 @@ TokenFontWeights: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20002,7 +20490,7 @@ TokenFontWeights: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20011,7 +20499,7 @@ TokenFontWeights: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenLetterSpacing: @@ -20030,11 +20518,8 @@ TokenLetterSpacing: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "letterSpacing"; value: string; resolvedValue: number | undefined; @@ -20123,7 +20608,7 @@ TokenLetterSpacing: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20133,7 +20618,7 @@ TokenLetterSpacing: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20146,7 +20631,7 @@ TokenLetterSpacing: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20155,7 +20640,7 @@ TokenLetterSpacing: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenNumber: @@ -20174,11 +20659,8 @@ TokenNumber: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "number"; value: string; resolvedValue: number | undefined; @@ -20267,7 +20749,7 @@ TokenNumber: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20277,7 +20759,7 @@ TokenNumber: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20290,7 +20772,7 @@ TokenNumber: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20299,7 +20781,7 @@ TokenNumber: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenOpacity: @@ -20318,11 +20800,8 @@ TokenOpacity: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "opacity"; value: string; resolvedValue: number | undefined; @@ -20412,7 +20891,7 @@ TokenOpacity: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20422,7 +20901,7 @@ TokenOpacity: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20435,7 +20914,7 @@ TokenOpacity: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20444,7 +20923,7 @@ TokenOpacity: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenRotation: @@ -20463,11 +20942,8 @@ TokenRotation: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "rotation"; value: string; resolvedValue: number | undefined; @@ -20557,7 +21033,7 @@ TokenRotation: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20567,7 +21043,7 @@ TokenRotation: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20580,7 +21056,7 @@ TokenRotation: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20589,7 +21065,7 @@ TokenRotation: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenSizing: @@ -20608,11 +21084,8 @@ TokenSizing: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "sizing"; value: string; resolvedValue: number | undefined; @@ -20701,7 +21174,7 @@ TokenSizing: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20711,7 +21184,7 @@ TokenSizing: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20724,7 +21197,7 @@ TokenSizing: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20733,7 +21206,7 @@ TokenSizing: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenSpacing: @@ -20752,11 +21225,8 @@ TokenSpacing: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "spacing"; value: string; resolvedValue: number | undefined; @@ -20845,7 +21315,7 @@ TokenSpacing: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20855,7 +21325,7 @@ TokenSpacing: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -20868,7 +21338,7 @@ TokenSpacing: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -20877,7 +21347,7 @@ TokenSpacing: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenBorderWidth: @@ -20896,11 +21366,8 @@ TokenBorderWidth: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "borderWidth"; value: string; resolvedValue: number | undefined; @@ -20989,7 +21456,7 @@ TokenBorderWidth: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -20999,7 +21466,7 @@ TokenBorderWidth: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -21012,7 +21479,7 @@ TokenBorderWidth: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -21021,7 +21488,7 @@ TokenBorderWidth: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenTextCase: @@ -21040,11 +21507,8 @@ TokenTextCase: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "textCase"; value: string; resolvedValue: string | undefined; @@ -21134,7 +21598,7 @@ TokenTextCase: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -21144,7 +21608,7 @@ TokenTextCase: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -21157,7 +21621,7 @@ TokenTextCase: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -21166,7 +21630,7 @@ TokenTextCase: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenTextDecoration: @@ -21185,11 +21649,8 @@ TokenTextDecoration: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "textDecoration"; value: string; resolvedValue: string | undefined; @@ -21279,7 +21740,7 @@ TokenTextDecoration: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -21289,7 +21750,7 @@ TokenTextDecoration: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -21302,7 +21763,7 @@ TokenTextDecoration: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -21311,7 +21772,7 @@ TokenTextDecoration: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenTypographyValue: @@ -21319,6 +21780,8 @@ TokenTypographyValue: Interface TokenTypographyValue ============================== + The value of a TokenTypography in its composite form. + ``` interface TokenTypographyValue { letterSpacing: number; @@ -21381,6 +21844,8 @@ TokenTypographyValueString: Interface TokenTypographyValueString ==================================== + The value of a TokenTypography in its composite of strings form. + ``` interface TokenTypographyValueString { letterSpacing: string; @@ -21426,9 +21891,9 @@ TokenTypographyValueString: lineHeight: string ``` - The line height, as a number. Note that there not exists an individual - token type line height, only part of a Typography token. If you need to - put here a reference, use a NumberToken. + The line height, as a number. Note that there is no individual line-height + token type; it only exists as part of a Typography token. If you need to + put a reference here, use a Number token. textCase: |- ``` textCase: string @@ -21459,11 +21924,8 @@ TokenTypography: duplicate(): Token; remove(): void; resolvedValueString: string | undefined; - applyToShapes( - shapes: Shape[], - properties: TokenProperty[] | undefined, - ): void; - applyToSelected(properties: TokenProperty[] | undefined): void; + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void; + applyToSelected(properties?: TokenProperty[]): void; type: "typography"; value: string | TokenTypographyValueString; resolvedValue: TokenTypographyValue[] | undefined; @@ -21554,7 +22016,7 @@ TokenTypography: Returns void applyToShapes: |- ``` - applyToShapes(shapes: Shape[], properties: TokenProperty[] | undefined): void + applyToShapes(shapes: Shape[], properties?: TokenProperty[]): void ``` Applies this token to one or more properties of the given shapes. @@ -21564,7 +22026,7 @@ TokenTypography: * shapes: Shape[] is an array of shapes to apply it. - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] an optional list of property names. If omitted, the default properties will be applied. @@ -21577,7 +22039,7 @@ TokenTypography: Returns void applyToSelected: |- ``` - applyToSelected(properties: TokenProperty[] | undefined): void + applyToSelected(properties?: TokenProperty[]): void ``` Applies this token to the currently selected shapes. @@ -21586,7 +22048,7 @@ TokenTypography: Parameters - * properties: TokenProperty[] | undefined + * properties: TokenProperty[] Returns void TokenCatalog: @@ -21607,7 +22069,7 @@ TokenCatalog: themes: TokenTheme[]; sets: TokenSet[]; addTheme(group: { group: string; name: string }): TokenTheme; - addSet(name: { name: string }): TokenSet; + addSet(name: { name: string; active?: boolean }): TokenSet; getThemeById(id: string): TokenTheme | undefined; getSetById(id: string): TokenSet | undefined; } @@ -21628,7 +22090,7 @@ TokenCatalog: ``` The list of sets in this catalog, in the order defined - by the user. The order is important because then same token name + by the user. The order is important because when the same token name exists in several active sets, the latter has precedence. Methods: addTheme: |- @@ -21649,14 +22111,19 @@ TokenCatalog: Returns the created TokenTheme. addSet: |- ``` - addSet(name: { name: string }): TokenSet + addSet(name: { name: string; active?: boolean }): TokenSet ``` Creates a new TokenSet and adds it to the catalog. + Newly created sets are **inactive** by default: only active sets + affect shapes and reference resolution. Pass `active: true` to create + an already-active set, or activate it later via `set.active = true` / + `set.toggleActive()`. + Parameters - * name: { name: string } + * name: { name: string; active?: boolean } The name of the set (required). It may contain a group path, separated by `/`. @@ -21796,7 +22263,7 @@ TokenSet: * type: { type: TokenType; name: string; value: TokenValueString } - Thetype of token. + The type of the token. Returns Token @@ -21829,9 +22296,9 @@ TokenTheme: sets that are *not* in this theme, because they may have been activated by other themes. - Themes may be gruped. At any time only one of the themes in a group + Themes may be grouped. At any time only one of the themes in a group may be active. But there may be active themes in other groups. This - allows to define multiple "axis" for theming (e.g. color scheme, + allows defining multiple "axis" for theming (e.g. color scheme, density or brand). When a TokenSet is activated or deactivated directly, all themes @@ -21847,8 +22314,8 @@ TokenTheme: active: boolean; toggleActive(): void; activeSets: TokenSet[]; - addSet(tokenSet: TokenSet): void; - removeSet(tokenSet: TokenSet): void; + addSet(tokenSet: string | TokenSet): void; + removeSet(tokenSet: string | TokenSet): void; duplicate(): TokenTheme; remove(): void; } @@ -21869,14 +22336,14 @@ TokenTheme: readonly externalId: string | undefined ``` - Optional identifier that may exists if the theme was imported from an + Optional identifier that may exist if the theme was imported from an external tool that uses ids in the json file. group: |- ``` group: string ``` - The group name of the theme. Can be empt string. + The group name of the theme. Can be an empty string. name: |- ``` name: string @@ -21891,7 +22358,7 @@ TokenTheme: Indicates if the theme is currently active. activeSets: |- ``` - activeSets: TokenSet[] + readonly activeSets: TokenSet[] ``` The sets that will be activated if this theme is activated. @@ -21906,26 +22373,30 @@ TokenTheme: Returns void addSet: |- ``` - addSet(tokenSet: TokenSet): void + addSet(tokenSet: string | TokenSet): void ``` Adds a set to the list of the theme. Parameters - * tokenSet: TokenSet + * tokenSet: string | TokenSet + + a `TokenSet` or the id of a token set. Returns void removeSet: |- ``` - removeSet(tokenSet: TokenSet): void + removeSet(tokenSet: string | TokenSet): void ``` Removes a set from the list of the theme. Parameters - * tokenSet: TokenSet + * tokenSet: string | TokenSet + + a `TokenSet` or the id of a token set. Returns void duplicate: |- @@ -22029,7 +22500,10 @@ Variants: Interface Variants ================== - TODO + Represents a Variant in Penpot: the grouping of all the VariantComponents that + belong to the same VariantContainer, together with their shared properties. + This interface provides attributes and actions that affect the Variant as a + whole (not a single VariantComponent). ``` interface Variants { @@ -22063,7 +22537,7 @@ Variants: The unique identifier of the library to which the variant belongs. properties: |- ``` - properties: string[] + readonly properties: string[] ``` A list with the names of the properties of the Variant @@ -22073,7 +22547,7 @@ Variants: currentValues(property: string): string[] ``` - A list of all the values of a property along all the variantComponents of this Variant + A list of all the values of a property across all the VariantComponents of this Variant Parameters @@ -22281,25 +22755,25 @@ Bounds: Properties: x: |- ``` - x: number + readonly x: number ``` Top-left x position of the rectangular area defined y: |- ``` - y: number + readonly y: number ``` Top-left y position of the rectangular area defined width: |- ``` - width: number + readonly width: number ``` Width of the represented area height: |- ``` - height: number + readonly height: number ``` Height of the represented area @@ -22415,37 +22889,37 @@ ImageData: Properties: name: |- ``` - name?: string + readonly name?: string ``` The optional name of the image. width: |- ``` - width: number + readonly width: number ``` The width of the image. height: |- ``` - height: number + readonly height: number ``` The height of the image. mtype: |- ``` - mtype?: string + readonly mtype?: string ``` The optional media type of the image (e.g., 'image/png', 'image/jpeg'). id: |- ``` - id: string + readonly id: string ``` The unique identifier for the image. keepAspectRatio: |- ``` - keepAspectRatio?: boolean + readonly keepAspectRatio?: boolean ``` Whether to keep the aspect ratio of the image when resizing. @@ -22456,7 +22930,7 @@ ImageData: data(): Promise> ``` - Returns the imaged data as a byte array. + Returns the image data as a byte array. Returns Promise> LibraryContext: @@ -22557,11 +23031,11 @@ Point: Properties: x: |- ``` - x: number + readonly x: number ``` y: |- ``` - y: number + readonly y: number ``` RulerGuideOrientation: overview: |- @@ -22572,6 +23046,8 @@ RulerGuideOrientation: RulerGuideOrientation: "horizontal" | "vertical" ``` + The possible orientations for a ruler guide: 'horizontal' or 'vertical'. + Referenced by: Board, Page, RulerGuide, VariantContainer members: {} Shape: @@ -22679,10 +23155,16 @@ TokenValueString: | TokenTypographyValueString | string | string[] + | number ``` Any possible type of value field in a token. + Token values are always stored as strings, including for numeric token + types such as `spacing`, `dimension` or `borderRadius` (e.g. `"16"` or + `"16px"`). A plain `number` is also accepted on input and coerced to its + string representation. + Referenced by: TokenSet members: {} Token: From 93b5baa9fcfa829a5066d643ced3e64bd3ce45f4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 6 Jul 2026 16:00:53 +0000 Subject: [PATCH 02/63] :bug: Relocate MCP run directory out of the npm-managed install directory For the npm MCP package, adjust the launcher to specifically prepare the runtime directory: The package contents are copied once per version to a runtime directory owned by the launcher. The npm-owned package directory cannot be used. While installing there worked for older npm/npx version, we have to assume it is read-only. Fixes #9947 --- mcp/bin/mcp-local.js | 50 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js index b77b245dea..feecbc9e39 100644 --- a/mcp/bin/mcp-local.js +++ b/mcp/bin/mcp-local.js @@ -1,21 +1,61 @@ #!/usr/bin/env node +// Entry point (bin) of the published @penpot/mcp package. +// This script is not intended to be run from a source checkout; there, use +// `pnpm run bootstrap` directly, as described in the README. + const { execSync } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); -const root = path.resolve(__dirname, ".."); -const pkg = require(path.join(root, "package.json")); +const packageRoot = path.resolve(__dirname, ".."); +const pkg = require(path.join(packageRoot, "package.json")); function pnpmVersion() { const match = (pkg.packageManager || "").match(/^pnpm@([^+]+)/); return match ? match[1] : "latest"; } -function run(command) { - execSync(command, { cwd: root, stdio: "inherit" }); +/** + * Prepares the directory in which to bootstrap and run. + * + * The package contents are copied once per version to a runtime directory + * owned by this launcher. The npm-owned package directory cannot be used; + * we have to assume it is read-only (#9947). + * + * @returns {string} the directory to run the bootstrap in + */ +function prepareRuntimeRoot() { + const cacheBase = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); + const runtimeRoot = path.join(cacheBase, "penpot-mcp", pkg.version); + if (fs.existsSync(runtimeRoot)) { + return runtimeRoot; + } + + // copy to a temporary sibling first and rename, so an interrupted copy + // cannot leave a partial runtime directory behind + console.log(`Preparing runtime directory ${runtimeRoot} ...`); + const tempRoot = `${runtimeRoot}.tmp-${process.pid}`; + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.cpSync(packageRoot, tempRoot, { + recursive: true, + filter: (src) => path.basename(src) !== "node_modules", + }); + try { + fs.renameSync(tempRoot, runtimeRoot); + } catch (error) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + if (!fs.existsSync(runtimeRoot)) { + throw error; + } + // a concurrent launch created the runtime directory in the meantime; use it + } + return runtimeRoot; } +const root = prepareRuntimeRoot(); + // pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml // and restored here before bootstrap runs. const distLock = path.join(root, "pnpm-lock.dist.yaml"); @@ -25,7 +65,7 @@ if (fs.existsSync(distLock)) { } try { - run(`npx -y pnpm@${pnpmVersion()} run bootstrap`); + execSync(`npx -y pnpm@${pnpmVersion()} run bootstrap`, { cwd: root, stdio: "inherit" }); } catch (error) { process.exit(error.status ?? 1); } From b9e0421f79f7a8e14201c537c67fc53a74511b5f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 24 Jun 2026 16:52:54 +0200 Subject: [PATCH 03/63] :books: Update and improve devenv documentation * Fix stale references to `run-devenv-agentic` and `start-devenv` * Improve clarity in description of workspaces * Improve section on agentic devenv Co-authored-by: Michael Panchenko --- .devenv/README.md | 2 +- .serena/memories/devenv/core.md | 8 +-- .../developer/agentic-devenv.md | 44 +++++++-------- docs/technical-guide/developer/devenv.md | 54 ++++++++++--------- 4 files changed, 53 insertions(+), 55 deletions(-) diff --git a/.devenv/README.md b/.devenv/README.md index d56e327dd0..f56b1e09d1 100644 --- a/.devenv/README.md +++ b/.devenv/README.md @@ -52,7 +52,7 @@ built fresh from `shared/codex.toml` + `templates/codex.toml` at launch. `manage.sh`. * **`mcp/`** (Claude Code, opencode) is the result of merging `shared/` with the port-substituted `templates/`. `manage.sh` writes these on every - `run-devenv-agentic` pass. Gitignored, dedicated paths with no developer + `run-devenv --agentic` pass. Gitignored, dedicated paths with no developer content — never edit by hand, your edits will be overwritten on the next reconcile. * **`.vscode/mcp.json`** is the same merge, but written to the path VS Code diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index e612a5f923..82e4e704f3 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -25,7 +25,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par ## Worker policy -Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv-agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. +Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. ## Port layout @@ -44,13 +44,13 @@ Everything else (frontend dev, backend API, exporter, storybook, REPLs, plugin d ## Tmux + MCP routing -`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv-agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv-agentic` — the conditional windows are only added at session create time. +`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv --agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv --agentic` — the conditional windows are only added at session create time. MCP plugin routing is same-origin: frontend uses `/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`. ## Workspace orchestration (ws1+) -Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv-agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it. +Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv --agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it. `sync-workspace wsN`: 1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape. @@ -63,7 +63,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi ## CLI surface -- `run-devenv-agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up. +- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up. - `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra. - `run-devenv`: legacy alias, ws0 non-agentic attached. - `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing. diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md index ed362ce385..8d943dcd93 100644 --- a/docs/technical-guide/developer/agentic-devenv.md +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -1,30 +1,22 @@ --- -title: 3.11. Agentic Development Environment +title: 3.11. Agentic Dev Environment desc: Dive into agentic Penpot development. --- -# Agentic Development Environment +# Agentic Dev Environment The agentic DevEnv is an extension of the standard DevEnv (the [general DevEnv instructions](/technical-guide/developer/devenv/) apply), optimised for AI agent-based development. It adds MCP servers (Penpot, Serena, Playwright) and supports a launcher that wires them into your AI client. -Two things to know up front: - -- **Parallel workspaces are first-class.** Run several devenv instances side - by side - one per AI agent if you like - each with its own source-tree - clone, ports, and tmux session. Pass `--ws N` to target one. -- **Your existing AI-client config is preserved.** The launcher loads a - per-workspace MCP config on top of your global one. - ## Quick Start 1. **Bring up one or more workspaces**[^cfg]: ```bash - ./manage.sh run-devenv-agentic # ws0 (the live repo) - ./manage.sh run-devenv-agentic --ws 1 # ws1 (sibling clone) + ./manage.sh run-devenv --agentic --attach # ws0 (the live repo) + ./manage.sh run-devenv --agentic --ws 1 # ws1 (sibling clone) ``` Add `--ws 2`, `--ws 3`, … for more parallel workspaces. @@ -47,20 +39,20 @@ Two things to know up front: "MCP Server" on. The agentic DevEnv runs the MCP server in single-user mode - the key and proxied URL shown in the UI are not needed. -4. **Launch your AI client** against the workspace you want it to drive: +4. **Launch your AI client**. Either use your manually configured client + (as described below) or conveniently launch an explicitly supported client + against the workspace you want it to drive: ```bash - ./manage.sh start-coding-agent claude # ws0 + ./manage.sh start-coding-agent claude # ws0 (main) ./manage.sh start-coding-agent claude --ws 1 # ws1 ``` Supported clients: `claude` | `opencode` | `vscode` | `codex`. -5. **Attach to the tmux session** for the workspace (optional): + Note: The launcher loads a per-workspace MCP config on top of your global one (if any). - ```bash - ./manage.sh attach-devenv # ws0 - ./manage.sh attach-devenv --ws 1 # ws1 - ``` +5. **Work within your client**, which is now equipped with extended capabilities + for Penpot development (see below for details). 6. **Shut down workspaces** with `./manage.sh stop-devenv`, either one by one or all at once. You cannot shut down `ws0` if any other workspace is still running, since it's the worker-bearer. @@ -113,10 +105,10 @@ var penpotFlags = "enable-mcp"; ``` The file is gitignored and lives in the live repo only. On every -`run-devenv-agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is +`run-devenv --agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is copied into the workspace clone on the **initial** sync only - subsequent `--sync` passes leave the workspace's copy alone so per-workspace -customisations survive. `run-devenv-agentic` refuses to start if the file is +customisations survive. `run-devenv --agentic` refuses to start if the file is missing. **Browser remote debugging.** The Playwright MCP server drives a real @@ -148,13 +140,13 @@ devenv image itself (add a tool, change a base layer): ./manage.sh build-devenv --local ``` -The default `run-devenv-agentic` flow pulls the published image +The default `run-devenv --agentic` flow pulls the published image automatically, so regular users never run this. ### Bringing up workspaces ```bash -./manage.sh run-devenv-agentic \ +./manage.sh run-devenv --agentic \ [--ws N] [--sync] [--serena-context CTX] \ [--git-user-name NAME] [--git-user-email EMAIL] ``` @@ -171,7 +163,7 @@ every bring-up so you don't compute offsets by hand. See the semantics, and stop ordering. **Git identity for agent commits.** Coding agents typically need to commit -inside the devenv, so `run-devenv-agentic` wires a Git identity into the +inside the devenv, so `run-devenv --agentic` wires a Git identity into the container's global config on every bring-up. By default it propagates the host's effective `git config user.{name,email}` (local repo override wins over `~/.gitconfig`, matching what `git commit` on the host would record). @@ -189,7 +181,7 @@ the full mechanics. > > ```bash > ./manage.sh stop-devenv -> ./manage.sh run-devenv-agentic +> ./manage.sh run-devenv --agentic > ``` ### Launching an AI client @@ -198,7 +190,7 @@ The agentic environment supports any AI client, one just needs to set the right see [manual configuration](#manual-ai-client-configuration) below. For some popular clients, the `manage.sh` CLI offers direct support through the following mechanism: -Every `run-devenv-agentic` regenerates three MCP-client config files with +Every `run-devenv --agentic` regenerates three MCP-client config files with the workspace's ports baked in; Codex is wired up at launch instead (see below): diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 88ae8db6ca..5aaab86e7f 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -45,31 +45,38 @@ This is an incomplete list of devenv related subcommands found on manage.sh script: ```bash -./manage.sh build-devenv --local # builds the local devenv docker image -./manage.sh start-devenv # brings up the shared infra + ws0 in background -./manage.sh run-devenv # ws0 with non-agentic tmux, attached (legacy alias) -./manage.sh run-devenv-agentic # one agentic instance; --ws to target ws1+; see below -./manage.sh attach-devenv # re-attaches to the tmux session of a running instance -./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last -./manage.sh drop-devenv # removes containers (data volumes preserved) +./manage.sh build-devenv --local # builds the local devenv docker image +./manage.sh start-devenv # brings up the shared infra + ws0 in background +./manage.sh run-devenv --attach # bring up main devenv instance and attach to its tmux session +./manage.sh run-devenv --agentic --attach # bring up main devenv instance in agentic mode and attach tmux +./manage.sh attach-devenv # re-attaches to the tmux session of a running instance +./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last +./manage.sh drop-devenv # removes containers (data volumes preserved) ``` +### Agentic Mode + +The `--agentic` flag enables additional features for AI-assisted development. +See the dedicated section [Agentic Dev Environment](../agentic-devenv/) for details. + ### Parallel workspaces -The devenv runs as separate compose projects: shared infra (`penpotdev-infra`: -Postgres, MinIO, mailer, LDAP) plus one `penpotdev-wsN` project per runtime -instance. `ws0` (a.k.a. `main`) binds the live repo; `ws1+` bind clones the -developer maintains explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/` -(default `~/.penpot/penpot_workspaces/`). +The devenv runs as separate compose projects: + * shared infra (`penpotdev-infra`: Postgres, MinIO, mailer, LDAP) + * `penpotdev-wsN` project per runtime instance. + - `ws0` (a.k.a. `main`) is the current state of your repo; + - `ws1` and up are clones that you maintain explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/` + (default `~/.penpot/penpot_workspaces/`). You can explicitly sync them + with the `--sync` flag (automatic on first start). -Each call to `run-devenv-agentic` brings up one instance, and ws0 is always +Each call to `run-devenv` brings up one instance, and ws0 is always running whenever any ws1+ is — `--ws N` (N≥1) auto-starts ws0 first if it isn't already up: ```bash -./manage.sh run-devenv-agentic # main (ws0) -./manage.sh run-devenv-agentic --ws 1 # ws0 if needed, then ws1 -./manage.sh run-devenv-agentic --ws 2 --sync # ws2, re-seeding from the live repo +./manage.sh run-devenv # main (ws0) +./manage.sh run-devenv --ws 1 # ws0 if needed, then ws1 +./manage.sh run-devenv --ws 2 --sync # ws2, re-seeding from the live repo ``` Starting an instance that is already running is an error. `--sync` is only @@ -102,12 +109,12 @@ Host ports are offset by `10000 × N`: | Serena MCP | `http://localhost:14181` | `http://localhost:24181` | `http://localhost:34181` | Container-internal ports stay fixed. Target a specific instance with -`--ws N` on `attach-devenv`, `run-devenv-agentic`, `stop-devenv`, +`--ws N` on `attach-devenv`, `run-devenv`, `stop-devenv`, `start-coding-agent`, `run-devenv-shell`, and `isolated-shell`. `--ws` accepts a **non-negative integer only** — `--ws main` or `--ws ws1` is rejected, keeping the flag shape uniform across commands. `run-devenv` is -ws0-only and takes no workspace flag. `run-devenv-agentic` also accepts -`--serena-context CTX` and `--git-user-name NAME` / `--git-user-email +ws0-only and takes no workspace flag. `run-devenv` also accepts +`--serena-context CTX` (used together with `--agentic`) and `--git-user-name NAME` / `--git-user-email EMAIL` (see below). Configuration lives in one tracked file, `docker/devenv/defaults.env` (the @@ -116,7 +123,7 @@ derived and injected automatically, so there is no per-instance file to edit. ### Git identity inside the container -`run-devenv-agentic` wires a Git author identity into the container's +`run-devenv` wires a Git author identity into the container's **global** git config (`git config --global user.{name,email}`) so commits made from inside the devenv carry a real author/committer. Without this, the container would commit as the unconfigured `penpot@` @@ -130,7 +137,7 @@ returns at the working directory `manage.sh` is invoked from — local `git commit` on the host would record. If neither is available the script prints a warning and continues — commits will fail inside the container until you set an identity. The values are applied every time -`run-devenv-agentic` brings an instance up (idempotent), so re-running +`run-devenv` brings an instance up (idempotent), so re-running with different flags is the way to change the in-container identity. ### Shared state and workers @@ -185,11 +192,10 @@ docker rm penpotdev-postgres-1 penpotdev-minio-1 penpotdev-minio-setup-1 \ docker network rm penpotdev_default 2>/dev/null # Bring up infra + ws0 under the new project layout. -./manage.sh run-devenv-agentic +./manage.sh run-devenv ``` -After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` / -`run-devenv-agentic` commands work against the new layout. The legacy +After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` work against the new layout. The legacy `penpotdev` compose project is no longer used. Having the container running and tmux opened inside the container, From fdb529138439b59d328aefbd149398c39cbd6f27 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Jul 2026 12:03:02 +0200 Subject: [PATCH 04/63] :books: Update changelog --- CHANGES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 9fc930b9f6..adf585bd3a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,6 +41,7 @@ - Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274)) - Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210)) - Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444)) +- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393)) ### :bug: Bugs fixed @@ -148,6 +149,14 @@ - Fix emojis without fill but with outer stroke not being rendered [#10473](https://github.com/penpot/penpot/issues/10473) (PR: [#10519](https://github.com/penpot/penpot/pull/10519)) - Fix texts with lots of emojis not being rendered across multiple tiles [#10474](https://github.com/penpot/penpot/issues/10474) (PR: [#10504](https://github.com/penpot/penpot/pull/10504)) - Fix artifacts in texts with inner strokes [#10476](https://github.com/penpot/penpot/issues/10476) (PR: [#10509](https://github.com/penpot/penpot/pull/10509)) +- Fix open overlay position control showing only center icon instead of full grid [#10176](https://github.com/penpot/penpot/issues/10176) (PR: [#10512](https://github.com/penpot/penpot/pull/10512)) +- Fix wrong text color in selection size badge [#10256](https://github.com/penpot/penpot/issues/10256) (PR: [#10393](https://github.com/penpot/penpot/pull/10393)) +- Fix several issues with plugins [#10394](https://github.com/penpot/penpot/issues/10394) +- Fix pixel grid rendered on top of the rulers [#10426](https://github.com/penpot/penpot/issues/10426) (PR: [#10430](https://github.com/penpot/penpot/pull/10430)) +- Fix double-clicking text without fills creating a black file [#10472](https://github.com/penpot/penpot/issues/10472) (PR: [#10483](https://github.com/penpot/penpot/pull/10483)) +- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492)) +- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533)) +- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575)) ## 2.16.2 From 83ce70d02dbe4d9c29e3741b7cd8ce748b972038 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Jul 2026 12:07:40 +0200 Subject: [PATCH 05/63] :paperclip: Update version on mcp package.json --- mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/package.json b/mcp/package.json index 5efd15acc9..df573f34af 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.16.0", + "version": "2.17.0-rc.2", "description": "MCP server for Penpot integration", "license": "MPL-2.0", "bin": { From aadae99b918b4d1bef8759df3a335ce6b65c24e6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Jul 2026 12:43:51 +0200 Subject: [PATCH 06/63] :paperclip: Add create-issue skill --- .opencode/skills/create-issue/SKILL.md | 27 +++ .opencode/skills/gh-issue-from-pr/SKILL.md | 227 ------------------- .opencode/skills/issue-title/SKILL.md | 123 ---------- .serena/memories/workflow/creating-issues.md | 195 +++++++++++++++- 4 files changed, 221 insertions(+), 351 deletions(-) create mode 100644 .opencode/skills/create-issue/SKILL.md delete mode 100644 .opencode/skills/gh-issue-from-pr/SKILL.md delete mode 100644 .opencode/skills/issue-title/SKILL.md diff --git a/.opencode/skills/create-issue/SKILL.md b/.opencode/skills/create-issue/SKILL.md new file mode 100644 index 0000000000..90a0fc3b75 --- /dev/null +++ b/.opencode/skills/create-issue/SKILL.md @@ -0,0 +1,27 @@ +--- +name: create-issue +description: Create or update GitHub issues (from PR, from draft body, retitle existing). Routes to the canonical flow in `mem:workflow/creating-issues`. +--- + +# Skill: create-issue + +Entry point for all GitHub issue work. All rules (title derivation, metadata, +body templates, Issue Type IDs), all flows, and all `gh` / GraphQL commands +live in `mem:workflow/creating-issues` (file: +`.serena/memories/workflow/creating-issues.md`). This skill routes to the +right flow. + +## When to Use + +- **Create from PR** — PR exists; the issue is the changelog/release unit, + the PR is the implementation. Issue = WHAT, PR = HOW. + → memory section **Creating Issues from PRs** +- **Create from draft body** — Taiga story, user report, discussion; no PR + yet. + → memory section **Creating Issues from Draft Body** +- **Retitle existing issue** — current title is vague, prefixed, or stale. + → memory section **Retitling an Existing Issue** + +Everything else (title derivation, metadata policy, body templates, Issue +Type IDs, create/verify/cleanup commands) lives in the memory — go to the +matching section there. diff --git a/.opencode/skills/gh-issue-from-pr/SKILL.md b/.opencode/skills/gh-issue-from-pr/SKILL.md deleted file mode 100644 index bb7fe10cf9..0000000000 --- a/.opencode/skills/gh-issue-from-pr/SKILL.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -name: gh-issue-from-pr -description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type. ---- - -# Skill: gh-issue-from-pr - -Create a GitHub issue that captures the **WHAT** (user-facing feature or -bug) from an existing PR that describes the **HOW** (implementation). -Used when the project board needs an issue as the primary changelog/release unit. - -## When to Use - -- Create a tracking issue from a PR for changelog purposes -- Extract the user-facing problem/feature from a PR's implementation details -- Assign milestone, project, labels, and issue type to a new issue derived from a PR - -## Prerequisites - -- `gh` CLI authenticated (`gh auth status`) -- Permission to create issues and edit PRs in the target repository - -## Workflow - -### 1. Understand the PR - -```bash -gh pr view --repo penpot/penpot \ - --json title,body,author,labels,baseRefName,mergedAt,state,milestone -``` - -Identify: - -- **WHAT** — user-facing problem or feature. Goes into the issue. - Describe symptoms and impact, not internal mechanisms. -- **HOW** — implementation details. These belong in the PR, not the issue. - -### 2. Determine metadata - -| Field | Source | Rule | -|-------|--------|------| -| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. | -| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). | -| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. | -| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. | -| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. | -| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. | - -### 3. Write the issue body - -**Bug template:** - -```markdown -### Description - - - -### Steps to reproduce - -1. -2. - -### Expected behavior - - - -### Affected versions - - -``` - -**Enhancement template:** - -```markdown -### Description - - - -### Use case - - - -### Affected versions - - -``` - -### 4. Create the issue - -Write the body to a temp file to avoid shell quoting issues: - -```bash -cat > /tmp/issue-body.md << 'ISSUE_BODY' - -ISSUE_BODY -``` - -Create: - -```bash -gh issue create \ - --repo penpot/penpot \ - --title "" \ - --label "community contribution" \ # only if PR has this label - --milestone "<milestone>" \ - --project "Main" \ - --body-file /tmp/issue-body.md -``` - -Output: `https://github.com/penpot/penpot/issues/<NUMBER>` - -### 5. Assign to the PR author - -Assign the issue to the PR author so they're responsible for it: - -```bash -AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login') -gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR" -``` - -### 6. Set the Issue Type - -`gh issue create` can't set the Issue Type directly. Use GraphQL. - -Get the issue's GraphQL node ID: - -```bash -ISSUE_ID=$(gh api graphql -f query=' -query { repository(owner: "penpot", name: "penpot") { - issue(number: <ISSUE_NUMBER>) { id } -}}' --jq '.data.repository.issue.id') -``` - -Issue Type IDs for the Penpot repo: - -| Type | ID | -|------|----| -| Bug | `IT_kwDOAcyBPM4AX5Nb` | -| Enhancement | `IT_kwDOAcyBPM4B_IQN` | -| Feature | `IT_kwDOAcyBPM4AX5Nf` | -| Task | `IT_kwDOAcyBPM4AX5NY` | -| Question | `IT_kwDOAcyBPM4B_IQj` | -| Docs | `IT_kwDOAcyBPM4B_IQz` | - -Set it: - -```bash -gh api graphql -f query=' -mutation { - updateIssue(input: { - id: "'"$ISSUE_ID"'" - issueTypeId: "<TYPE_ID>" - }) { - issue { number issueType { name } } - } -}' -``` - -### 7. Verify - -```bash -gh issue view <ISSUE_NUMBER> --repo penpot/penpot \ - --json title,milestone,projectItems,labels \ - --jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}' - -gh api graphql -f query=' -query { repository(owner: "penpot", name: "penpot") { - issue(number: <ISSUE_NUMBER>) { issueType { name } } -}}' --jq '.data.repository.issue.issueType.name' -``` - -### 8. Link the PR to the issue - -Append `Closes #<ISSUE_NUMBER>` to the PR body: - -```bash -gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md -printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md -gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md - -# Verify -gh pr view <PR_NUMBER> --repo penpot/penpot --json body \ - --jq '.body | test("Closes #<ISSUE_NUMBER>")' -``` - -**Note:** If the PR is already merged, `Closes` won't auto-close the issue -— it only creates the "Development" sidebar link. This is the desired -behavior since the issue is a tracking artifact. - -### 9. Clean up - -```bash -rm -f /tmp/issue-body.md /tmp/pr-body.md -``` - -## Label rules - -| PR has | Issue gets | -|--------|-----------| -| `community contribution` | `community contribution` | -| `bug`, `enhancement` | *(skip — redundant with Issue Type)* | -| `backport candidate` | *(skip — workflow label)* | -| `team-qa` | *(skip — workflow label)* | - -## Issue Type mapping - -| PR label(s) / title prefix | Issue Type | -|----------------------------|-----------| -| `bug` or `:bug:` | Bug | -| `enhancement` or `:sparkles:` or `:tada:` | Enhancement | -| Feature / epic | Feature | -| Documentation | Docs | -| None of the above | Task | - -## Key Principles - -- **Issue = WHAT, PR = HOW.** Never put implementation details in the - issue body. The issue is for users, QA, and changelog readers. -- **Copy the milestone from the PR.** Don't guess based on branch names. - If the PR has no milestone, create the issue without one. -- **Set Issue Type via GraphQL** — `gh issue create` can't set it. -- **Link via PR body** — `Closes #<NUMBER>` creates the "Development" - sidebar link automatically. -- **One issue per PR** — even if a PR fixes multiple things, create a - single issue that summarizes the overall change. -- **Community attribution:** if the PR has the `community contribution` - label or the author is not a core team member, add the label to the issue. diff --git a/.opencode/skills/issue-title/SKILL.md b/.opencode/skills/issue-title/SKILL.md deleted file mode 100644 index 6cd8c14fc5..0000000000 --- a/.opencode/skills/issue-title/SKILL.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: issue-title -description: Derive a clear, well-formatted title for a GitHub issue from its description body, using descriptive present-tense for bugs and imperative mood for features, always including the "where" (location in the UI/module). ---- - -# Skill: issue-title - -Derive a concise, descriptive title for a GitHub issue based on its body -content. Use **descriptive present tense for bugs** (e.g. "Plugin API -crashes when setting text fills") and **imperative mood for features** (e.g. -"Add customizable dash and gap controls"). No emoji or type prefixes -(`feat:`, `bug:`, `feature:`, etc.). - -Can be used both when **creating a new issue** and when **updating an -existing one** that has a vague or outdated title. - -## When to Use - -- Creating a new issue and need a well-formatted title from the draft body -- An existing issue has a vague, outdated, or auto-generated title (e.g. - `[PENPOT FEEDBACK]: ...`, `feature: ...`) -- The current title doesn't reflect the actual content of the description -- The title is missing the "where" (which part of the UI/module is affected) - -## Prerequisites - -- `gh` CLI authenticated (`gh auth status`) - -## Workflow - -### 1. Get the issue body - -For an **existing issue**, fetch it: - -```bash -gh issue view <NUMBER> --repo penpot/penpot --json title,body -``` - -For a **new issue**, read the draft body from wherever it was provided -(Taiga link, user report, discussion, etc.). - -### 2. Read the body and derive a title - -Extract the core problem or request from the description. Distinguish between -bug reports and feature requests: - -**Bug titles (descriptive, present tense):** -Describe the symptom as it appears to the user. Format: -`[Where] [present-tense verb] when [condition]` - -- *"Plugin API crashes when setting text fills"* -- *"Canvas renders glitches when zooming quickly"* -- *"French Canada locale falls back to French (fr) translations"* -- *"Text layer content is not deleted when WebGL render is enabled"* - -Do **not** start bug titles with "Fix" or any imperative verb. The title -should state what's broken, not command a fix. - -**Feature / Enhancement titles (imperative mood):** -Command what should be built. Format: -`[Imperative verb] [what] in/on [where]` - -- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"* -- *"Show user, timestamp, and hash in the workspace history panel like git commits"* -- *"Validate shape on add-object to catch malformed inputs early"* - -**Universal rules (both types):** -- **Include the "where"** — specify the UI location or module (e.g. - "in the sidebar", "in the workspace history panel", "on the stroke - options") -- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, - `[PENPOT FEEDBACK]`, etc. -- **No emoji** — plain text only -- **Be specific** — prefer concrete detail over generality. If the - description mentions two related problems, capture both. - -**Examples:** - -| Original / draft title | Type | New title | -|---|---|---| -| `[PENPOT FEEDBACK]: WebGL` | Bug | `Canvas renders glitches when zooming quickly — text appears distorted and nodes have background-colored rectangles` | -| `bug: flatten-nested-tokens-json uses $type instead of $value as the DTCG token/group discriminator` | Bug | `Token import fails when group-level type inheritance is used — parser misidentifies groups as tokens` | -| `feature: Dashed stroke customization` | Feature | `Add customizable dash and gap length controls to dashed strokes in the sidebar` | -| `feature: Add more detail to history of actions` | Feature | `Show user, timestamp, and hash in the workspace history panel like git commits` | - -### 3. Apply the title - -**If updating an existing issue:** - -```bash -gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>" -``` - -**If creating a new issue:** - -```bash -gh issue create --repo penpot/penpot --title "<NEW TITLE>" --body "<BODY>" -``` - -### 4. Confirm - -For updates, the command returns the issue URL. Verify by optionally fetching -again: - -```bash -gh issue view <NUMBER> --repo penpot/penpot --json title -``` - -## Key Principles - -- **Bug titles describe the symptom** — present tense, 3rd person: - "crashes", "fails", "shows", "is cut off", "does not load". Do not - start with "Fix" or "Bug:". -- **Feature titles use imperative mood** — command form: "Add", "Show", - "Use", "Validate", "Support", "Toggle". -- **Always include the "where"** — a title like "Crashes when zooming" - is too vague; "Canvas crashes when zooming quickly" is clear. -- **No prefixes, no emoji** — strip all type labels and decorative - characters from the title. -- **Derive from the body, not the current title** — the body contains - the real detail; the current title may be auto-generated or stale. -- **Two problems → cover both** — if the description has two distinct - but related issues, capture both in the title joined by "and". diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 42a07f7887..46ca109c3c 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -155,6 +155,199 @@ query { repository(owner: "penpot", name: "penpot") { rm -f /tmp/issue-body.md ``` +## Creating Issues from PRs + +Used when the project board needs an issue as the primary changelog/release +unit and the PR describes the implementation. The issue is the **WHAT** +(user-facing), the PR is the **HOW** (implementation). + +### Fetch the PR + +```bash +gh pr view <PR_NUMBER> --repo penpot/penpot \ + --json title,body,author,labels,baseRefName,mergedAt,state,milestone +``` + +Identify: + +- **WHAT** — user-facing problem or feature. Goes into the issue. + Describe symptoms and impact, not internal mechanisms. +- **HOW** — implementation details. These belong in the PR, not the issue. + +### Determine metadata + +- **Title:** rewrite from user perspective using the title rules above. Strip + leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on + observable behavior. +- **Labels:** copy `community contribution` if present on the PR. +- **Milestone:** always copy what's on the PR. + + ```bash + gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title' + ``` + + If the PR has no milestone, create the issue without one. +- **Project:** `Main`. +- **Body:** extract the user-facing section (steps to reproduce or feature + description). Omit internal details. Use the templates above. +- **Issue Type:** use the mapping table above (also handles `:bug:` / + `:sparkles:` / `:tada:` title prefixes). + +### Create the issue + +```bash +cat > /tmp/issue-body.md << 'ISSUE_BODY' +<body content here> +ISSUE_BODY + +gh issue create \ + --repo penpot/penpot \ + --title "<Title>" \ + --label "community contribution" \ # only if PR has this label + --milestone "<milestone>" \ + --project "Main" \ + --body-file /tmp/issue-body.md +``` + +Output: `https://github.com/penpot/penpot/issues/<NUMBER>` + +### Assign to the PR author + +```bash +AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login') +gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR" +``` + +### Set Issue Type and verify + +See the **Setting the Issue Type** and **Verification** sections above — the +GraphQL mutations and `gh issue view` calls are identical regardless of how +the issue was sourced. + +### Link the PR to the issue + +Append `Closes #<ISSUE_NUMBER>` to the PR body: + +```bash +gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md +printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md +gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md + +# Verify +gh pr view <PR_NUMBER> --repo penpot/penpot --json body \ + --jq '.body | test("Closes #<ISSUE_NUMBER>")' +``` + +**Note:** If the PR is already merged, `Closes` won't auto-close the issue — +it only creates the "Development" sidebar link. This is the desired +behavior since the issue is a tracking artifact. + +### Clean up + +```bash +rm -f /tmp/issue-body.md /tmp/pr-body.md +``` + +### Rules for this flow + +- **One issue per PR** — even if a PR fixes multiple things, create a single + issue that summarizes the overall change. +- **Community attribution:** if the PR has the `community contribution` + label or the author is not a core team member, add the label to the issue. +- **Don't put implementation details in the issue body** — the issue is for + users, QA, and changelog readers. + +## Creating Issues from Draft Body + +Used when the user provides a draft body from elsewhere (Taiga story, user +report, discussion transcript) and there is no PR yet. + +### Get the body + +Read the draft body from wherever it was provided. If the user gives only a +vague one-liner, ask them to expand it (steps to reproduce, expected vs. +actual, use case) before proceeding. + +### Derive the title + +Apply the title rules in the **Title Derivation** section above. Distinguish +bug vs. feature from the body content: + +- Steps to reproduce + expected vs. actual → bug +- "would be nice", "add support for", "allow users to" → feature / enhancement + +### Choose a body template + +Use the bug or enhancement template from the **Issue Body Template** section +above. Fill in placeholders with the user-provided details. If the body +doesn't fit either, ask the user which template to use. + +### Determine metadata + +- **Project:** `Main` (always). +- **Milestone:** ask the user if not obvious; otherwise omit. +- **Labels:** usually none for new user-reported issues. Add + `community contribution` if the user is a non-team contributor. +- **Issue Type:** use the mapping table above (bug description → Bug; feature + request → Enhancement or Feature). + +### Create the issue + +```bash +cat > /tmp/issue-body.md << 'ISSUE_BODY' +<body content here> +ISSUE_BODY + +gh issue create \ + --repo penpot/penpot \ + --title "<Title>" \ + --label "community contribution" \ # only if applicable + --milestone "<milestone>" \ # only if provided + --project "Main" \ + --body-file /tmp/issue-body.md +``` + +### Set Issue Type and verify + +Same GraphQL mutation and `gh issue view` commands as in the +**Setting the Issue Type** and **Verification** sections above. + +### Clean up + +```bash +rm -f /tmp/issue-body.md +``` + +## Retitling an Existing Issue + +Used when an issue's current title is vague, prefixed, or no longer matches +the body (e.g. `[PENPOT FEEDBACK]: ...`, `feature: ...`). + +### Fetch the issue + +```bash +gh issue view <NUMBER> --repo penpot/penpot --json title,body +``` + +### Derive a new title + +Read the body (not the current title) and apply the title rules in the +**Title Derivation** section above. + +### Apply the new title + +```bash +gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>" +``` + +### Confirm + +```bash +gh issue view <NUMBER> --repo penpot/penpot --json title +``` + ## See Also -- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs` +- End-to-end orchestration entry point: the `create-issue` skill at + `.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry + point; this memory is the canonical home for all issue-creation rules. From 0de8bce8958feffa07eabffe41130300f625da9a Mon Sep 17 00:00:00 2001 From: Pablo Alba <pablo.alba@kaleidos.net> Date: Wed, 8 Jul 2026 12:51:03 +0200 Subject: [PATCH 07/63] :sparkles: Add ignore sso flag to nitrate management api endpoints (#10579) --- backend/src/app/rpc/management/nitrate.clj | 45 ++++++++++++++-------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index b2b8ec4c95..fc96d2e448 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -53,7 +53,8 @@ "Authenticate the current user" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id] :as params}] (let [profile (profile/get-profile cfg profile-id)] (-> (profile-to-map profile) @@ -104,7 +105,8 @@ "List teams for which current user is owner" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:get-teams-result} + ::sm/result schema:get-teams-result + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id]}] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (->> (db/exec! cfg [sql:get-teams current-user-id]) @@ -127,7 +129,8 @@ collection when replacing an existing one." {::doc/added "2.17" ::sm/params schema:upload-org-logo - ::sm/result schema:upload-org-logo-result} + ::sm/result schema:upload-org-logo-result + ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] (when previous-id (sto/touch-object! storage previous-id)) @@ -195,7 +198,8 @@ "List profiles that belong to teams for which current user is owner" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:managed-profile-result} + ::sm/result schema:managed-profile-result + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id]}] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id]))) @@ -234,7 +238,8 @@ "Get summary information for a list of teams" {::doc/added "2.15" ::sm/params schema:get-teams-summary-params - ::sm/result schema:get-teams-summary-result} + ::sm/result schema:get-teams-summary-result + ::nitrate/sso false} [cfg {:keys [ids]}] (let [;; Handle one or multiple params ids (cond @@ -373,7 +378,8 @@ RETURNING id, deleted_at;") "For a given user, find all owned organizations and apply the deleted-org transfer rules to their imported Your Penpot teams." {::doc/added "2.18" - ::sm/params schema:notify-user-organizations-deletion} + ::sm/params schema:notify-user-organizations-deletion + ::nitrate/sso false} [cfg {:keys [profile-id]}] (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] (doseq [org owned-orgs] @@ -399,7 +405,8 @@ RETURNING id, deleted_at;") "Get profile by email" {::doc/added "2.15" ::sm/params [:map [:email ::sm/email]] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [email]}] (let [profile (db/exec-one! cfg [sql:get-profile-by-email email])] (when-not profile @@ -422,7 +429,8 @@ RETURNING id, deleted_at;") "Get profile by email" {::doc/added "2.15" ::sm/params [:map [:id ::sm/uuid]] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [id]}] (let [profile (db/exec-one! cfg [sql:get-profile-by-id id])] (when-not profile @@ -482,7 +490,8 @@ RETURNING id, deleted_at;") {::doc/added "2.15" ::sm/params [:map [:email ::sm/email] - [:organization schema:organization-with-avatar]]} + [:organization schema:organization-with-avatar]] + ::nitrate/sso false} [cfg params] (db/tx-run! cfg ti/create-org-invitation params) nil) @@ -510,7 +519,7 @@ RETURNING id, deleted_at;") {::doc/added "2.16" ::sm/params schema:get-org-invitations-params ::sm/result schema:get-org-invitations-result - ::rpc/auth false} + ::nitrate/sso false} [cfg {:keys [organization-id]}] (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] @@ -537,7 +546,7 @@ RETURNING id, deleted_at;") "Delete all invitations for one email in an organization scope (org + org teams)." {::doc/added "2.16" ::sm/params schema:delete-org-invitations-params - ::rpc/auth false} + ::nitrate/sso false} [cfg {:keys [organization-id email]}] (let [clean-email (profile/clean-email email) team-ids (noh/get-org-team-ids cfg organization-id)] @@ -606,7 +615,8 @@ RETURNING id, deleted_at;") [:organization-id ::sm/uuid] [:organization-name ::sm/text] [:default-team-id ::sm/uuid]] - ::db/transaction true} + ::db/transaction true + ::nitrate/sso false} [cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}] (let [{:keys [valid-teams-to-delete-ids valid-teams-to-transfer @@ -643,7 +653,8 @@ RETURNING id, deleted_at;") [:organization-id ::sm/uuid] [:default-team-id ::sm/uuid]] ::sm/result schema:get-remove-from-org-summary-result - ::db/transaction true} + ::db/transaction true + ::nitrate/sso false} [cfg {:keys [profile-id organization-id default-team-id]}] (let [{:keys [valid-teams-to-delete-ids valid-teams-to-transfer @@ -744,7 +755,8 @@ RETURNING id, deleted_at;") "Return if there are any team invitations for emails that are not organization members." {::doc/added "2.18" ::sm/params schema:org-team-invitations-for-non-members-params - ::sm/result schema:exists-org-team-invitations-for-non-members-result} + ::sm/result schema:exists-org-team-invitations-for-non-members-result + ::nitrate/sso false} [cfg params] (db/run! cfg (fn [{:keys [::db/conn]}] {:exists (boolean (non-member-org-team-invitations-exist? conn params))}))) @@ -753,7 +765,8 @@ RETURNING id, deleted_at;") "Delete team invitations for emails that are not organization members." {::doc/added "2.18" ::sm/params schema:org-team-invitations-for-non-members-params - ::db/transaction true} + ::db/transaction true + ::nitrate/sso false} [cfg params] (db/run! cfg (fn [{:keys [::db/conn]}] (let [{:keys [emails-array teams-array]} @@ -869,7 +882,7 @@ RETURNING id, deleted_at;") {::doc/added "2.20" ::sm/params schema:get-teams-detail-params ::sm/result schema:get-teams-detail-result - ::rpc/auth false} + ::nitrate/sso false} [cfg {:keys [organization-id]}] (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams org-summary))] From e61c55e53c84cc9b766b1f30d655367f0c21dc85 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 8 Jul 2026 12:53:11 +0200 Subject: [PATCH 08/63] :books: Add no text-wrapping rule to the creating-issue workflow --- .serena/memories/workflow/creating-issues.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 46ca109c3c..9f3831b165 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -79,6 +79,8 @@ Write the body to a temp file to avoid shell quoting issues: <version> ``` +Note: do not soft-wrap paragraphs in the body. Each paragraph is a single line in the source; newlines are reserved for structural breaks (section headers, list items, code-block fences, blank-line separators). List items stay on a single line each. GitHub renders single-line paragraphs correctly, and wrapping makes diffs noisy on every small wording change. Same rule applies to PR bodies. + ## Creating the Issue ```bash From f0cf8dca2a5e607750eea52f13e972c9b1c7b78d Mon Sep 17 00:00:00 2001 From: "alonso.torres" <alonso.torres@kaleidos.net> Date: Wed, 8 Jul 2026 12:40:52 +0200 Subject: [PATCH 09/63] :bug: Fix the release scripts --- plugins/tools/scripts/build-css.mjs | 22 ++++++++++++---------- plugins/tools/scripts/build-types.mjs | 17 +++++++++++------ plugins/tools/scripts/publish.ts | 22 +++++++++++++++++++--- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/plugins/tools/scripts/build-css.mjs b/plugins/tools/scripts/build-css.mjs index 36434e482b..73be37f276 100644 --- a/plugins/tools/scripts/build-css.mjs +++ b/plugins/tools/scripts/build-css.mjs @@ -1,16 +1,19 @@ import esbuild from 'esbuild'; import { copy } from 'fs-extra'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; -const source = 'libs/plugins-styles'; -const dist = 'dist/plugins-styles'; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const source = resolve(root, 'libs/plugins-styles'); +const dist = resolve(root, 'dist/plugins-styles'); const handleErr = (err) => { console.error(err); process.exit(1); }; -esbuild - .build({ +Promise.all([ + esbuild.build({ entryPoints: [`${source}/src/lib/styles.css`], bundle: true, outfile: `${dist}/styles.css`, @@ -18,9 +21,8 @@ esbuild loader: { '.svg': 'dataurl', }, - }) - .catch(handleErr); - -copy(`${source}/package.json`, `${dist}/package.json`).catch(handleErr); -copy(`${source}/README.md`, `${dist}/README.md`).catch(handleErr); -copy(`LICENSE`, `${dist}/LICENSE`).catch(handleErr); + }), + copy(`${source}/package.json`, `${dist}/package.json`), + copy(`${source}/README.md`, `${dist}/README.md`), + copy(resolve(root, 'LICENSE'), `${dist}/LICENSE`), +]).catch(handleErr); diff --git a/plugins/tools/scripts/build-types.mjs b/plugins/tools/scripts/build-types.mjs index 999852cca1..3d5d739cc5 100644 --- a/plugins/tools/scripts/build-types.mjs +++ b/plugins/tools/scripts/build-types.mjs @@ -1,14 +1,19 @@ import { copy } from 'fs-extra'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; -const source = 'libs/plugin-types'; -const dist = 'dist/plugin-types'; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const source = resolve(root, 'libs/plugin-types'); +const dist = resolve(root, 'dist/plugin-types'); const handleErr = (err) => { console.error(err); process.exit(1); }; -copy(`${source}/package.json`, `${dist}/package.json`).catch(handleErr); -copy(`${source}/README.md`, `${dist}/README.md`).catch(handleErr); -copy(`${source}/index.d.ts`, `${dist}/index.d.ts`).catch(handleErr); -copy(`LICENSE`, `${dist}/LICENSE`).catch(handleErr); +Promise.all([ + copy(`${source}/package.json`, `${dist}/package.json`), + copy(`${source}/README.md`, `${dist}/README.md`), + copy(`${source}/index.d.ts`, `${dist}/index.d.ts`), + copy(resolve(root, 'LICENSE'), `${dist}/LICENSE`), +]).catch(handleErr); diff --git a/plugins/tools/scripts/publish.ts b/plugins/tools/scripts/publish.ts index 855ee3f13e..64125436fe 100644 --- a/plugins/tools/scripts/publish.ts +++ b/plugins/tools/scripts/publish.ts @@ -1,5 +1,5 @@ import { execSync } from 'child_process'; -import { readFileSync, writeFileSync } from 'fs'; +import { cpSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; @@ -66,6 +66,21 @@ const writePackageJson = (packagePath: string, content: PackageJson): void => { writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n'); }; +const getPublishPath = (packagePath: string): string => { + return join('dist', packagePath.split('/').pop()!); +}; + +const prepareRuntimePackage = (): void => { + const source = 'libs/plugins-runtime'; + const dist = getPublishPath(source); + + mkdirSync(dist, { recursive: true }); + cpSync(join(source, 'package.json'), join(dist, 'package.json')); + cpSync(join(source, 'README.md'), join(dist, 'README.md')); + cpSync('LICENSE', join(dist, 'LICENSE')); + cpSync(join(source, 'dist'), join(dist, 'dist'), { recursive: true }); +}; + const incrementVersion = ( currentVersion: string, specifier: string, @@ -164,6 +179,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => { stdio: 'inherit', }, ); + prepareRuntimePackage(); } else { console.log(' [DRY RUN] Skipping build\n'); } @@ -176,7 +192,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => { for (const packagePath of PACKAGES) { const pkg = readPackageJson(packagePath); - const distPath = join('dist', packagePath.split('/').pop()!); + const distPath = getPublishPath(packagePath); if (args.dryRun) { console.log( @@ -188,7 +204,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => { `pnpm publish --tag ${tag} --access public --no-git-checks`, { cwd: join(process.cwd(), distPath), - stdio: args.verbose ? 'inherit' : 'pipe', + stdio: 'inherit', }, ); console.log(` ✅ Published ${pkg.name}@${newVersion}`); From b1537488663fc0f8b0cbbc738846512b4b00bf30 Mon Sep 17 00:00:00 2001 From: "alonso.torres" <alonso.torres@kaleidos.net> Date: Wed, 8 Jul 2026 12:41:38 +0200 Subject: [PATCH 10/63] :arrow_up: Release plugins 1.5.0 --- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index e44dd187b9..f79cbe7d85 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/plugin-types", - "version": "1.4.2", + "version": "1.5.0", "typings": "./index.d.ts", "type": "module", "scripts": { diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index cac1e949d5..bd56f9de75 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/plugins-runtime", - "version": "1.4.2", + "version": "1.5.0", "dependencies": { "@penpot/plugin-types": "workspace:^", "ses": "^2.1.0", diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index f71775d820..2ba002b6f5 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/plugin-styles", - "version": "1.4.2", + "version": "1.5.0", "dependencies": {}, "scripts": { "build": "node ../../tools/scripts/build-css.mjs", From 88186eaec14fd1fb2486fe56c2703b83a4cdb91d Mon Sep 17 00:00:00 2001 From: "alonso.torres" <alonso.torres@kaleidos.net> Date: Wed, 8 Jul 2026 12:41:57 +0200 Subject: [PATCH 11/63] :arrow_up: Release MCP 2.17.0 --- mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/package.json b/mcp/package.json index 5efd15acc9..13c17b252f 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.16.0", + "version": "2.17.0", "description": "MCP server for Penpot integration", "license": "MPL-2.0", "bin": { From d656d342fec6766f8868aa57f98016c531b75593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Wed, 8 Jul 2026 13:56:58 +0200 Subject: [PATCH 12/63] :bug: Fix text selrect size (#10566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix text selrect size * :bug: Fix blinks on complex files --------- Co-authored-by: Belén Albeza <belen@hey.com> --- frontend/src/app/main/data/workspace.cljs | 9 ++ .../app/main/data/workspace/wasm_text.cljs | 14 +-- .../app/main/ui/workspace/viewport_wasm.cljs | 10 ++- frontend/src/app/render_wasm/api.cljs | 85 ++++++++++++++----- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 26bed66c22..25eb0e50d3 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -65,6 +65,7 @@ [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwva] [app.main.data.workspace.viewport :as dwv] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.zoom :as dwz] [app.main.errors] [app.main.features :as features] @@ -441,6 +442,14 @@ ;; Keep comment thread positions in sync on undo/redo (rx/of (dwcm/watch-comment-thread-position-changes stoper-s)) + ;; Resize auto-grow text shapes whose selrect does not match + ;; the WASM text layout once their fonts finish loading. + (->> stream + (rx/filter (ptk/type? :app.render-wasm.api/stale-text-selrects)) + (rx/map deref) + (rx/map (fn [{:keys [ids]}] + (dwwt/resize-wasm-text-all ids)))) + (let [local-commits-s (->> stream (rx/filter dch/commit?) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 6468f487aa..d893caedf6 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -42,12 +42,14 @@ (wasm.api/set-shape-text-images id content) (let [dimension (when (not= :fixed grow-type) (wasm.api/get-text-dimensions))] - {:width (if (#{:fixed :auto-height} grow-type) - (:width selrect) - (:width dimension)) - :height (if (= :fixed grow-type) - (:height selrect) - (:height dimension))})))) + ;; nil dimension = shape not present in WASM state; skip the resize. + (when (or (= :fixed grow-type) (some? dimension)) + {:width (if (#{:fixed :auto-height} grow-type) + (:width selrect) + (:width dimension)) + :height (if (= :fixed grow-type) + (:height selrect) + (:height dimension))}))))) (defn resize-wasm-text-modifiers ([shape] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index ca67ec8f11..6529ccf7d1 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -573,15 +573,19 @@ (wasm.api/push-ruler-theme-colors!) (wasm.api/request-render "rulers-colors-theme"))))) + ;; Ruler overlay updates below only change the UI surface, not the shapes. + ;; They use `render-from-cache!` (cached tiles + UI, atomic) instead of a full + ;; `request-render`, which would kick off a progressive tile-by-tile shape + ;; re-render that flashes on zoomed-in views (see penpot ruler-selection flash). (mf/with-effect [@canvas-init? frame-visible?] (when @canvas-init? (wasm.api/set-rulers-frame-visible! frame-visible?) - (wasm.api/request-render "rulers-frame"))) + (wasm.api/render-from-cache!))) (mf/with-effect [@canvas-init? show-rulers?] (when @canvas-init? (wasm.api/set-rulers-visible! show-rulers?) - (wasm.api/request-render "rulers-visible"))) + (wasm.api/render-from-cache!))) (mf/with-effect [@canvas-init? show-rulers? offset-x offset-y] (when (and @canvas-init? show-rulers?) @@ -592,7 +596,7 @@ (some-> ruler-selection :width) (some-> ruler-selection :height)] (when (and @canvas-init? show-rulers?) (wasm.api/set-rulers-selection! ruler-selection) - (wasm.api/request-render "rulers-selection"))) + (wasm.api/render-from-cache!))) ;; Paint background + rulers instantly, before shapes finish loading. Runs ;; after the ruler push effects so the WASM ruler state is already set. diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b00406d0f5..3a5357d5d1 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -58,6 +58,7 @@ [app.util.timers :as timers] [beicon.v2.core :as rx] [cuerdas.core :as str] + [potok.v2.core :as ptk] [promesa.core :as p] [rumext.v2 :as mf])) @@ -459,6 +460,20 @@ (when (and wasm/context-initialized? (not @wasm/context-lost?)) (h/call wasm/internal-module "_render_ui_only"))) +(defn render-from-cache! + "Blit the shapes from the cached tile atlas and redraw the UI overlay + (rulers, selection band) fresh on top, in a single atomic frame. The + *shapes* are the cached part (already-rasterized tiles, not rebuilt); the UI + is re-rendered every call, which is what lets it reflect a new selection. + + Use for UI-only updates that don't change shapes (e.g. the ruler selection + band): unlike `request-render`, it never kicks off a progressive, + tile-by-tile shape re-render, so it does not flash on zoomed-in views where + the scene spans multiple tiles." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_render_from_cache" 0))) + ;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM). (def ^:private TRANSITION_BLUR_RADIUS 4.0) @@ -1282,17 +1297,19 @@ ([] (if-not (initialized?) {:x 0 :y 0 :width 0 :height 0 :max-width 0} - (let [offset (-> (h/call wasm/internal-module "_get_text_dimensions") - (mem/->offset-32)) - heapf32 (mem/get-heap-f32) - width (aget heapf32 (+ offset 0)) - height (aget heapf32 (+ offset 1)) - max-width (aget heapf32 (+ offset 2)) + (let [ptr (h/call wasm/internal-module "_get_text_dimensions")] + ;; NULL pointer when there is no current shape or it is not a text. + (when-not (zero? ptr) + (let [offset (mem/->offset-32 ptr) + heapf32 (mem/get-heap-f32) + width (aget heapf32 (+ offset 0)) + height (aget heapf32 (+ offset 1)) + max-width (aget heapf32 (+ offset 2)) - x (aget heapf32 (+ offset 3)) - y (aget heapf32 (+ offset 4))] - (mem/free) - {:x x :y y :width width :height height :max-width max-width})))) + x (aget heapf32 (+ offset 3)) + y (aget heapf32 (+ offset 4))] + (mem/free) + {:x x :y y :width width :height height :max-width max-width})))))) (defn intersect-position-in-shape [id position] @@ -1452,19 +1469,45 @@ [text-ids] (run! f/force-update-text-layout text-ids)) +(defn- text-selrect-stale? + "Check if the WASM-measured dimensions of an auto-grow text shape differ + from its stored selrect (same 0.1px tolerance as the classic renderer)." + [{:keys [id selrect grow-type]}] + (when-let [{:keys [width height]} (get-text-dimensions id)] + (case grow-type + :auto-width (or (not (mth/close? width (:width selrect) 0.1)) + (not (mth/close? height (:height selrect) 0.1))) + :auto-height (not (mth/close? height (:height selrect) 0.1)) + false))) + +(defn- sync-stale-text-selrects! + "Emit the ids of auto-grow text shapes whose selrect no longer matches the + measured layout, so the workspace resizes them (data-event instead of a + direct call to avoid a circular dependency; see the watcher in + `app.main.data.workspace/initialize-workspace`)." + [shapes] + (let [stale-ids (into [] + (comp (filter cfh/text-shape?) + (filter (comp #{:auto-width :auto-height} :grow-type)) + (filter text-selrect-stale?) + (map :id)) + shapes)] + (when (seq stale-ids) + (st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids}))))) + (defn- relayout-after-fonts! - "Relayout text shapes once their pending fonts have resolved. Shapes in - `font-pending-ids` had a font fetched, so they get a forced relayout to pick - up the real glyph metrics; the remaining text shapes get a normal layout." + "Relayout text shapes once their pending fonts have resolved. Font fetches + are deduped per URL and storing a font does not invalidate cached layouts, + so every text shape (not only the fetch triggers in `font-pending-ids`) + needs a forced relayout; then re-sync selrects that drifted." [shapes font-pending-ids] - (let [force-ids (set font-pending-ids) - text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes) - forced (filterv force-ids text-ids) - rest-ids (filterv (complement force-ids) text-ids)] - (when (seq forced) - (force-update-text-layouts forced)) - (when (seq rest-ids) - (update-text-layouts rest-ids)))) + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (if (seq font-pending-ids) + (do + (force-update-text-layouts text-ids) + (sync-stale-text-selrects! shapes)) + (update-text-layouts text-ids))))) (defn process-pending [shapes thumbnails full font-pending-ids on-complete] From cc454de9ce48b949dbda26104540fb6c9d397c47 Mon Sep 17 00:00:00 2001 From: Pablo Alba <pablo.alba@kaleidos.net> Date: Wed, 8 Jul 2026 14:21:34 +0200 Subject: [PATCH 13/63] :bug: Fix wrap nitrate sso when there is no profile (#10592) --- backend/src/app/rpc.clj | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c99a9210a1..98126e574d 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -246,7 +246,8 @@ (::nitrate/sso mdata true)) (fn [cfg params] ;; Resolve team/project/file from explicit keys or from :id via metadata - (let [organization-id (uuid/coerce (:organization-id params)) + (let [profile-id (::profile-id params) + organization-id (uuid/coerce (:organization-id params)) id-type (::id-type mdata) id (uuid/coerce (:id params)) team-id (or (uuid/coerce (:team-id params)) @@ -255,9 +256,10 @@ (when (= id-type :project) id)) file-id (or (uuid/coerce (:file-id params)) (when (= id-type :file) id))] - (if (or organization-id team-id project-id file-id) + (if (and profile-id + (or organization-id team-id project-id file-id)) (let [cache-ref (or organization-id team-id project-id file-id) - profile-id (::profile-id params) + cache-key [profile-id cache-ref] cached (cache/get org-sso-auth-cache cache-key) result (if (some? cached) From 8b734d984434be74f1c3dbfd99a390b766940cbc Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 8 Jul 2026 14:14:09 +0200 Subject: [PATCH 14/63] :paperclip: Add postgresql client tool wrapper for devenv --- .serena/memories/backend/core.md | 3 ++ .serena/memories/critical-info.md | 3 +- .serena/memories/tools/psql.md | 51 +++++++++++++++++++++++++++++++ tools/db-schema | 34 +++++++++++++++++++++ tools/psql | 34 +++++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .serena/memories/tools/psql.md create mode 100755 tools/db-schema create mode 100755 tools/psql diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 34a272dedc..d815f69070 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -39,6 +39,9 @@ Backend RPC command areas without focused memories include access tokens, binfil Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table. +For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump +the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`). + For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`. ## Background tasks diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 23881c6f89..33ccf8a8ad 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -45,7 +45,8 @@ module. You can read it from `mem:<MODULE>/core` tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. - `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), - `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and + `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), + `psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. diff --git a/.serena/memories/tools/psql.md b/.serena/memories/tools/psql.md new file mode 100644 index 0000000000..c502b4c1e9 --- /dev/null +++ b/.serena/memories/tools/psql.md @@ -0,0 +1,51 @@ +# PostgreSQL client wrapper + +`tools/psql` is a wrapper around the `psql` command with defaults preconfigured +for the Penpot development environment. + +## When to use + +- Running SQL queries against the dev database (`penpot`) or test database + (`penpot_test`). +- Inspecting table structures, running migrations manually, or debugging + database state. +- Any time you need PostgreSQL access and want the correct host/user/password + without typing them each time. + +## How to use + +```bash +# Interactive session (penpot database) +tools/psql + +# Interactive session (penpot_test database) +tools/psql --test + +# Inline query (penpot) +tools/psql -c "SELECT 1" + +# Inline query (penpot_test) +tools/psql --test -c "SELECT 1" + +# Override defaults +tools/psql -h other-host -U other-user -d other-db + +# Pipe SQL from a file +tools/psql -f some-query.sql +``` + +All standard `psql` flags are passed through after the wrapper's own flags. + +## Defaults + +| Setting | Default | Env override | +|----------|-----------|---------------------| +| Host | `postgres` | `PENPOT_DB_HOST` | +| User | `penpot` | `PENPOT_DB_USER` | +| Password | `penpot` | `PENPOT_DB_PASSWORD` | +| Database | `penpot` | `PENPOT_DB_NAME` | + +## See also + +`tools/db-schema` — a companion script that dumps the current DDL schema +using `pg_dump --schema-only`, with the same defaults and `--test` flag. diff --git a/tools/db-schema b/tools/db-schema new file mode 100755 index 0000000000..6cf2a8c710 --- /dev/null +++ b/tools/db-schema @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="${PENPOT_DB_HOST:-postgres}" +USER="${PENPOT_DB_USER:-penpot}" +PASSWORD="${PENPOT_DB_PASSWORD:-penpot}" +DB="${PENPOT_DB_NAME:-penpot}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --test) + DB="penpot_test" + shift + ;; + --host|-h) + HOST="$2" + shift 2 + ;; + --user|-U) + USER="$2" + shift 2 + ;; + --db|-d) + DB="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +export PGPASSWORD="$PASSWORD" +exec pg_dump -h "$HOST" -U "$USER" -d "$DB" --schema-only --no-owner --no-privileges "$@" diff --git a/tools/psql b/tools/psql new file mode 100755 index 0000000000..03dee3be19 --- /dev/null +++ b/tools/psql @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="${PENPOT_DB_HOST:-postgres}" +USER="${PENPOT_DB_USER:-penpot}" +PASSWORD="${PENPOT_DB_PASSWORD:-penpot}" +DB="${PENPOT_DB_NAME:-penpot}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --test) + DB="penpot_test" + shift + ;; + --host|-h) + HOST="$2" + shift 2 + ;; + --user|-U) + USER="$2" + shift 2 + ;; + --db|-d) + DB="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +export PGPASSWORD="$PASSWORD" +exec psql -h "$HOST" -U "$USER" -d "$DB" "$@" From 5d933fd7705b630858d684494e7c5d0a6da8eaa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Wed, 8 Jul 2026 15:43:36 +0200 Subject: [PATCH 15/63] :bug: Organizations list sorted by name (#10589) --- frontend/src/app/main/ui/dashboard/sidebar.cljs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 9f81b51a19..420383aa60 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -801,7 +801,12 @@ :class (stl/css :dropdown :teams-dropdown) :organization current-org :profile profile - :organizations (vals orgs)}]]) + :organizations (->> (vals orgs) + (sort-by (juxt (fn [o] (str/lower (:name o ""))) + :id)))}]]) + + + orgs-portal-container))) ;; Orgs options [:> org-options-dropdown* {:show show-org-options-menu? From bcf77767b2356b4488dec2a81c52659a8b8e920f Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 8 Jul 2026 15:40:17 +0200 Subject: [PATCH 16/63] :book: Update ai agents documentation --- .serena/memories/critical-info.md | 6 +++++- AGENTS.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 33ccf8a8ad..d0f83b656b 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -9,7 +9,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. # Development workflow -- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`. +- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples: + - Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer) + - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type) + - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) +- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. - You have access to the GitHub CLI `gh` or corresponding MCP tools. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. diff --git a/AGENTS.md b/AGENTS.md index 08103521e0..8c3e6ba431 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,20 @@ # AI AGENT GUIDE +## Hard rules (always apply — no exceptions) + +- **Never `git push`, force-push, or modify `git origin`** (or any other remote). + The user pushes from their own shell. If a push is required to surface the + agent's work (e.g. force-push after an amend), state this in the response and + wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS. +- **Never amend a commit that has been pushed** unless the user explicitly asks. + If the user pushes, treat that commit as final from the agent's side. +- **Read the workflow memory BEFORE the corresponding action**: + - Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer) + - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type) + - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, AI note) + Don't infer format from the title of a previous commit/issue/PR — the memory + is the source of truth. + ## CRITICAL: Read module memories BEFORE writing any code Do this **before planning, before coding, before touching any file**: From 8ac2e8c8a828f61f46bc05d2a286a1b00359074a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Wed, 8 Jul 2026 20:38:16 +0200 Subject: [PATCH 17/63] :bug: Fix sidebar scroll to shape (#10600) --- .../main/ui/workspace/sidebar/layer_item.cljs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 675e280c3a..446ef4187e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -490,8 +490,7 @@ selected-child-render-idx (when (> total default-chunk-size) (some (fn [sel-id] - (let [idx (.indexOf shapes sel-id)] - (when (>= idx 0) idx))) + (d/index-of-pred shapes #(= (get % :id) sel-id))) selected)) ;; Load at least enough to include the selected child plus extra @@ -509,12 +508,7 @@ (reset! children-count* new-count)) - (reset! children-count* 0)) - - (fn [] - (when-let [obs (mf/ref-val observer-ref)] - (.disconnect obs) - (mf/set-ref-val! obs nil))))) + (reset! children-count* 0)))) ;; Re-observe sentinel whenever children-count changes (sentinel moves) ;; and (shapes item) to reconnect observer after shape changes @@ -524,11 +518,6 @@ scroll-node (dom/get-parent-with-data name-node "scroll-container") lazy-node (mf/ref-val lazy-ref)] - ;; Disconnect previous observer - (when-let [obs (mf/ref-val observer-ref)] - (.disconnect obs) - (mf/set-ref-val! observer-ref nil)) - ;; Setup new observer if there are more children to load (when (and ^boolean is-expanded ^boolean (< children-count total) @@ -542,7 +531,13 @@ (reset! children-count* next-count)))) observer (js/IntersectionObserver. cb #js {:root scroll-node})] (.observe observer lazy-node) - (mf/set-ref-val! observer-ref observer))))) + (mf/set-ref-val! observer-ref observer))) + + ;; Disconnect on deps change or unmount; this effect owns the observer + (fn [] + (when-let [obs (mf/ref-val observer-ref)] + (.disconnect obs) + (mf/set-ref-val! observer-ref nil))))) [:> layer-item-inner* {:ref dref From b3d1a7aa8bffbe96e445856557254d87f15c3c78 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 08:33:46 +0200 Subject: [PATCH 18/63] :books: Add better dev tools doc to serena --- .opencode/skills/nrepl-eval/SKILL.md | 99 ++------------------- .serena/memories/critical-info.md | 19 +++- .serena/memories/tools/gh.md | 80 +++++++++++++++++ .serena/memories/tools/nrepl-eval.md | 126 +++++++++++++++++++++++++++ .serena/memories/tools/taiga.md | 44 ++++++++++ tools/nrepl-eval.mjs | 29 +++++- 6 files changed, 299 insertions(+), 98 deletions(-) create mode 100644 .serena/memories/tools/gh.md create mode 100644 .serena/memories/tools/nrepl-eval.md create mode 100644 .serena/memories/tools/taiga.md diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md index 2cf44d88c7..b4b3d76d7f 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -6,26 +6,20 @@ description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-ev # nREPL Eval Evaluate Clojure (or ClojureScript) code via a running nREPL server using -`tools/nrepl-eval.mjs` — a standalone CLI application. +`tools/nrepl-eval.mjs`. -Session state (defs, in-ns, etc.) persists across invocations via a stored -session ID, so you can build up state incrementally. +Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`) -## Usage +## Quick Reference -```bash -node tools/nrepl-eval.mjs [options] [<code>] -``` - -The tool is also executable directly: ```bash ./tools/nrepl-eval.mjs [options] [<code>] ``` -## Options - | Flag | Description | Default | |------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | | `-p, --port PORT` | nREPL server port | `6064` | | `-H, --host HOST` | nREPL server host | `127.0.0.1` | | `-t, --timeout MS` | Timeout in milliseconds | `120000` | @@ -33,88 +27,11 @@ The tool is also executable directly: | `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | | `-h, --help` | Show help message | — | -## When to Use +## Examples -Use this tool when you need to: - -1. **Evaluate Clojure code** during development — test functions, inspect - state, or run experiments against a running Clojure process. -2. **Verify that edited files compile** — require namespaces with `:reload` - to pick up changes. -3. **Inspect the last exception** after a failed evaluation — use `-e` to - print the error stored in `*e`. - -## Workflow - -### 1. Session management - -Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State -carries across calls automatically: - -```bash -./tools/nrepl-eval.mjs '(def x 42)' -./tools/nrepl-eval.mjs 'x' -# => 42 -``` - -Reset the session to start fresh: - -```bash -./tools/nrepl-eval.mjs --reset-session '(def x 0)' -``` - -### 2. Evaluate code - -**Single expression (inline) — uses default port 6064:** ```bash ./tools/nrepl-eval.mjs '(+ 1 2 3)' -``` - -**Multiple expressions via heredoc (recommended — avoids escaping issues):** -```bash -./tools/nrepl-eval.mjs <<'EOF' -(def x 10) -(+ x 20) -EOF -``` - -**Override with a different port:** -```bash -./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' -``` - -### 3. Inspect last exception - -After code throws an error, retrieve the full exception details: - -```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' ./tools/nrepl-eval.mjs -e ``` - -## Common Patterns - -**Require a namespace with reload:** -```bash -./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" -``` - -**Test a function:** -```bash -./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" -``` - -**Long-running operation with custom timeout:** -```bash -./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" -``` - -## Key Principles - -- **Default port is 6064** — just pass code directly, no `-p` needed when - your nREPL server is on 6064. Use `-p <PORT>` for a different port. -- **Always use `:reload`** when requiring namespaces to pick up file changes. -- **Session is reused** across invocations — defs, in-ns, and var bindings - persist. Use `--reset-session` to clear. -- **Do not start any server** — the tool connects to an existing nREPL - server, it is not the agent's responsibility to start the nREPL server - (assume the server is already running on the specified port). diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d0f83b656b..1027829bcf 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -48,13 +48,24 @@ module. You can read it from `mem:<MODULE>/core` When working on devenv startup, compose layout, instance config (`defaults.env`), tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. -- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), - `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), - `psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and - `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. +# Dev tools + +- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL. + Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases. + See `mem:tools/nrepl-eval`. +- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files + and reformat with cljfmt. Run before lint checks when LLM edits break parens. + See `mem:tools/paren-repair`. +- `tools/psql` — PostgreSQL client wrapper with devenv defaults. + Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`. +- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the + Penpot Taiga project without authentication. See `mem:tools/taiga`. +- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR + details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`. + # Dependency graph `frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can diff --git a/.serena/memories/tools/gh.md b/.serena/memories/tools/gh.md new file mode 100644 index 0000000000..3f74b82b66 --- /dev/null +++ b/.serena/memories/tools/gh.md @@ -0,0 +1,80 @@ +# GitHub operations helper + +`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub +repository via GraphQL and REST APIs through the authenticated `gh` CLI. + +## When to use + +- Listing issues in a milestone (for changelog generation). +- Finding issues with no milestone. +- Fetching PR details by number or by milestone. +- Comparing milestone issues against CHANGES.md to find missing entries. + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`). +- Python 3.8+. + +## Subcommands + +### `issues` + +List issues in a milestone, with filtering by state, labels, and project status. + +```bash +# Closed issues in a milestone (default) +python3 tools/gh.py issues "2.16.0" + +# All issues in a milestone +python3 tools/gh.py issues "2.16.0" --state all + +# Issues with no milestone +python3 tools/gh.py issues none +python3 tools/gh.py issues none --state open + +# Filter by label (include only) +python3 tools/gh.py issues "2.16.0" --label "bug" +python3 tools/gh.py issues "2.16.0" --label "bug,regression" + +# Exclude by label +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" + +# Show only issues NOT yet in CHANGES.md +python3 tools/gh.py issues "2.16.0" --compare CHANGES.md +``` + +**Default filters** (override with flags): +- Issues with type "Task" are excluded (`--include-tasks` to keep them). +- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them). + +**Output**: JSON array to stdout; progress to stderr. + +### `prs` + +Fetch PR details by number or by milestone. + +```bash +# Fetch specific PRs +python3 tools/gh.py prs 9179 9204 9311 + +# Read PR numbers from file +python3 tools/gh.py prs --file prs.txt + +# Read PR numbers from stdin +cat prs.txt | python3 tools/gh.py prs --stdin + +# All PRs in a milestone (default: merged only) +python3 tools/gh.py prs --milestone "2.16.0" + +# All PRs in a milestone (all states) +python3 tools/gh.py prs --milestone "2.16.0" --state all +``` + +**Output**: JSON array to stdout; progress to stderr. + +## Key principles + +- All output is JSON — pipe into `jq` or other tools for further processing. +- Milestone lookup is by exact title match. +- `issues` subcommand auto-paginates (100 items per page). +- `prs` subcommand batches PR number lookups (50 per GraphQL query). diff --git a/.serena/memories/tools/nrepl-eval.md b/.serena/memories/tools/nrepl-eval.md new file mode 100644 index 0000000000..f5ec3ab994 --- /dev/null +++ b/.serena/memories/tools/nrepl-eval.md @@ -0,0 +1,126 @@ +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [<code>] +# or +./tools/nrepl-eval.mjs [options] [<code>] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +- `--backend` and `--frontend` are mutually exclusive. +- Explicit `--port` is overridden when `--backend`/`--frontend` is used. + +## When to Use + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Backend nREPL (explicit):** +```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +``` + +**Frontend nREPL:** +```bash +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447) + as quick aliases. Use `-p <PORT>` for any other port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/.serena/memories/tools/taiga.md b/.serena/memories/tools/taiga.md new file mode 100644 index 0000000000..b4e623e3e1 --- /dev/null +++ b/.serena/memories/tools/taiga.md @@ -0,0 +1,44 @@ +# Taiga API client + +`tools/taiga.py` fetches public issues, user stories, and tasks from the +Penpot Taiga project (id 345963) without authentication. + +## When to use + +- Fetching details of a Taiga issue, user story, or task by URL or ref number. +- Inspecting status, assignee, tags, description, and other metadata. +- Piping structured JSON into other scripts (with `--json`). + +## How to use + +```bash +# Fetch by full Taiga URL +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# Fetch by type and ref number +python3 tools/taiga.py issue 13714 +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Output raw JSON instead of formatted summary +python3 tools/taiga.py --json issue 13714 +python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 +``` + +## Supported types + +| Type | Description | +|------|-------------| +| `issue` | Bug reports, feature requests | +| `us` | User stories | +| `task` | Implementation tasks | + +## Output + +Default output is a formatted summary with title, status, assignee, author, +tags, URL, and description. Use `--json` for the raw API response. + +## Prerequisites + +- Python 3.8+ (standard library only, no dependencies). +- Network access to `api.taiga.io`. diff --git a/tools/nrepl-eval.mjs b/tools/nrepl-eval.mjs index 45da2445de..8a10905305 100755 --- a/tools/nrepl-eval.mjs +++ b/tools/nrepl-eval.mjs @@ -128,18 +128,24 @@ function formatEvalMessages(messages) { function parseArgs(argv) { const args = { - port: 6064, + port: null, host: "127.0.0.1", timeout: DEFAULT_TIMEOUT, help: false, resetSession: false, lastError: false, + backend: false, + frontend: false, code: null, }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "-p" || a === "--port") { + if (a === "--backend") { + args.backend = true; + } else if (a === "--frontend") { + args.frontend = true; + } else if (a === "-p" || a === "--port") { const val = argv[++i]; if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); } args.port = parseInt(val, 10); @@ -166,6 +172,19 @@ function parseArgs(argv) { } } + if (args.backend && args.frontend) { + console.error("Error: --backend and --frontend are mutually exclusive."); + process.exit(1); + } + + if (args.backend) { + args.port = 6064; + } else if (args.frontend) { + args.port = 3447; + } else if (args.port === null) { + args.port = 6064; + } + return args; } @@ -177,7 +196,9 @@ Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns) persists across invocations via a stored session ID. Options: - -p, --port PORT nREPL port (default: 6064) + --backend Connect to backend nREPL on port 6064 (default) + --frontend Connect to frontend nREPL on port 3447 + -p, --port PORT nREPL port (default: 6064; overridden by --backend/--frontend) -H, --host HOST nREPL host (default: 127.0.0.1) -t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000) --reset-session Discard stored session and start fresh @@ -187,6 +208,8 @@ Options: Examples: ${bin} '(def x 42)' ${bin} 'x' + ${bin} --backend '(+ 1 2 3)' + ${bin} --frontend '(js/alert "hi")' ${bin} --reset-session '(def x 0)' ${bin} --last-error ${bin} <<'EOF' From 455c7f5cae1a0d9e0b47a4bb23b5de62881c52a1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 08:48:00 +0200 Subject: [PATCH 19/63] :books: Update serena doc related to creating issues --- .serena/memories/workflow/creating-issues.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9f3831b165..9aff0b50c1 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`. | Field | Rule | |-------|------| -| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) | +| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) | | **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. | | **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. | | **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. | @@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>` | Docs | `IT_kwDOAcyBPM4B_IQz` | **Map:** -- `bug` label → Bug -- `enhancement` label → Enhancement +- Bug report (steps to reproduce, expected vs. actual) → Bug +- Enhancement / new feature → Enhancement - Feature/epic → Feature - Docs → Docs - None of the above → Task From 8fcd8a63b46fe67ccd31466799fa5c1225a59241 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 09:25:19 +0200 Subject: [PATCH 20/63] :arrow_up: Update opencode dependency on devenv --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b854a48cb..1fd9a7b382 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.17.13 +ENV OPENCODE_VERSION=1.17.16 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From 8e9df0c5153a8cd9fa83192d87cd72fcfb6b72dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 09:25:59 +0200 Subject: [PATCH 21/63] :sparkles: Add insert-multi chunked variant to app.db --- backend/src/app/db.clj | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c4e432b225..f10b0089a0 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -331,7 +331,10 @@ This expands to a single SQL statement with placeholders for every value being inserted. For large data sets, this may exceed the limit - of sql string size and/or number of parameters." + of sql string size and/or number of parameters. + + See `insert-many-chunked!` for a safe alternative that automatically + partitions rows to stay within the parameter limit." [ds table cols rows & {:as opts}] (let [conn (get-connectable ds) sql (sql/insert-many table cols rows opts) @@ -341,6 +344,24 @@ opts (update opts :return-keys boolean)] (jdbc/execute! conn sql opts))) +(def ^:private default-max-params + "PostgreSQL PreparedStatement parameter limit." + 65535) + +(defn insert-many-chunked! + "Like `insert-many!` but partitions rows into chunks that stay within + PostgreSQL's 65,535 PreparedStatement parameter limit. + + The chunk size is computed as `floor(max-params / num-columns)`, + so callers do not need to calculate it. All chunks execute within + the same transaction when called inside `tx-run!`." + [ds table cols rows & {:keys [max-params] :as opts + :or {max-params default-max-params}}] + (let [chunk-size (quot max-params (count cols)) + opts (dissoc opts :max-params)] + (doseq [chunk (partition-all chunk-size rows)] + (apply insert-many! ds table cols chunk (mapcat identity opts))))) + (defn update! "A helper that build an UPDATE SQL statement and executes it. From f2460eee2957383d527fbed684cc890714dba6b7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 10:59:40 +0200 Subject: [PATCH 22/63] :bug: Fix Draft.js selection offset DOMException on text editor re-render (#10608) Clamp selection offset to node length in setDraftEditorSelection to prevent DOMException when Draft.js SelectionState offset exceeds the actual DOM text node length. This can happen during spellcheck, IME composition, or race conditions during React re-renders. Updates @penpot/draft-js to commit 09a33e0a which includes the fix in addPointToSelection and addFocusToSelection. Fixes #10607 AI-assisted-by: mimo-v2.5-pro --- frontend/packages/draft-js/package.json | 2 +- frontend/packages/draft-js/pnpm-lock.yaml | 449 ---------------------- frontend/pnpm-lock.yaml | 10 +- package.json | 2 +- 4 files changed, 7 insertions(+), 456 deletions(-) delete mode 100644 frontend/packages/draft-js/pnpm-lock.yaml diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 8ad22449d7..ec153b2eac 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,7 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0", + "draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35", "immutable": "^5.1.9" }, "peerDependencies": { diff --git a/frontend/packages/draft-js/pnpm-lock.yaml b/frontend/packages/draft-js/pnpm-lock.yaml deleted file mode 100644 index 6efab568fd..0000000000 --- a/frontend/packages/draft-js/pnpm-lock.yaml +++ /dev/null @@ -1,449 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - immutable: - specifier: ^5.1.4 - version: 5.1.4 - react: - specifier: '>=0.17.0' - version: 19.2.3 - react-dom: - specifier: '>=0.17.0' - version: 19.2.3(react@19.2.3) - devDependencies: - esbuild: - specifier: ^0.27.2 - version: 0.27.2 - -packages: - - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - cross-fetch@3.2.0: - resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} - version: 0.11.7 - peerDependencies: - react: '>=0.14.0' - react-dom: '>=0.14.0' - - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - - fbjs-css-vars@1.0.2: - resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} - - fbjs@3.0.5: - resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} - - immutable@3.7.6: - resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} - engines: {node: '>=0.8.0'} - - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - promise@7.3.1: - resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} - - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - ua-parser-js@1.0.41: - resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} - hasBin: true - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - -snapshots: - - '@esbuild/aix-ppc64@0.27.2': - optional: true - - '@esbuild/android-arm64@0.27.2': - optional: true - - '@esbuild/android-arm@0.27.2': - optional: true - - '@esbuild/android-x64@0.27.2': - optional: true - - '@esbuild/darwin-arm64@0.27.2': - optional: true - - '@esbuild/darwin-x64@0.27.2': - optional: true - - '@esbuild/freebsd-arm64@0.27.2': - optional: true - - '@esbuild/freebsd-x64@0.27.2': - optional: true - - '@esbuild/linux-arm64@0.27.2': - optional: true - - '@esbuild/linux-arm@0.27.2': - optional: true - - '@esbuild/linux-ia32@0.27.2': - optional: true - - '@esbuild/linux-loong64@0.27.2': - optional: true - - '@esbuild/linux-mips64el@0.27.2': - optional: true - - '@esbuild/linux-ppc64@0.27.2': - optional: true - - '@esbuild/linux-riscv64@0.27.2': - optional: true - - '@esbuild/linux-s390x@0.27.2': - optional: true - - '@esbuild/linux-x64@0.27.2': - optional: true - - '@esbuild/netbsd-arm64@0.27.2': - optional: true - - '@esbuild/netbsd-x64@0.27.2': - optional: true - - '@esbuild/openbsd-arm64@0.27.2': - optional: true - - '@esbuild/openbsd-x64@0.27.2': - optional: true - - '@esbuild/openharmony-arm64@0.27.2': - optional: true - - '@esbuild/sunos-x64@0.27.2': - optional: true - - '@esbuild/win32-arm64@0.27.2': - optional: true - - '@esbuild/win32-ia32@0.27.2': - optional: true - - '@esbuild/win32-x64@0.27.2': - optional: true - - asap@2.0.6: {} - - cross-fetch@3.2.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - fbjs: 3.0.5 - immutable: 3.7.6 - object-assign: 4.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - encoding - - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - - fbjs-css-vars@1.0.2: {} - - fbjs@3.0.5: - dependencies: - cross-fetch: 3.2.0 - fbjs-css-vars: 1.0.2 - loose-envify: 1.4.0 - object-assign: 4.1.1 - promise: 7.3.1 - setimmediate: 1.0.5 - ua-parser-js: 1.0.41 - transitivePeerDependencies: - - encoding - - immutable@3.7.6: {} - - immutable@5.1.4: {} - - js-tokens@4.0.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - object-assign@4.1.1: {} - - promise@7.3.1: - dependencies: - asap: 2.0.6 - - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - - react@19.2.3: {} - - scheduler@0.27.0: {} - - setimmediate@1.0.5: {} - - tr46@0.0.3: {} - - ua-parser-js@1.0.41: {} - - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c11f5736a6..e7ee6eda1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -263,8 +263,8 @@ importers: packages/draft-js: dependencies: draft-js: - specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0 - version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 + version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) immutable: specifier: ^5.1.9 version: 5.1.9 @@ -2886,8 +2886,8 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0: - resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0} + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35: + resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35} version: 0.11.7 peerDependencies: react: '>=0.14.0' @@ -8117,7 +8117,7 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 diff --git a/package.json b/package.json index 77800eaca6..1454dbd2bc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" From 41ebb8e80b0d1f608da01d9c8ffe2351634771f8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 11:00:18 +0200 Subject: [PATCH 23/63] :bug: Fix crash when converting SVG-raw shape to path (#10613) Guard the content-to-PathData coercion on whether stp/convert-to-path actually produced a new value, so SVG-raw shapes (whose :content is a hiccup map) pass through unchanged instead of crashing. Closes #10612 AI-assisted-by: deepseek-v4-pro --- common/src/app/common/types/path.cljc | 6 ++++-- common/test/common_tests/types/path_data_test.cljc | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 60e736914b..2b1188682f 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -392,7 +392,9 @@ ([shape] (convert-to-path shape {})) ([shape objects] - (-> (stp/convert-to-path shape objects) - (update :content impl/path-data)))) + (let [shape' (stp/convert-to-path shape objects)] + (if (identical? shape shape') + shape' + (update shape' :content impl/path-data))))) (dm/export impl/decode-segments) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index f56d61a539..714270cb62 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1370,6 +1370,15 @@ ;; A path shape stays a path shape unchanged (t/is (= :path (:type result))))) +(t/deftest shape-to-path-svg-raw-does-not-throw + (let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0 + :selrect (make-selrect 0.0 0.0 100.0 50.0) + :content {:tag :text :attrs {:style {}} + :content [{:tag :tspan :attrs {} :content ["x"]}]}} + result (path/convert-to-path shape {})] + (t/is (= :svg-raw (:type result))) + (t/is (some? (:content result))))) + (t/deftest shape-to-path-rect-with-radius (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0 :r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0 From 47a3158602099c55f84bb820984d93084c00fefe Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 11:01:08 +0200 Subject: [PATCH 24/63] :bug: Fix component variant panel crash with mismatched property counts (#10616) Use `get` instead of `nth` to avoid index-out-of-bounds when a selected component copy has fewer variant properties than the first component in the selection. Closes #10615 AI-assisted-by: deepseek-v4-pro --- .../app/main/ui/workspace/sidebar/options/menus/component.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 536c087b36..2851124260 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -522,7 +522,7 @@ [:* [:div {:class (stl/css :variant-property-list)} (for [[pos prop] (map-indexed vector props-first)] - (let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties) + (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) options (get-options (:name prop)) boolean-pair (ctv/find-boolean-pair (mapv :id options)) options (cond-> options From 28fd798c5238a26310f2e0325d4725084c2c5685 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 11:03:06 +0200 Subject: [PATCH 25/63] :bug: Fix crash with degenerate selrect in text pipeline (#10618) Guard remaining text pipeline locations that accessed :selrect directly against nil/zero-dimension selrects by using safe-size-rect, which provides a 4-level fallback chain (selrect -> points -> shape fields -> empty 0.01x0.01 rect). Fixes: - fix-position in viewport_texts_html.cljs: replaced dm/get-prop :selrect with ctm/safe-size-rect for both old and new shape - assoc-position-data in modifiers.cljs: replaced (:selrect ...) with ctm/safe-size-rect for delta computation - change-orientation-modifiers in modifiers.cljc: replaced raw :selrect access with safe-size-rect for scale and origin computation Closes #10617 AI-assisted-by: mimo-v2.5-pro --- common/src/app/common/types/modifiers.cljc | 4 +-- .../common_tests/types/modifiers_test.cljc | 32 ++++++++++++++++++ .../app/main/data/workspace/modifiers.cljs | 4 +-- .../shapes/text/viewport_texts_html.cljs | 4 +-- .../data/workspace_texts_test.cljs | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index dbc0b52296..34a81365f1 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -500,9 +500,9 @@ shape-transform (:transform shape) shape-transform-inv (:transform-inverse shape) shape-center (gco/shape->center shape) - {sr-width :width sr-height :height} (:selrect shape) + {sr-width :width sr-height :height} (safe-size-rect shape) - origin (cond-> (gpt/point (:selrect shape)) + origin (cond-> (gpt/point (safe-size-rect shape)) (some? shape-transform) (gmt/transform-point-center shape-center shape-transform)) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 2cfb196fa8..405da89935 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -657,3 +657,35 @@ mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) result (ctm/apply-structure-modifiers shape mods)] (t/is (mth/close? 45.0 (:rotation result)))))) + +;; ─── change-orientation-modifiers — degenerate selrect ──────────────────────── + +(defn- make-degenerate-shape + "Build a shape whose selrect has zero width/height, simulating a shape + decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)." + [x y selrect-width selrect-height] + (let [shape (make-shape x y 100 50)] + (assoc shape :selrect (grc/map->Rect {:x x :y y + :width selrect-width + :height selrect-height + :x1 x :y1 y + :x2 (+ x selrect-width) + :y2 (+ y selrect-height)})))) + +(t/deftest change-orientation-zero-width-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-shape 0 0 0 50) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-height-selrect-does-not-throw + (t/testing "orientation change on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-shape 0 0 100 0) + mods (ctm/change-orientation-modifiers shape :vert)] + (t/is (some? mods))))) + +(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw + (t/testing "orientation change on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-shape 0 0 0 0) + mods (ctm/change-orientation-modifiers shape :horiz)] + (t/is (some? mods))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..b5696414c8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -256,8 +256,8 @@ (defn assoc-position-data [shape position-data old-shape] - (let [deltav (gpt/to-vec (gpt/point (:selrect old-shape)) - (gpt/point (:selrect shape))) + (let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape)) + (gpt/point (ctm/safe-size-rect shape))) position-data (-> position-data (gsh/move-position-data deltav))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..ae6f49f688 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -35,8 +35,8 @@ (if-let [modifiers (:modifiers shape)] (let [shape' (gsh/transform-shape shape modifiers) - old-sr (dm/get-prop shape :selrect) - new-sr (dm/get-prop shape' :selrect) + old-sr (ctm/safe-size-rect shape) + new-sr (ctm/safe-size-rect shape') ;; We need to remove the movement because the dynamic modifiers will have move it deltav (gpt/to-vec (gpt/point new-sr) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 15db703ef8..ee438672c5 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -9,9 +9,11 @@ [app.common.geom.rect :as grc] [app.common.test-helpers.files :as cthf] [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.main.data.workspace.texts :as dwt] + [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths])) @@ -374,3 +376,34 @@ "exactly one typography was added") (t/is (= "0.1" (:letter-spacing (first typographies))) "float letter-spacing is normalised to 2-decimal string"))))))) + +;; --------------------------------------------------------------------------- +;; Tests: fix-position with degenerate selrect +;; --------------------------------------------------------------------------- + +(t/deftest fix-position-zero-width-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect width does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-height-selrect-does-not-throw + (t/testing "fix-position on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw + (t/testing "fix-position on a fully degenerate selrect does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true}) + shape' (assoc shape :modifiers modifiers) + result (vth/fix-position shape')] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) From 80d688be9375fc51a6b3d732f235f91cc8ca068b Mon Sep 17 00:00:00 2001 From: Alonso Torres <alonso.torres@kaleidos.net> Date: Thu, 9 Jul 2026 11:37:45 +0200 Subject: [PATCH 26/63] :bug: Fix problems with comments clusters (#10543) --- frontend/src/app/main/data/comments.cljs | 20 +- .../src/app/main/data/workspace/comments.cljs | 72 +++- frontend/src/app/main/ui/comments.cljs | 319 +++++++++++------- frontend/src/app/main/ui/comments.scss | 84 ++++- frontend/src/app/main/ui/viewer/comments.cljs | 68 +++- .../main/ui/workspace/viewport/comments.cljs | 43 ++- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/comments_clustering_test.cljs | 144 ++++++++ 8 files changed, 578 insertions(+), 174 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/comments_clustering_test.cljs diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index c0c0dc7409..a69d759272 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -503,7 +503,7 @@ (-> state (update :comments-local assoc :open id) (update :comments-local assoc :options nil) - (update :comments-local dissoc :draft))))) + (update :comments-local dissoc :draft :expanded))))) (defn close-thread [] @@ -511,8 +511,26 @@ ptk/UpdateEvent (update [_ state] (-> state + (update :comments-local dissoc :open :draft :options :expanded))))) + +(defn expand-comment-group + "Temporarily mark a proximity cluster of threads as expanded so its bubbles + can be laid out visually without altering their stored positions." + [thread-ids] + (ptk/reify ::expand-comment-group + ptk/UpdateEvent + (update [_ state] + (-> state + (update :comments-local assoc :expanded (set thread-ids)) (update :comments-local dissoc :open :draft :options))))) +(defn collapse-comment-group + [] + (ptk/reify ::collapse-comment-group + ptk/UpdateEvent + (update [_ state] + (update state :comments-local dissoc :expanded)))) + (defn update-filters [{:keys [mode show list] :as params}] (ptk/reify ::update-filters diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 968cc8194e..16251100bf 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -25,6 +25,7 @@ [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.layout :as dwlo] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] [app.main.repo :as rp] [app.main.router :as rt] @@ -79,15 +80,16 @@ (let [local (:comments-local state) comments-mode? (= :comments (get-in state [:workspace-drawing :tool]))] (cond - (:draft local) (rx/of (dcmt/close-thread)) - (:open local) (rx/of (dcmt/close-thread)) + (:draft local) (rx/of (dcmt/close-thread)) + (:open local) (rx/of (dcmt/close-thread)) + (:expanded local) (rx/of (dcmt/collapse-comment-group)) ;; Only clear edition / deselect on interrupt while the comments ;; tool is active. When comments are merely visible during design, ;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; the freshly selected shape, breaking click selection. - comments-mode? (rx/of (dwe/clear-edition-mode) - (dws/deselect-all true)) - :else (rx/empty)))))) + comments-mode? (rx/of (dwe/clear-edition-mode) + (dws/deselect-all true)) + :else (rx/empty)))))) ;; Event responsible of the what should be executed when user clicked ;; on the comments layer. An option can be create a new draft thread, @@ -98,17 +100,28 @@ (ptk/reify ::handle-comment-layer-click ptk/WatchEvent (watch [_ state _] - (if (not= :comments (get-in state [:workspace-drawing :tool])) - (rx/empty) - (let [local (:comments-local state)] - (if (some? (:open local)) - (rx/of (dcmt/close-thread)) - (let [page-id (:current-page-id state) - file-id (:current-file-id state) - params {:position position - :page-id page-id - :file-id file-id}] - (rx/of (dcmt/create-draft params))))))))) + (let [local (:comments-local state) + comments-mode? (= :comments (get-in state [:workspace-drawing :tool]))] + (cond + ;; A click anywhere collapses a temporarily separated cluster, + ;; regardless of the active tool. Opening a thread clears :expanded, + ;; so this never fires while a comment is open. + (some? (:expanded local)) + (rx/of (dcmt/collapse-comment-group)) + + (not comments-mode?) + (rx/empty) + + (some? (:open local)) + (rx/of (dcmt/close-thread)) + + :else + (let [page-id (:current-page-id state) + file-id (:current-file-id state) + params {:position position + :page-id page-id + :file-id file-id}] + (rx/of (dcmt/create-draft params)))))))) (defn center-to-comment-thread [{:keys [position] :as thread}] @@ -127,7 +140,11 @@ nh (- (/ (:height vbox) 2) ph) nx (- (:x position) nw) ny (- (:y position) nh)] - (update local :vbox assoc :x nx :y ny))))))) + (update local :vbox assoc :x nx :y ny))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn- set-comment-thread "Stores the comment thread in the workspace state so its bubble re-renders." @@ -305,6 +322,25 @@ distance-overlap 32] (< distance-zoom distance-overlap))) +(defn group-bubbles + "Group bubbles into vectors by proximity: each group holds threads whose + bubbles overlap at the given `zoom`." + [zoom circles] + (letfn [(overlaps-group? [current group] + (some #(overlap-bubbles? zoom current %) group)) + + (find-overlapping-group [groups current] + (some #(when (overlaps-group? current %) %) groups)) + + (add-to-group [groups target current] + (map #(if (= % target) (cons current %) %) groups)) + + (assign [groups current] + (if-let [group (find-overlapping-group groups current)] + (add-to-group groups group current) + (cons [current] groups)))] + (reduce assign [] circles))) + (defn- calculate-zoom-scale-to-ungroup-current-bubble "Calculate the minimum zoom scale needed to keep the current bubble ungrouped from the rest" [zoom thread threads] @@ -373,7 +409,7 @@ (rx/empty)) (->> (rx/of (dwd/select-for-drawing :comments) - (set-zoom-to-separate-grouped-bubbles thread) + ;; Center on the comment (no zoom) and open its thread. (center-to-comment-thread thread) (with-meta (dcmt/open-thread thread) {::ev/origin "workspace"})) (rx/observe-on :async)))))) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index c3bd8044fa..bf49f7acc0 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -18,8 +18,6 @@ [app.main.data.comments :as dcm] [app.main.data.modal :as modal] [app.main.data.workspace.comments :as dwcm] - [app.main.data.workspace.viewport :as dwv] - [app.main.data.workspace.zoom :as dwz] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] @@ -704,15 +702,21 @@ (mf/defc comment-reply-form* {::mf/private true} - [{:keys [on-submit]}] + [{:keys [on-submit on-cancel]}] (let [content (mf/use-state "") disabled? (or (blank-content? @content) (exceeds-length? @content)) + ;; Contexts without a global interrupt handler (e.g. the viewer) pass an + ;; explicit cancel; otherwise fall back to the interrupt cycle. on-cancel (mf/use-fn - #(st/emit! :interrupt)) + (mf/deps on-cancel) + (fn [] + (if (fn? on-cancel) + (on-cancel) + (st/emit! :interrupt)))) on-change (mf/use-fn @@ -799,8 +803,10 @@ (some? position-modifier) (gpt/transform position-modifier)) content (:content draft) - bubble-margin (gpt/point 0 0) + ;; Keep the draft bubble centered on the comment position (matching a + ;; created bubble) while the input box is offset to the side. + bubble-margin (gpt/point 24 24) pos (offset-position position viewport zoom bubble-margin) margin-x (* (:x bubble-margin) (if (= (:h-dir pos) :left) -1 1)) @@ -808,6 +814,9 @@ pos-x (+ (* (:x pos) zoom) margin-x) pos-y (- (* (:y pos) zoom) margin-y) + bubble-x (floor (* (:x position) zoom)) + bubble-y (floor (* (:y position) zoom)) + disabled? (or (blank-content? content) (exceeds-length? content)) @@ -833,39 +842,36 @@ (on-submit draft)))] [:> (mf/provider mentions-context) {:value mentions-s} - [:div {:class (stl/css-case :floating-thread-draft-wrapper true + [:div {:class (stl/css :floating-preview-wrapper :floating-preview-bubble) + :data-testid "floating-thread-bubble" + :style {:top (dm/str bubble-y "px") + :left (dm/str bubble-x "px")} + :on-click dom/stop-propagation} + [:> comment-avatar* {:class (stl/css :avatar-lg) + :image (cfg/resolve-profile-photo-url profile)}]] + + [:div {:class (stl/css-case :floating-thread-draft-inner-wrapper true + :cursor-auto true :left (= (:h-dir pos) :left) :top (= (:v-dir pos) :top)) :style {:top (str pos-y "px") - :left (str pos-x "px")}} - [:div - {:data-testid "floating-thread-bubble" - :style {:top (str pos-y "px") - :left (str pos-x "px")} - :on-click dom/stop-propagation} - [:> comment-avatar* {:class (stl/css :avatar-lg) - :image (cfg/resolve-profile-photo-url profile)}]] - [:div {:class (stl/css :floating-thread-draft-inner-wrapper - :cursor-auto) - :style {:top (str (- pos-y 24) "px") - :left (str (+ pos-x 28) "px")} - - :on-click dom/stop-propagation} - [:div {:class (stl/css :form)} - [:> comment-input* - {:placeholder (tr "labels.write-new-comment") - :value (or content "") - :autofocus true - :on-esc on-esc - :on-change on-change - :on-ctrl-enter on-submit*}] - (when (exceeds-length? content) - [:div {:class (stl/css :error-text)} - (tr "errors.character-limit-exceeded")]) - [:> comment-form-buttons* {:on-submit on-submit* - :on-cancel on-esc - :is-disabled disabled?}]] - [:> mentions-panel*]]]])) + :left (str pos-x "px")} + :on-click dom/stop-propagation} + [:div {:class (stl/css :form)} + [:> comment-input* + {:placeholder (tr "labels.write-new-comment") + :value (or content "") + :autofocus true + :on-esc on-esc + :on-change on-change + :on-ctrl-enter on-submit*}] + (when (exceeds-length? content) + [:div {:class (stl/css :error-text)} + (tr "errors.character-limit-exceeded")]) + [:> comment-form-buttons* {:on-submit on-submit* + :on-cancel on-esc + :is-disabled disabled?}]] + [:> mentions-panel*]]])) (mf/defc comment-floating-thread-header* {::mf/private true} @@ -1057,7 +1063,10 @@ (mf/use-fn (mf/deps thread) (fn [content] - (st/emit! (dcm/add-comment thread content))))] + (st/emit! (dcm/add-comment thread content)))) + + on-cancel + (mf/use-fn #(st/emit! (dcm/close-thread)))] (mf/with-effect [thread-id] (st/emit! (dcm/retrieve-comments thread-id))) @@ -1092,60 +1101,65 @@ [:* {:key (dm/str (:id item))} [:> comment-floating-thread-item* {:comment item}]])] - [:> comment-reply-form* {:on-submit on-submit}] + [:> comment-reply-form* {:on-submit on-submit + :on-cancel (when (= origin :viewer) on-cancel)}] [:> mentions-panel*]])])) -(defn group-bubbles - "Group bubbles in different vectors by proximity" - ([zoom circles] - (group-bubbles zoom circles [] [])) +;; Screen-space gap (px) between concentric rings of an expanded cluster. +(def ^:private expanded-ring-gap 44) - ([zoom circles visited groups] - (if (empty? circles) - groups - (let [current (first circles) - remaining (rest circles) - overlapping-group (some (fn [group] - (when (some (partial dwcm/overlap-bubbles? zoom current) group) group)) - groups)] - (if overlapping-group - (group-bubbles zoom remaining visited (map (fn [group] - (if (= group overlapping-group) - (cons current group) - group)) - groups)) - (group-bubbles zoom remaining visited (cons [current] groups))))))) +;; Number of bubbles that fit in the innermost ring; each further ring +;; grows its capacity proportionally to its circumference. +(def ^:private expanded-ring-base 6) -(defn- inside-vbox? - "Checks if a bubble or a bubble group is inside a viewbox" - [thread-group wl] - (let [vbox (:vbox wl) - positions (mapv :position thread-group) - position (gpt/center-points positions) - pos-x (:x position) - pos-y (:y position) - x1 (:x vbox) - y1 (:y vbox) - x2 (+ x1 (:width vbox)) - y2 (+ y1 (:height vbox))] - (and (> x2 pos-x x1) (> y2 pos-y y1)))) +(defn- expanded-ring-slot + "Return [ring slot capacity] placing the bubble at index `i` (0-based) into + concentric rings, filling the innermost ring first." + [i] + (loop [ring 1 + i i] + (let [capacity (* expanded-ring-base ring)] + (if (< i capacity) + [ring i capacity] + (recur (inc ring) (- i capacity)))))) -(defn- calculate-zoom-scale - "Calculates the zoom level needed to ungroup the largest number of bubbles while - keeping them all visible in the viewbox." - [position zoom threads wl] - (let [num-threads (count threads) - grouped-threads (group-bubbles zoom threads) - num-grouped-threads (count grouped-threads) - zoom-scale-step 1.75 - scaled-zoom (* zoom zoom-scale-step) - zoomed-wl (dwz/impl-update-zoom wl position scaled-zoom) - outside-vbox? (complement inside-vbox?)] - (if (or (= num-threads num-grouped-threads) - (some #(outside-vbox? % zoomed-wl) grouped-threads)) - zoom - (calculate-zoom-scale position scaled-zoom threads zoomed-wl)))) +(defn- expanded-ring-vector + "Pure ring displacement (px) around the cluster center for the bubble at index + `i` (0-based), laid out into concentric rings, innermost first." + [i] + (let [[ring slot capacity] (expanded-ring-slot i) + ;; Start each ring at the top and stagger alternate rings by half a + ;; slot so bubbles don't line up radially. + angle (+ (* (/ slot capacity) 2 mth/PI) + (- (/ mth/PI 2)) + (if (even? ring) (/ mth/PI capacity) 0)) + radius (* ring expanded-ring-gap)] + (gpt/point (* radius (mth/cos angle)) + (* radius (mth/sin angle))))) + +(defn expanded-group-center + "Cluster center point (stored coordinates) shared by all bubbles of a group." + [thread-group] + (gpt/center-points (mapv :position thread-group))) + +(defn expanded-group-offsets + "Return a seq of [thread offset ring] triples laying each thread of + `thread-group` into a centered concentric-ring layout, leaving stored + positions untouched. `offset` is the total screen-space displacement from the + bubble's own position; `ring` is just the displacement from the cluster + center (used to animate the fan-out)." + [zoom thread-group] + (let [threads (sort-by :seqn thread-group) + center (expanded-group-center threads)] + (map-indexed + (fn [i thread] + (let [position (:position thread) + ring (expanded-ring-vector i) + base (gpt/point (* (- (:x center) (:x position)) zoom) + (* (- (:y center) (:y position)) zoom))] + [thread (gpt/add base ring) ring])) + threads))) (mf/defc comment-floating-group* {::mf/wrap [mf/memo]} @@ -1167,16 +1181,15 @@ ;; Click-through while transforming a shape, so it doesn't capture the drag dragging? (some? (mf/deref refs/current-transform)) + thread-ids (mf/with-memo [thread-group] + (into #{} (map :id) thread-group)) + on-click (mf/use-fn - (mf/deps thread-group position zoom) - (fn [] - (let [wl (deref refs/workspace-local) - centered-wl (dwv/calculate-centered-viewbox wl position) - updated-zoom (calculate-zoom-scale position zoom thread-group centered-wl) - scale-zoom (/ updated-zoom zoom)] - (st/emit! (dwv/update-viewport-position-center position) - (dwz/set-zoom position scale-zoom)))))] + (mf/deps thread-ids) + (fn [event] + (dom/stop-propagation event) + (st/emit! (dcm/expand-comment-group thread-ids))))] [:div {:style {:top (dm/str pos-y "px") :left (dm/str pos-x "px") @@ -1189,9 +1202,31 @@ :data-testid (dm/str "floating-thread-bubble-" test-id)} num-threads]])) +(mf/defc comment-floating-ghost* + "Dashed, semi-transparent placeholder shown at the cluster center while its + bubbles are fanned out into the expanded ring." + {::mf/wrap [mf/memo] + ::mf/private true} + [{:keys [thread-group zoom position-modifier]}] + (let [center (expanded-group-center thread-group) + center (cond-> center + (some? position-modifier) + (gpt/transform position-modifier)) + pos-x (floor (* (:x center) zoom)) + pos-y (floor (* (:y center) zoom)) + num-threads (str (count thread-group))] + [:div {:style {:top (dm/str pos-y "px") + :left (dm/str pos-x "px") + :pointer-events "none"} + :class (stl/css :floating-preview-wrapper :floating-preview-bubble :floating-preview-ghost)} + [:> comment-avatar* + {:class (stl/css :avatar-lg) + :variant "read"} + num-threads]])) + (mf/defc comment-floating-bubble* {::mf/wrap [mf/memo]} - [{:keys [thread zoom is-open on-click origin position-modifier]}] + [{:keys [thread zoom is-open on-click origin position-modifier offset ring]}] (let [owner (mf/with-memo [thread] (dcm/get-owner thread)) @@ -1202,6 +1237,10 @@ frame-id (:frame-id thread) + ;; A bubble with `offset` is one of the fanned-out members of an + ;; expanded cluster. + expanded? (some? offset) + ;; Click-through while transforming a shape, so it doesn't capture the drag dragging? (some? (mf/deref refs/current-transform)) @@ -1212,8 +1251,31 @@ :new-position-y nil :new-frame-id frame-id})) - pos-x (floor (* (or (:new-position-x @state) (:x position)) zoom)) - pos-y (floor (* (or (:new-position-y @state) (:y position)) zoom)) + new-x (:new-position-x @state) + new-y (:new-position-y @state) + + ;; While dragging, the new position already accounts for the ring offset; + ;; otherwise an expanded bubble sits at its stored position plus the + ;; screen-space ring offset. + pos-x (if (some? new-x) + (floor (* new-x zoom)) + (+ (floor (* (:x position) zoom)) + (if expanded? (:x offset) 0))) + pos-y (if (some? new-y) + (floor (* new-y zoom)) + (+ (floor (* (:y position) zoom)) + (if expanded? (:y offset) 0))) + + ;; World-space anchor a drag starts from: an expanded bubble is shown at + ;; its ring position, so dragging must begin there, not at its stored + ;; position. + drag-base-x (if expanded? (+ (:x position) (/ (:x offset) zoom)) (:x position)) + drag-base-y (if expanded? (+ (:y position) (/ (:y offset) zoom)) (:y position)) + + ;; CSS custom properties fed to the fan-out animation; nil for regular + ;; (non-expanded) bubbles. + ring-x (when (and expanded? (some? ring)) (dm/str (- (:x ring)) "px")) + ring-y (when (and expanded? (some? ring)) (dm/str (- (:y ring)) "px")) drag? (mf/use-ref nil) was-open? (mf/use-ref nil) @@ -1237,7 +1299,7 @@ on-pointer-up (mf/use-fn - (mf/deps origin thread (select-keys @state [:new-position-x :new-position-y :new-frame-id])) + (mf/deps origin thread expanded? (select-keys @state [:new-position-x :new-position-y :new-frame-id])) (fn [event] (when (not= origin :viewer) (swap! state assoc :is-grabbing false) @@ -1250,13 +1312,16 @@ (some? (:new-position-y @state))) (st/emit! (dwcm/update-comment-thread-position thread [(:new-position-x @state) (:new-position-y @state)])) + ;; Dropping a fanned-out bubble commits its new spot and closes + ;; the temporary cluster expansion. + (when expanded? (st/emit! (dcm/collapse-comment-group))) (swap! state assoc :new-position-x nil :new-position-y nil))))) on-pointer-move (mf/use-fn - (mf/deps origin drag? position zoom) + (mf/deps origin drag? drag-base-x drag-base-y zoom) (fn [event] (when (not= origin :viewer) (mf/set-ref-val! drag? true) @@ -1267,8 +1332,8 @@ delta-x (/ (- (:x current-pt) (:x start-pt)) zoom) delta-y (/ (- (:y current-pt) (:y start-pt)) zoom)] (swap! state assoc - :new-position-x (+ (:x position) delta-x) - :new-position-y (+ (:y position) delta-y))))))) + :new-position-x (+ drag-base-x delta-x) + :new-position-y (+ drag-base-y delta-y))))))) on-pointer-enter (mf/use-fn @@ -1286,7 +1351,7 @@ on-click* (mf/use-fn - (mf/deps origin thread on-click was-open? drag? (select-keys @state [:is-hover])) + (mf/deps origin thread on-click was-open? drag?) (fn [event] (dom/stop-propagation event) (when (or (and (mf/ref-val was-open?) (mf/ref-val drag?)) @@ -1298,34 +1363,42 @@ [:div {:style {:top (dm/str pos-y "px") :left (dm/str pos-x "px") - :pointer-events (when dragging? "none")} - :on-pointer-down on-pointer-down - :on-pointer-up on-pointer-up - :on-pointer-move on-pointer-move - :on-pointer-enter on-pointer-enter - :on-pointer-leave on-pointer-leave - :on-click on-click* + :pointer-events (when dragging? "none") + "--comment-ring-x" ring-x + "--comment-ring-y" ring-y} :class (stl/css-case :floating-preview-wrapper true - :floating-preview-bubble (false? (:is-hover @state)))} + :floating-preview-bubble (false? (:is-hover @state)) + :floating-preview-expanded expanded? + :floating-preview-hovered (:is-hover @state))} - (if (:is-hover @state) - [:div {:class (stl/css-case :floating-thread-wrapper true - :floating-preview-displacement true - :cursor-pointer (false? (:is-grabbing @state)) - :cursor-grabbing (true? (:is-grabbing @state)))} + ;; The avatar circle is the only pointer target: it drives hover, drag and + ;; click, so hovering the preview card below never keeps the tooltip open. + [:div {:on-pointer-down on-pointer-down + :on-pointer-up on-pointer-up + :on-pointer-move on-pointer-move + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave + :on-click on-click* + :class (stl/css-case :floating-preview-avatar true + :cursor-pointer (false? (:is-grabbing @state)) + :cursor-grabbing (true? (:is-grabbing @state)))} + [:> comment-avatar* + {:image (cfg/resolve-profile-photo-url owner) + :class (stl/css :avatar-lg) + :data-testid (dm/str "floating-thread-bubble-" (:seqn thread)) + :variant (cond + (:is-resolved thread) "solved" + (pos? (:count-unread-comments thread)) "unread" + :else "read")}]] + + (when (:is-hover @state) + [:div {:class (stl/css :floating-thread-wrapper + :floating-preview-displacement + :floating-preview-hover-card)} [:div {:class (stl/css :floating-thread-item-wrapper)} [:div {:class (stl/css :floating-thread-item)} [:> comment-info* {:item thread - :profile owner}]]]] - - [:> comment-avatar* - {:image (cfg/resolve-profile-photo-url owner) - :class (stl/css :avatar-lg) - :data-testid (dm/str "floating-thread-bubble-" (:seqn thread)) - :variant (cond - (:is-resolved thread) "solved" - (pos? (:count-unread-comments thread)) "unread" - :else "read")}])])) + :profile owner}]]]])])) (mf/defc comment-sidebar-thread-item* {::mf/private true} diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index 8c99a6c95f..fc47433711 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS INC Sucursal en España SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; .cursor-grabbing { cursor: grabbing; @@ -104,6 +105,7 @@ justify-content: center; font-size: deprecated.$fs-12; background-color: var(--color-background-quaternary); + color: var(--color-foreground-quaternary); } .avatar-mask { @@ -167,31 +169,68 @@ z-index: initial; } -.floating-thread-draft-wrapper { - position: absolute; +// Sole pointer target of a floating bubble. +.floating-preview-avatar { display: flex; - flex-direction: row; - column-gap: deprecated.$s-12; + height: $sz-32; + width: $sz-32; +} - --translate-x: 0%; - --translate-y: 0%; +// Expanded-cluster bubble: sits above regular bubbles and fans out on appearance. +.floating-preview-expanded { + z-index: 2; + animation: comment-bubble-fan-out 0.18s ease-out; +} - transform: translate(var(--translate-x), var(--translate-y)); - - &.left { - --translate-x: -100%; - - flex-direction: row-reverse; +@keyframes comment-bubble-fan-out { + from { + translate: var(--comment-ring-x, 0) var(--comment-ring-y, 0); + opacity: 0; } - &.top { - --translate-y: -100%; - - align-items: flex-end; + to { + translate: 0 0; + opacity: 1; } } +// Hovered bubble's preview card floats above every other bubble. +.floating-preview-hovered { + z-index: 10; +} + +// Placeholder shown at the cluster center while its bubbles are fanned out. +.floating-preview-ghost { + z-index: 1; + opacity: 0.6; + pointer-events: none; + animation: comment-ghost-appear 0.18s ease-out; + + .avatar { + border-style: dashed; + background-color: var(--comment-modal-background-color); + } +} + +@keyframes comment-ghost-appear { + from { + opacity: 0; + } + + to { + opacity: 0.6; + } +} + +// Anchored to the wrapper origin and click-through, so hover stays bound to the avatar. +.floating-thread-wrapper.floating-preview-hover-card { + top: 0; + left: 0; + pointer-events: none; +} + .floating-thread-draft-inner-wrapper { + position: absolute; display: flex; flex-direction: column; gap: deprecated.$s-12; @@ -202,6 +241,19 @@ border: deprecated.$s-2 solid var(--modal-border-color); background-color: var(--comment-modal-background-color); max-height: var(--comment-height); + + --translate-x: 0%; + --translate-y: 0%; + + transform: translate(var(--translate-x), var(--translate-y)); + + &.left { + --translate-x: -100%; + } + + &.top { + --translate-y: -100%; + } } .floating-preview-displacement { diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index 4c18332a5d..e08d50cb5a 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -15,6 +15,7 @@ [app.common.geom.shapes :as gsh] [app.main.data.comments :as dcm] [app.main.data.event :as ev] + [app.main.data.workspace.comments :as dwcm] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.comments :as cmt] @@ -176,13 +177,22 @@ (-> (dcm/open-thread thread) (with-meta {::ev/origin "viewer"})))))) + expanded (:expanded local) + on-click (mf/use-fn - (mf/deps open-thread-id zoom page-id file-id modifier2) + (mf/deps open-thread-id expanded zoom page-id file-id modifier2) (fn [event] (dom/stop-propagation event) - (if (some? open-thread-id) + (cond + ;; A click anywhere first collapses a temporarily expanded cluster. + (some? expanded) + (st/emit! (dcm/collapse-comment-group)) + + (some? open-thread-id) (st/emit! (dcm/close-thread)) + + :else (let [event (dom/event->native-event event) position (-> (dom/get-offset-position event) (update :x #(/ % zoom)) @@ -204,24 +214,60 @@ (st/emit! (dcm/create-thread-on-viewer params) (dcm/close-thread)))))] + ;; Any zoom change collapses an expanded cluster back, matching the workspace. + (mf/with-effect [zoom] + (st/emit! (dcm/collapse-comment-group))) + [:div {:class (stl/css :comments-section) :on-click on-click} [:div {:class (dm/str cursor " " (stl/css :viewer-comments-container))} [:div {:class (stl/css :threads)} - (for [item threads] - [:> cmt/comment-floating-bubble* - {:thread item - :position-modifier modifier1 - :zoom zoom - :on-click on-bubble-click - :is-open (= (:id item) (:open local)) - :key (:seqn item) - :origin :viewer}]) + (for [thread-group (dwcm/group-bubbles zoom threads)] + (let [group? (> (count thread-group) 1) + thread (first thread-group) + expanded? (and group? + (= expanded (into #{} (map :id) thread-group)))] + (cond + expanded? + [:* {:key (:seqn thread)} + [:> cmt/comment-floating-ghost* + {:thread-group thread-group + :zoom zoom + :position-modifier modifier1}] + (for [[thread offset ring] (cmt/expanded-group-offsets zoom thread-group)] + [:> cmt/comment-floating-bubble* + {:thread thread + :position-modifier modifier1 + :zoom zoom + :offset offset + :ring ring + :on-click on-bubble-click + :is-open false + :origin :viewer + :key (:seqn thread)}])] + + group? + [:> cmt/comment-floating-group* + {:thread-group thread-group + :zoom zoom + :position-modifier modifier1 + :key (:seqn thread)}] + + :else + [:> cmt/comment-floating-bubble* + {:thread thread + :position-modifier modifier1 + :zoom zoom + :on-click on-bubble-click + :is-open (= (:id thread) (:open local)) + :origin :viewer + :key (:seqn thread)}]))) (when-let [thread (get threads-map open-thread-id)] [:> cmt/comment-floating-thread* {:thread thread :position-modifier modifier1 + :origin :viewer :viewport {:offset-x 0 :offset-y 0 :width (:width vsize) :height (:height vsize)} :zoom zoom}]) diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index 8fc476751c..288b537f1b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -28,12 +28,14 @@ (mf/defc comment-floating-bubble-wrapper* {::mf/private true} - [{:keys [thread zoom is-open]}] + [{:keys [thread zoom is-open offset ring]}] (let [position-modifier (use-frame-position-modifier (:frame-id thread))] [:> cmt/comment-floating-bubble* {:thread thread :zoom zoom :position-modifier position-modifier + :offset offset + :ring ring :is-open is-open}])) (mf/defc comment-floating-group-wrapper* @@ -46,6 +48,16 @@ :zoom zoom :position-modifier position-modifier}])) +(mf/defc comment-floating-ghost-wrapper* + {::mf/private true} + [{:keys [thread-group zoom]}] + (let [thread (first thread-group) + position-modifier (use-frame-position-modifier (:frame-id thread))] + [:> cmt/comment-floating-ghost* + {:thread-group thread-group + :zoom zoom + :position-modifier position-modifier}])) + (mf/defc comment-floating-thread-wrapper* {::mf/private true} [{:keys [thread viewport zoom]}] @@ -93,6 +105,10 @@ (st/emit! (dwcm/initialize-comments file-id)) (fn [] (st/emit! ::dwcm/finalize))) + ;; Any viewport change (pan/zoom) collapses an expanded cluster back. + (mf/with-effect [vbox zoom] + (st/emit! (dcm/collapse-comment-group))) + [:div {:class (stl/css :comments-section)} [:div {:id "comments" @@ -102,13 +118,30 @@ [:div {:class (stl/css :threads) :style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}} - (for [thread-group (cmt/group-bubbles zoom threads)] - (let [group? (> (count thread-group) 1) - thread (first thread-group)] - (if group? + (for [thread-group (dwcm/group-bubbles zoom threads)] + (let [group? (> (count thread-group) 1) + thread (first thread-group) + expanded? (and group? + (= (:expanded local) (into #{} (map :id) thread-group)))] + (cond + expanded? + [:* {:key (:seqn thread)} + [:> comment-floating-ghost-wrapper* {:thread-group thread-group + :zoom zoom}] + (for [[thread offset ring] (cmt/expanded-group-offsets zoom thread-group)] + [:> comment-floating-bubble-wrapper* {:thread thread + :zoom zoom + :offset offset + :ring ring + :is-open false + :key (:seqn thread)}])] + + group? [:> comment-floating-group-wrapper* {:thread-group thread-group :zoom zoom :key (:seqn thread)}] + + :else [:> comment-floating-bubble-wrapper* {:thread thread :zoom zoom :is-open (= (:id thread) (:open local)) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6fc3fdd1cc..a83cba73be 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -52,6 +52,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] + [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.measures-menu-props-test] @@ -122,6 +123,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.workspace-tokens-remap-test + 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.measures-menu-props-test diff --git a/frontend/test/frontend_tests/ui/comments_clustering_test.cljs b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs new file mode 100644 index 0000000000..eeaa7d1a97 --- /dev/null +++ b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs @@ -0,0 +1,144 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.ui.comments-clustering-test + (:require + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.main.data.comments :as dcm] + [app.main.data.workspace.comments :as dwcm] + [app.main.ui.comments :as cmt] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(defn- thread + [seqn x y] + {:id seqn :seqn seqn :position (gpt/point x y)}) + +(defn- magnitude + [p] + (gpt/distance p (gpt/point 0 0))) + +(defn- find-group + "Return the group (from a group-bubbles result) that contains the thread with + the given seqn." + [groups seqn] + (some (fn [group] + (when (some #(= (:seqn %) seqn) group) group)) + groups)) + +;; --- overlap-bubbles? ------------------------------------------------------ + +(t/deftest overlap-bubbles-close + (t/testing "bubbles within the overlap distance overlap" + (t/is (true? (dwcm/overlap-bubbles? 1 (thread 1 100 100) (thread 2 110 100)))))) + +(t/deftest overlap-bubbles-far + (t/testing "distant bubbles do not overlap" + (t/is (false? (dwcm/overlap-bubbles? 1 (thread 1 100 100) (thread 2 200 100)))))) + +(t/deftest overlap-bubbles-zoom + (t/testing "zoom scales the screen-space distance, separating close bubbles" + ;; 10px apart in canvas space is 40px on screen at zoom 4, above the 32px + ;; overlap threshold. + (t/is (false? (dwcm/overlap-bubbles? 4 (thread 1 100 100) (thread 2 110 100)))))) + +;; --- group-bubbles --------------------------------------------------------- + +(t/deftest group-bubbles-separate-clusters + (let [threads [(thread 1 100 100) + (thread 2 105 100) + (thread 3 500 500)] + groups (dwcm/group-bubbles 1 threads)] + (t/testing "produces one group per proximity cluster" + (t/is (= 2 (count groups)))) + (t/testing "overlapping bubbles share a group" + (t/is (= 2 (count (find-group groups 1)))) + (t/is (some #(= (:seqn %) 2) (find-group groups 1)))) + (t/testing "a distant bubble stays alone" + (t/is (= 1 (count (find-group groups 3))))))) + +(t/deftest group-bubbles-transitive-chain + ;; A-B and B-C overlap but A-C do not; the shared neighbour B keeps them in a + ;; single group. + (let [threads [(thread 1 100 100) + (thread 2 120 100) + (thread 3 140 100)] + groups (dwcm/group-bubbles 1 threads)] + (t/testing "a chain of overlaps forms a single group" + (t/is (= 1 (count groups))) + (t/is (= 3 (count (first groups))))))) + +;; --- expanded-group-center ------------------------------------------------- + +(t/deftest expanded-group-center-centroid + (t/testing "the cluster center is the centroid of the bubble positions" + (t/is (= (gpt/point 150 150) + (cmt/expanded-group-center [(thread 1 100 100) + (thread 2 200 200)]))))) + +;; --- expanded-group-offsets ------------------------------------------------ + +(t/deftest expanded-group-offsets-single-ring + (let [threads (mapv #(thread % 100 100) (range 6)) + offsets (cmt/expanded-group-offsets 1 threads)] + (t/testing "lays out every bubble of the cluster" + (t/is (= 6 (count offsets)))) + (t/testing "the innermost ring keeps a constant radius" + (t/is (every? #(mth/close? 44 (magnitude (nth % 2))) offsets))) + (t/testing "the first bubble is placed at the top of the ring" + (let [ring (nth (first offsets) 2)] + (t/is (mth/close? 0 (:x ring))) + (t/is (mth/close? -44 (:y ring))))))) + +(t/deftest expanded-group-offsets-concentric-rings + (let [threads (mapv #(thread % 100 100) (range 8)) + rings (mapv #(magnitude (nth % 2)) + (cmt/expanded-group-offsets 1 threads))] + (t/testing "the first six bubbles fill the inner ring" + (t/is (every? #(mth/close? 44 %) (take 6 rings)))) + (t/testing "the overflow spills onto a second, wider ring" + (t/is (every? #(mth/close? 88 %) (drop 6 rings)))))) + +(t/deftest expanded-group-offsets-total-offset + (t/testing "the total offset moves each bubble from its position to the ring" + ;; With all bubbles sharing a position, the base displacement is zero, so the + ;; total offset equals the pure ring vector. + (let [threads (mapv #(thread % 100 100) (range 3))] + (doseq [[_ offset ring] (cmt/expanded-group-offsets 1 threads)] + (t/is (mth/close? (:x offset) (:x ring))) + (t/is (mth/close? (:y offset) (:y ring))))))) + +;; --- cluster state transitions --------------------------------------------- + +(t/deftest expand-comment-group-replaces-open + (let [state {:comments-local {:open 5 :draft {} :options 9}} + result (ptk/update (dcm/expand-comment-group #{1 2 3}) state) + local (:comments-local result)] + (t/testing "expanding a cluster records its member ids" + (t/is (= #{1 2 3} (:expanded local)))) + (t/testing "expanding a cluster clears any open thread or draft" + (t/is (nil? (:open local))) + (t/is (nil? (:draft local)))))) + +(t/deftest collapse-comment-group-preserves-open + (let [state {:comments-local {:expanded #{1 2} :open 5}} + result (ptk/update (dcm/collapse-comment-group) state) + local (:comments-local result)] + (t/testing "collapsing clears the expansion" + (t/is (nil? (:expanded local)))) + (t/testing "collapsing leaves an open thread untouched" + (t/is (= 5 (:open local)))))) + +(t/deftest close-thread-clears-everything + (let [state {:comments-local {:open 5 :draft {} :expanded #{1} :options 9}} + result (ptk/update (dcm/close-thread) state) + local (:comments-local result)] + (t/testing "closing a thread clears open, draft, expanded and options" + (t/is (nil? (:open local))) + (t/is (nil? (:draft local))) + (t/is (nil? (:expanded local))) + (t/is (nil? (:options local)))))) From 64b0bff7bd1590c6c56ec786fe16ccdd1e61bf85 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 13:12:41 +0200 Subject: [PATCH 27/63] :books: Update serena creating-prs workflow documentation --- .serena/memories/workflow/creating-prs.md | 35 +++++++++++ tools/detect-target-branch | 74 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100755 tools/detect-target-branch diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4c5e75c212..204fd49c76 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -2,6 +2,20 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). +## Target Branch + +Auto-detect the base branch with `tools/detect-target-branch`: + +```bash +TARGET=$(tools/detect-target-branch) +``` + +This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails. + +## Metadata + +Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project. + ## Title Format PR titles follow commit title conventions: @@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. - Follow `mem:workflow/creating-commits` for commits - Run the focused tests/lints appropriate to touched modules. - Do not force-push during review unless the maintainer workflow explicitly asks for it. +- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`. + +## Creating the PR + +```bash +cat > /tmp/pr-body.md << 'PR_BODY' +<body content here> +PR_BODY + +TARGET=$(tools/detect-target-branch) + +gh pr create \ + --repo penpot/penpot \ + --base "$TARGET" \ + --head <branch> \ + --title "<title>" \ + --project "Main" \ + --body-file /tmp/pr-body.md + +rm -f /tmp/pr-body.md +``` diff --git a/tools/detect-target-branch b/tools/detect-target-branch new file mode 100755 index 0000000000..5d3d584552 --- /dev/null +++ b/tools/detect-target-branch @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# detect-target-branch +# +# Determines the target branch for a PR of the current branch. +# +# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of +# HEAD, falling back to a graph walk if name-rev returns the current branch. +# +# Pure local — no remote, no SSH, no network. Works even when the current +# branch shares its tip commit with the target (not yet diverged). +# +# Usage: +# tools/detect-target-branch # print branch name +# tools/detect-target-branch --verbose # print "branch~N" +# +# Exit status: +# 0 — target found +# 1 — no target found (orphan commit, no other branches) + +verbose=false + +for arg do + case "$arg" in + --verbose|-v) verbose=true ;; + --name-only) ;; + --help|-h) + sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//' + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2 + exit 1 + ;; + esac +done + +current=$(git rev-parse --abbrev-ref HEAD) + +# Use name-rev to find the nearest ref — it has smart tie-breaking +raw=$(git name-rev --name-only HEAD 2>/dev/null) +target=${raw%%~[0-9]*} # strip ~N distance suffix + +# If name-rev returned the current branch itself, walk backwards +if [ "$target" = "$current" ] || [ -z "$target" ]; then + target=$(git rev-list HEAD | while read commit; do + branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \ + | grep -v "^$current$" \ + | head -1 || true) + if [ -n "$branch" ]; then + echo "$branch" + break + fi + done || true) + + if [ -z "$target" ]; then + echo "error: unable to determine target branch" >&2 + exit 1 + fi +fi + +if [ "$verbose" = true ]; then + target_commit=$(git rev-parse "$target" 2>/dev/null || true) + if [ -n "$target_commit" ]; then + distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0") + echo "${target}~${distance}" + else + echo "${target}~?" + fi +else + echo "$target" +fi From ebfcb716ac4e6501075985f25f14c9f24dcccac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Thu, 9 Jul 2026 14:12:41 +0200 Subject: [PATCH 28/63] :bug: Fix permission to add a team to an organization (#10623) --- frontend/src/app/main/ui/dashboard/team.cljs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 0746a49a6b..e134930b4c 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -1538,8 +1538,9 @@ can-change-organization? (mf/with-memo [all-organizations] (> (count all-organizations) 1)) - can-add-to-organization? (mf/with-memo [organizations all-organizations] - (and (pos? (count all-organizations)) + can-add-to-organization? (mf/with-memo [organizations all-organizations permissions] + (and (:is-owner permissions) + (pos? (count all-organizations)) (not (:is-default team)))) show-org-options-menu* From d6c50cc40b0439b4d0ed8f1abf1991e985feabde Mon Sep 17 00:00:00 2001 From: Alonso Torres <alonso.torres@kaleidos.net> Date: Thu, 9 Jul 2026 18:00:26 +0200 Subject: [PATCH 29/63] :bug: Fix problems with plugins api setPluginData (#10632) --- frontend/src/app/plugins/library.cljs | 6 ++ frontend/src/app/plugins/page.cljs | 4 +- plugins/CHANGELOG.md | 2 + .../src/tests/library.test.ts | 41 ++++++++++ .../src/tests/plugin-data.test.ts | 76 +++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index b97343ec20..9fb646f205 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1059,6 +1059,9 @@ :setPluginData (fn [key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) + (not (string? key)) (u/not-valid plugin-id :setPluginData-key key) @@ -1092,6 +1095,9 @@ :setSharedPluginData (fn [namespace key value] (cond + (not= file-id (:current-file-id @st/state)) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) + (not (string? namespace)) (u/not-valid plugin-id :setSharedPluginData-namespace namespace) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 76bc45a832..e668bc8756 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -272,13 +272,13 @@ (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) :getSharedPluginDataKeys - (fn [self namespace] + (fn [namespace] (cond (not (string? namespace)) (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else - (let [page (u/proxy->page self)] + (let [page (u/locate-page file-id id)] (apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)])))))) :openPage diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 00095c38b5..80dee3f1c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -52,6 +52,8 @@ - **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires. - **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`). - **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error). +- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument. +- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited. ## 1.4.2 (2026-01-21) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 5f16958282..7d3a8ccf0a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -34,6 +34,24 @@ describe('Library', () => { expect(Array.isArray(ctx.penpot.library.connected)).toBe(true); }); + // The Library object itself carries plugin data (it extends PluginData), + // stored on the underlying file. Exercised on the local library; the + // connected-library case (writing to a shared library's file/assets) shares + // the same code path but can't be fixtured here (connecting a library hangs). + test('local library stores plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setPluginData('k', 'v'); + expect(lib.getPluginData('k')).toBe('v'); + expect(lib.getPluginDataKeys()).toContain('k'); + }); + + test('local library stores shared plugin data', (ctx) => { + const lib = ctx.penpot.library.local; + lib.setSharedPluginData('ns', 'k', 'v'); + expect(lib.getSharedPluginData('ns', 'k')).toBe('v'); + expect(lib.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('library elements expose a libraryId', (ctx) => { const color = ctx.penpot.library.local.createColor(); expect(typeof color.libraryId).toBe('string'); @@ -136,6 +154,13 @@ describe('Library', () => { expect(color.getPluginData('k')).toBe('v'); expect(color.getPluginDataKeys()).toContain('k'); }); + + test('color shared plugin data round-trips', (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.setSharedPluginData('ns', 'k', 'v'); + expect(color.getSharedPluginData('ns', 'k')).toBe('v'); + expect(color.getSharedPluginDataKeys('ns')).toContain('k'); + }); }); describe('Typographies', () => { @@ -170,6 +195,13 @@ describe('Library', () => { expect(typo.getPluginDataKeys()).toContain('k'); }); + test('typography shared plugin data round-trips', (ctx) => { + const typo = ctx.penpot.library.local.createTypography(); + typo.setSharedPluginData('ns', 'k', 'v'); + expect(typo.getSharedPluginData('ns', 'k')).toBe('v'); + expect(typo.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('typography fontFamily and fontId round-trip', (ctx) => { const typo = ctx.penpot.library.local.createTypography(); expect(typeof typo.fontFamily).toBe('string'); @@ -256,6 +288,15 @@ describe('Library', () => { expect(comp.getPluginDataKeys()).toContain('k'); }); + test('component shared plugin data round-trips', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + const comp = ctx.penpot.library.local.createComponent([rect]); + comp.setSharedPluginData('ns', 'k', 'v'); + expect(comp.getSharedPluginData('ns', 'k')).toBe('v'); + expect(comp.getSharedPluginDataKeys('ns')).toContain('k'); + }); + test('component instance and mainInstance return shapes', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts index 254717e63e..5564b2e3f1 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -26,6 +26,41 @@ describe('Plugin data', () => { if (file) { file.setPluginData('fileKey', 'fileValue'); expect(file.getPluginData('fileKey')).toBe('fileValue'); + expect(file.getPluginDataKeys()).toContain('fileKey'); + } + }); + + test('shared plugin data round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue'); + expect(file.getSharedPluginData('ns', 'fileShared')).toBe( + 'fileSharedValue', + ); + expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared'); + } + }); + + test('plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setPluginData('pageKey', 'pageValue'); + expect(page.getPluginData('pageKey')).toBe('pageValue'); + expect(page.getPluginDataKeys()).toContain('pageKey'); + } + }); + + test('shared plugin data round-trips on a page', (ctx) => { + const page = ctx.penpot.currentPage; + expect(page).not.toBeNull(); + if (page) { + page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue'); + expect(page.getSharedPluginData('ns', 'pageShared')).toBe( + 'pageSharedValue', + ); + expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared'); } }); @@ -45,6 +80,47 @@ describe('Plugin data', () => { expect(rect.getPluginDataKeys()).toContain(''); }); + // Keys are opaque strings: no case normalization is applied. A camelCase key + // and its kebab-case spelling are distinct entries that each round-trip + // independently. Pins that behaviour so a future key-normalization regression + // (reported as camelCase keys "behaving incorrectly") would be caught here. + test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setPluginData('myKey', 'camel'); + rect.setPluginData('my-key', 'kebab'); + expect(rect.getPluginData('myKey')).toBe('camel'); + expect(rect.getPluginData('my-key')).toBe('kebab'); + const keys = rect.getPluginDataKeys(); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + + // Same guarantee at the file-data storage location (a distinct code path from + // shape/page objects), covering a camelCase key on the file itself. + test('a camelCase key round-trips on the file', (ctx) => { + const file = ctx.penpot.currentFile; + expect(file).not.toBeNull(); + if (file) { + file.setPluginData('camelCaseFileKey', 'value'); + expect(file.getPluginData('camelCaseFileKey')).toBe('value'); + expect(file.getPluginDataKeys()).toContain('camelCaseFileKey'); + } + }); + + // camelCase is likewise preserved in the shared namespace and the shared key. + test('camelCase shared namespace and key round-trip', (ctx) => { + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.setSharedPluginData('myNamespace', 'myKey', 'camel'); + rect.setSharedPluginData('myNamespace', 'my-key', 'kebab'); + expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel'); + expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab'); + const keys = rect.getSharedPluginDataKeys('myNamespace'); + expect(keys).toContain('myKey'); + expect(keys).toContain('my-key'); + }); + test('setPluginData with a non-string value throws', (ctx) => { const rect = ctx.penpot.createRectangle(); ctx.board.appendChild(rect); From 3400d6afbb612ae01863fc28fbe9b990b80de4ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:04:05 +0200 Subject: [PATCH 30/63] :bug: Fix too much recursion when clicking shape in comments mode (#10622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix too much recursion when clicking shape in comments mode Remove `deselect-all` from `handle-interrupt` in comments mode. The `select-shape` event emits `:interrupt` which the comments stream watcher routes to `handle-interrupt`. In comments mode, calling `deselect-all` cleared the selection and emitted a competing `rt/nav`, creating a synchronous recursion cycle in the potok store that overflowed the JS call stack. Fixes #10620 AI-assisted-by: opencode * :bug: Add unit tests for comments handle-interrupt Make `handle-interrupt` public (defn- → defn) and add 4 tests covering each branch: draft thread, open thread, comments mode, and noop. AI-assisted-by: opencode --- .../src/app/main/data/workspace/comments.cljs | 6 +- .../data/workspace_comments_test.cljs | 84 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_comments_test.cljs diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 968cc8194e..fc9911feca 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -24,7 +24,6 @@ [app.main.data.workspace.drawing :as dwd] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.layout :as dwlo] - [app.main.data.workspace.selection :as dws] [app.main.data.workspace.zoom :as dwz] [app.main.repo :as rp] [app.main.router :as rt] @@ -71,7 +70,7 @@ (rx/take-until stopper-s)))))) -(defn- handle-interrupt +(defn handle-interrupt [] (ptk/reify ::handle-interrupt ptk/WatchEvent @@ -85,8 +84,7 @@ ;; tool is active. When comments are merely visible during design, ;; `select-shape` emits `:interrupt` and this would otherwise wipe ;; the freshly selected shape, breaking click selection. - comments-mode? (rx/of (dwe/clear-edition-mode) - (dws/deselect-all true)) + comments-mode? (rx/of (dwe/clear-edition-mode)) :else (rx/empty)))))) ;; Event responsible of the what should be executed when user clicked diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs new file mode 100644 index 0000000000..8e653875c1 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -0,0 +1,84 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.workspace-comments-test + (:require + [app.main.data.comments :as dcmt] + [app.main.data.workspace.comments :as dwcm] + [app.main.data.workspace.edition :as dwe] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +(t/deftest test-handle-interrupt-draft + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:draft {:id "draft-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-open + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:comments-local {:open {:id "thread-id"}}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dcmt/close-comment-thread (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-comments-mode + (t/async + done + (let [event (dwcm/handle-interrupt) + state {:workspace-drawing {:tool :comments}} + result (ptk/watch event state (rx/empty))] + (->> result + (rx/subs! + (fn [evt] + (t/is (= ::dwe/clear-edition-mode (ptk/type evt)))) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (done))))))) + +(t/deftest test-handle-interrupt-noop + (t/async + done + (let [event (dwcm/handle-interrupt) + state {} + result (ptk/watch event state (rx/empty)) + emitted? (atom false)] + (->> result + (rx/subs! + (fn [_] + (reset! emitted? true)) + (fn [err] + (done) + (js/console.error err) + (t/do-report {:type :error :message "Stream error" :actual err})) + (fn [_] + (t/is (false? @emitted?) "should not emit any events") + (done))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 0e92dd804a..f21b124277 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] @@ -85,6 +86,7 @@ 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test From 23a9b4bdd9a80deeb5d6256d035378c818173be5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 18:53:49 +0200 Subject: [PATCH 31/63] :bug: Fix backend util shell tests --- backend/src/app/util/shell.clj | 4 +++- backend/test/backend_tests/shell_test.clj | 27 ++++++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 0d7349d632..61dd08e682 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -65,7 +65,6 @@ (assert (every? string? cmd) "the command should be a vector of strings") (let [executor (::wrk/executor system) - _ (assert (some? executor) "executor is required, check ::wrk/executor") full-cmd (cond->> cmd (seq prlimit) (into (prlimit-cmd prlimit))) @@ -74,6 +73,9 @@ _ (reduce-kv set-env env-map env) process (.start builder)] + (when-not executor + (throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor"))) + (if in (px/run! executor (fn [] diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c77c585568..c9d1932c44 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -8,12 +8,17 @@ (:require [app.common.exceptions :as ex] [app.util.shell :as shell] + [app.worker :as-alias wrk] [clojure.string :as str] - [clojure.test :as t])) + [clojure.test :as t] + [promesa.exec :as px])) + +(def ^:private system + {::wrk/executor (px/cached-executor)}) (t/deftest exec-normal-completes (t/testing "normal process completes within timeout" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -21,7 +26,7 @@ (t/deftest exec-captures-stderr (t/testing "stderr is captured separately" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo out; echo err >&2"] :timeout 10)] (t/is (= 0 (:exit result))) @@ -30,14 +35,14 @@ (t/deftest exec-non-zero-exit (t/testing "non-zero exit code is captured" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "exit 42"] :timeout 10)] (t/is (= 42 (:exit result)))))) (t/deftest exec-with-env (t/testing "environment variables are passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "echo $MY_VAR"] :env {"MY_VAR" "test-value"} :timeout 10)] @@ -46,7 +51,7 @@ (t/deftest exec-with-input (t/testing "stdin input is passed to the process" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["cat"] :in "hello from stdin" :timeout 10)] @@ -57,7 +62,7 @@ (t/testing "process that exceeds timeout is killed and raises exception" (let [start (System/currentTimeMillis)] (try - (shell/exec! {} + (shell/exec! system :cmd ["sleep" "60"] :timeout 1) (t/is false "should have thrown") @@ -72,14 +77,14 @@ (t/deftest exec-no-timeout-waits (t/testing "without timeout, process runs to completion" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["sleep" "0.1"] :timeout nil)] (t/is (= 0 (:exit result)))))) (t/deftest exec-prlimit-normal (t/testing "normal process completes within prlimit" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["echo" "hello"] :prlimit {:mem 256 :cpu 10} :timeout 10)] @@ -88,7 +93,7 @@ (t/deftest exec-prlimit-cpu (t/testing "process exceeding CPU limit is killed" - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["bash" "-c" "while true; do :; done"] :prlimit {:cpu 2} :timeout 10)] @@ -98,7 +103,7 @@ (t/testing "process exceeding memory limit is killed" ;; Use python3 to allocate more memory than the limit allows. ;; This test requires python3 to be available in the environment. - (let [result (shell/exec! {} + (let [result (shell/exec! system :cmd ["python3" "-c" "import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"] :prlimit {:mem 256} From ad4dae5f289390bb4b1457f5bfbf7db57a4324a4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 9 Jul 2026 19:40:01 +0200 Subject: [PATCH 32/63] :books: Add testing principles to serena doc --- .serena/memories/backend/core.md | 1 + .serena/memories/common/core.md | 1 + .serena/memories/common/testing-principles.md | 47 +++++++++++++++++++ .serena/memories/frontend/core.md | 1 + 4 files changed, 50 insertions(+) create mode 100644 .serena/memories/common/testing-principles.md diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index d815f69070..f416b78cf8 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -106,3 +106,4 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. +* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index 474fb8d87e..23fbe20198 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -49,6 +49,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. - Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. +- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. ## Areas without focused memories diff --git a/.serena/memories/common/testing-principles.md b/.serena/memories/common/testing-principles.md new file mode 100644 index 0000000000..860852393b --- /dev/null +++ b/.serena/memories/common/testing-principles.md @@ -0,0 +1,47 @@ +# Testing Principles (Cross-Cutting) + +Shared testing principles for all modules (backend, frontend, common, render-wasm, plugins). Module-specific commands and helpers are in each module's own testing memory. + +## Core Principles + +- **Test State, Not Interactions** — assert on outcomes, not method calls; survives refactoring +- **DAMP over DRY** — tests are specifications; duplication OK if each test is self-contained and readable +- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; mock only at boundaries (network, filesystem, email) +- **Arrange-Act-Assert** — every test: setup / action / verify +- **One Assertion Per Concept** — each test verifies one behavior; split compound assertions +- **Descriptive Test Names** — names read like specifications + +## Anti-Patterns + +- Testing implementation details → breaks on refactor +- Flaky tests (timing, order-dependent) → erode trust +- Mocking everything → tests pass, production breaks +- No test isolation → pass individually, fail together +- Testing framework code → waste of time +- Snapshot abuse → nobody reviews, break on any change + +## Verification Checklist + +After completing any implementation: +- Every new behavior has a corresponding test +- All tests pass for touched modules +- Bug fixes include a reproduction test that failed before the fix +- Test names describe the behavior being verified +- No tests were skipped or disabled + +## Red Flags + +- Writing code without corresponding tests +- Tests that pass on the first run (may not be testing what you think) +- Bug fixes without reproduction tests +- Tests that test framework behavior instead of app behavior +- Skipping tests to make the suite pass + +## Rationalizations + +- "I'll write tests after" → you won't; they'll test implementation not behavior +- "Too simple to test" → simple gets complicated; test documents expected behavior +- "Tests slow me down" → they speed up every future change +- "I tested manually" → manual testing doesn't persist +- "Code is self-explanatory" → tests ARE the specification +- "Just a prototype" → prototypes become production; test debt accumulates diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 7edf80bbe1..b890de6770 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -52,6 +52,7 @@ Diagnostics and validation: - Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`. - Runtime crash recovery: `mem:frontend/handling-crashes`. - Tests and live verification: `mem:frontend/testing`. +- Cross-cutting testing principles and anti-patterns: `mem:common/testing-principles`. - Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`. ## Areas without focused memories From 508569437a613c61e61b1317f00ca5ba2d7795b0 Mon Sep 17 00:00:00 2001 From: Whos Deez <134500894+Whosdeez@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:01:43 +0000 Subject: [PATCH 33/63] :books: Fix flex word repetition and correct modifier key in docs (#10610) * :paperclip: Update version on mcp package.json * Update flexible-layouts.njk : fixed repetition and changed shortcut Fixed 'Flex' word repetition; made the Toggle Flex instruction reflect the correct keyboard modifier key. Signed-off-by: Whos Deez <134500894+Whosdeez@users.noreply.github.com> * Update package version to 2.17.0 Signed-off-by: Andrey Antukh <niwi@niwi.nz> --------- Signed-off-by: Whos Deez <134500894+Whosdeez@users.noreply.github.com> Signed-off-by: Andrey Antukh <niwi@niwi.nz> Co-authored-by: Andrey Antukh <niwi@niwi.nz> --- docs/user-guide/designing/flexible-layouts.njk | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/designing/flexible-layouts.njk b/docs/user-guide/designing/flexible-layouts.njk index 832344841e..15faa6bd39 100644 --- a/docs/user-guide/designing/flexible-layouts.njk +++ b/docs/user-guide/designing/flexible-layouts.njk @@ -24,11 +24,11 @@ desc: Master responsive web design with Penpot's flexible and grid layouts! Lear <h3 id="layouts-flex-add">Add Flex Layout</h3> -<p>You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout Flex is added the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:</p> +<p>You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout is added, the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:</p> <ul> <li>From the Design panel at the right sidebar.</li> <li>From the option at the selection menu (right click button).</li> - <li>Pressing <kbd>Ctrl/⌘</kbd> + <kbd>A</kbd>.</li> + <li>Pressing <kbd>Shift/⇧</kbd> + <kbd>A</kbd>.</li> </ul> <figure><img src="/img/flexible-layouts/layouts-add.webp" alt="Adding Layouts" /></figure> From 3ee5b500077d20ee16cb07f3a6adc7e62278d57c Mon Sep 17 00:00:00 2001 From: Alonso Torres <alonso.torres@kaleidos.net> Date: Fri, 10 Jul 2026 09:13:20 +0200 Subject: [PATCH 34/63] :bug: Fix problems with padding multiple values in plugins and UI (#10602) * :bug: Fix problem with padding types in plugins * :bug: Fix problem with multiple selection paddings --- common/src/app/common/types/shape/layout.cljc | 16 +++ .../options/menus/layout_container.cljs | 4 + .../sidebar/options/shapes/multiple.cljs | 61 ++++++++++- frontend/src/app/plugins/flex.cljs | 102 +++++++++++++----- frontend/src/app/plugins/grid.cljs | 51 ++++++--- frontend/test/frontend_tests/runner.cljs | 2 + .../ui/layout_container_multiple_test.cljs | 46 ++++++++ plugins/CHANGELOG.md | 12 ++- .../src/generated/api-surface.json | 26 +++++ .../src/tests/layout.test.ts | 93 ++++++++++++++++ plugins/libs/plugin-types/index.d.ts | 15 +++ 11 files changed, 388 insertions(+), 40 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 595f99caa0..8b7831d5ab 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -347,6 +347,22 @@ (+ pad-top pad-top) (+ pad-top pad-bottom)))) +(defn padding-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [p1 p2 p3 p4]}] + (if (and (mth/close? (d/nilv p1 0) (d/nilv p3 0)) + (mth/close? (d/nilv p2 0) (d/nilv p4 0))) + :simple + :multiple)) + +(defn margin-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [m1 m2 m3 m4]}] + (if (and (mth/close? (d/nilv m1 0) (d/nilv m3 0)) + (mth/close? (d/nilv m2 0) (d/nilv m4 0))) + :simple + :multiple)) + (defn child-min-width [child] (if (and (fill-width? child) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 654eccaf4c..70050dd38e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1213,6 +1213,10 @@ (and (= type :simple) (or (= prop :p2) (= prop #{:p2 :p4}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p2 val :p4 val}})) + (and (= type :multiple) (some? prop)) + (st/emit! (dwsl/update-layout ids {:layout-padding-type :multiple + :layout-padding {prop val}})) + (some? prop) (st/emit! (dwsl/update-layout ids {:layout-padding {prop val}})))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 565c368611..f20dc82caa 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -12,6 +12,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] + [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.path :as path] [app.common.types.shape.attrs :refer [editable-attrs]] @@ -208,6 +209,59 @@ [v] (when v (select-keys v blur-keys))) +(def layout-padding-attrs [:p1 :p2 :p3 :p4]) + +(defn- normalize-layout-padding + [value] + (if (= value :multiple) + (zipmap layout-padding-attrs (repeat :multiple)) + value)) + +(defn- merge-layout-padding + [values shape-values] + (let [current (normalize-layout-padding (get values :layout-padding ::unset)) + next (normalize-layout-padding (get shape-values :layout-padding ::unset))] + (cond + (= current ::unset) next + (= next ::unset) current + + (and (map? current) (map? next)) + (attrs/get-attrs-multi [current next] layout-padding-attrs) + + (= current next) + current + + :else + :multiple))) + +(defn- same-padding-value? + [v1 v2] + (if (and (number? v1) (number? v2)) + (mth/close? v1 v2) + (= v1 v2))) + +(defn- simple-layout-padding? + [{:keys [p1 p2 p3 p4]}] + (and (same-padding-value? p1 p3) + (same-padding-value? p2 p4))) + +(defn- promote-simple-layout-padding-type + [{:keys [layout-padding layout-padding-type] :as values}] + (cond-> values + (and (= layout-padding-type :simple) + (map? layout-padding) + (not (simple-layout-padding? layout-padding))) + (assoc :layout-padding-type :multiple))) + +(defn- merge-layout-container-attrs + [values shape-values attrs] + (let [merged-values (attrs/get-attrs-multi [values shape-values] attrs) + merged-values (cond-> merged-values + (or (contains? values :layout-padding) + (contains? shape-values :layout-padding)) + (assoc :layout-padding (merge-layout-padding values shape-values)))] + (promote-simple-layout-padding-type merged-values))) + (defn get-attrs* "Given a group of attributes that we want to extract and the shapes to extract them from returns a list of tuples [id, values] with the extracted properties for the shapes that @@ -217,9 +271,10 @@ merge-attrs (fn [v1 v2] (cond - (= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs shadow-eq shadow-sel) - (= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs blur-eq blur-sel) - :else (attrs/get-attrs-multi [v1 v2] attrs))) + (= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs shadow-eq shadow-sel) + (= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs blur-eq blur-sel) + (= attr-group :layout-container) (merge-layout-container-attrs v1 v2 attrs) + :else (attrs/get-attrs-multi [v1 v2] attrs))) merge-attr (fn [acc applied-tokens t-attr] diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index ff1ec594a7..0967edcbec 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -20,6 +20,20 @@ ;; Define in `app.plugins.shape` we do this way to prevent circular dependency (def shape-proxy? nil) +(defn- update-padding + "Patch the layout padding and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [padding (merge (-> this u/proxy->shape :layout-padding) patch)] + (st/emit! (dwsl/update-layout #{id} {:layout-padding patch + :layout-padding-type (ctl/padding-type-for padding)})))) + +(defn- update-margin + "Patch the item margin and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [margin (merge (-> this u/proxy->shape :layout-item-margin) patch)] + (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin patch + :layout-item-margin-type (ctl/margin-type-for margin)})))) + (defn flex-layout-proxy? [p] (obj/type-of? p "FlexLayoutProxy")) @@ -185,7 +199,7 @@ {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalPadding value) @@ -197,13 +211,13 @@ (u/not-valid plugin-id :verticalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} + (update-padding this id {:p1 value :p3 value})))} :horizontalPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalPadding value) @@ -215,13 +229,13 @@ (u/not-valid plugin-id :horizontalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} + (update-padding this id {:p2 value :p4 value})))} :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topPadding value) @@ -233,13 +247,13 @@ (u/not-valid plugin-id :topPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} + (update-padding this id {:p1 value})))} :rightPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightPadding value) @@ -251,13 +265,13 @@ (u/not-valid plugin-id :rightPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} + (update-padding this id {:p2 value})))} :bottomPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomPadding value) @@ -269,13 +283,13 @@ (u/not-valid plugin-id :bottomPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} + (update-padding this id {:p3 value})))} :leftPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftPadding value) @@ -287,7 +301,27 @@ (u/not-valid plugin-id :leftPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} + (update-padding this id {:p4 value})))} + + ;; `:simple` mirrors vertical/horizontal padding; `:multiple` honours each side. + :paddingType + {:this true + :get #(-> % u/proxy->shape :layout-padding-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/padding-type value)) + (u/not-valid plugin-id :paddingType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :paddingType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :paddingType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-padding-type value})))))} :remove (fn [] @@ -469,7 +503,7 @@ {:this true :get #(-> % u/proxy->shape :layout-item-margin :m1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalMargin value) @@ -481,13 +515,13 @@ (u/not-valid plugin-id :verticalMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} + (update-margin this id {:m1 value :m3 value})))} :horizontalMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalMargin value) @@ -499,13 +533,13 @@ (u/not-valid plugin-id :horizontalMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} + (update-margin this id {:m2 value :m4 value})))} :topMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topMargin value) @@ -517,13 +551,13 @@ (u/not-valid plugin-id :topMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} + (update-margin this id {:m1 value})))} :rightMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightMargin value) @@ -535,13 +569,13 @@ (u/not-valid plugin-id :rightMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} + (update-margin this id {:m2 value})))} :bottomMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomMargin value) @@ -553,13 +587,13 @@ (u/not-valid plugin-id :bottomMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} + (update-margin this id {:m3 value})))} :leftMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftMargin value) @@ -571,7 +605,27 @@ (u/not-valid plugin-id :leftMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} + (update-margin this id {:m4 value})))} + + ;; `:simple` mirrors vertical/horizontal margin; `:multiple` honours each side. + :marginType + {:this true + :get #(-> % u/proxy->shape :layout-item-margin-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-margin-types value)) + (u/not-valid plugin-id :marginType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :marginType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :marginType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin-type value})))))} :maxWidth {:this true diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index e0122062db..96deccb857 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -21,6 +21,13 @@ ;; Define in `app.plugins.shape` we do this way to prevent circular dependency (def shape-proxy? nil) +(defn- update-padding + "Patch the layout padding and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [padding (merge (-> this u/proxy->shape :layout-padding) patch)] + (st/emit! (dwsl/update-layout #{id} {:layout-padding patch + :layout-padding-type (ctl/padding-type-for padding)})))) + (defn grid-layout-proxy? [p] (obj/type-of? p "GridLayoutProxy")) @@ -217,7 +224,7 @@ {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalPadding value) @@ -229,13 +236,13 @@ (u/not-valid plugin-id :verticalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} + (update-padding this id {:p1 value :p3 value})))} :horizontalPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalPadding value) @@ -247,13 +254,13 @@ (u/not-valid plugin-id :horizontalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} + (update-padding this id {:p2 value :p4 value})))} :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topPadding value) @@ -265,13 +272,13 @@ (u/not-valid plugin-id :topPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} + (update-padding this id {:p1 value})))} :rightPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightPadding value) @@ -283,13 +290,13 @@ (u/not-valid plugin-id :rightPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} + (update-padding this id {:p2 value})))} :bottomPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomPadding value) @@ -301,13 +308,13 @@ (u/not-valid plugin-id :bottomPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} + (update-padding this id {:p3 value})))} :leftPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftPadding value) @@ -319,7 +326,27 @@ (u/not-valid plugin-id :leftPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} + (update-padding this id {:p4 value})))} + + ;; `:simple` mirrors vertical/horizontal padding; `:multiple` honours each side. + :paddingType + {:this true + :get #(-> % u/proxy->shape :layout-padding-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/padding-type value)) + (u/not-valid plugin-id :paddingType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :paddingType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :paddingType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-padding-type value})))))} :addRow (fn [type value] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6f92bff904..b6f1a32a80 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -56,6 +56,7 @@ [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.ds-controls-numeric-input-test] + [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] @@ -128,6 +129,7 @@ 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.ds-controls-numeric-input-test + 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test diff --git a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs new file mode 100644 index 0000000000..19c4278057 --- /dev/null +++ b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.ui.layout-container-multiple-test + (:require + [app.main.ui.workspace.sidebar.options.shapes.multiple :as multiple] + [cljs.test :as t :include-macros true])) + +(defn- flex-frame + [id padding-type padding] + {:id id + :type :frame + :layout :flex + :layout-flex-dir :row + :layout-gap-type :multiple + :layout-gap {:row-gap 0 :column-gap 0} + :layout-align-items :start + :layout-justify-content :start + :layout-align-content :stretch + :layout-wrap-type :nowrap + :layout-padding-type padding-type + :layout-padding padding}) + +(defn- layout-container-values + [shapes] + (let [[_ids values _tokens] (multiple/get-attrs* shapes {} :layout-container)] + values)) + +(t/deftest multiple-selection-padding-keeps-matching-sides + (let [values (layout-container-values + [(flex-frame :frame-1 :simple {:p1 10 :p2 20 :p3 10 :p4 20}) + (flex-frame :frame-2 :simple {:p1 10 :p2 20 :p3 30 :p4 40})])] + (t/is (= {:p1 10 :p2 20 :p3 :multiple :p4 :multiple} + (:layout-padding values))) + (t/is (= :multiple (:layout-padding-type values))))) + +(t/deftest multiple-selection-padding-type-does-not-demote + (let [values (layout-container-values + [(flex-frame :frame-1 :multiple {:p1 10 :p2 20 :p3 10 :p4 20}) + (flex-frame :frame-2 :multiple {:p1 10 :p2 20 :p3 10 :p4 20})])] + (t/is (= {:p1 10 :p2 20 :p3 10 :p4 20} + (:layout-padding values))) + (t/is (= :multiple (:layout-padding-type values))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 80dee3f1c9..323e8319ab 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,4 +1,14 @@ -## 1.5.0 (Unreleased) +## 1.6.0 (Unreleased) + +### 🚀 Features + +- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. + +### 🩹 Fixes + +- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. + +## 1.5.0 (2026-07-08) ### 💣 Breaking changes & Deprecations diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 69dc9d5546..f61aac311a 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -66,6 +66,7 @@ "justifyContent", "justifyItems", "leftPadding", + "paddingType", "remove", "rightPadding", "rowGap", @@ -254,6 +255,7 @@ "horizontalMargin", "horizontalSizing", "leftMargin", + "marginType", "maxHeight", "maxWidth", "minHeight", @@ -2032,6 +2034,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -3242,6 +3250,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -3590,6 +3604,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -4868,6 +4888,12 @@ "type": null, "array": false }, + "marginType": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, "maxWidth": { "decl": "LayoutChildProperties", "kind": "getset", diff --git a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts index b42cb65fab..867132714e 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts @@ -82,6 +82,34 @@ describe('Layout', () => { expect(flex.leftPadding).toBeCloseTo(4.5, 2); }); + // paddingType is "simple" (sides mirrored) or "multiple" (each side independent). + test('paddingType round-trips', (ctx) => { + const flex = board(ctx).addFlexLayout(); + expect(flex.paddingType).toBe('simple'); + flex.paddingType = 'multiple'; + expect(flex.paddingType).toBe('multiple'); + flex.paddingType = 'simple'; + expect(flex.paddingType).toBe('simple'); + }); + + // Issue #10278: a single asymmetric side must switch paddingType to "multiple" so it paints. + test('setting an individual padding side switches paddingType to multiple', (ctx) => { + const flex = board(ctx).addFlexLayout(); + expect(flex.paddingType).toBe('simple'); + flex.leftPadding = 40; + expect(flex.leftPadding).toBeCloseTo(40, 0); + expect(flex.paddingType).toBe('multiple'); + }); + + // Re-derived from the sides: once symmetric again, the type collapses to "simple". + test('padding collapsing back to symmetric restores simple type', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.leftPadding = 40; + expect(flex.paddingType).toBe('multiple'); + flex.leftPadding = 0; + expect(flex.paddingType).toBe('simple'); + }); + test('sizing round-trips', (ctx) => { const flex = board(ctx).addFlexLayout(); flex.horizontalSizing = 'fix'; @@ -210,6 +238,22 @@ describe('Layout', () => { expect(grid.leftPadding).toBeCloseTo(4.5, 2); }); + // paddingType behaves the same as on flex layouts (see issue #10278). + test('paddingType round-trips', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(grid.paddingType).toBe('simple'); + grid.paddingType = 'multiple'; + expect(grid.paddingType).toBe('multiple'); + }); + + test('setting an individual padding side switches paddingType to multiple', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(grid.paddingType).toBe('simple'); + grid.leftPadding = 40; + expect(grid.leftPadding).toBeCloseTo(40, 0); + expect(grid.paddingType).toBe('multiple'); + }); + // Index boundaries — invalid indices must be rejected. test('addRowAtIndex with a negative index throws', (ctx) => { const grid = board(ctx).addGridLayout(); @@ -389,6 +433,55 @@ describe('Layout', () => { } }); + // marginType is the child-margin counterpart of a layout's paddingType. + test('marginType round-trips', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + expect(child.marginType).toBe('simple'); + child.marginType = 'multiple'; + expect(child.marginType).toBe('multiple'); + child.marginType = 'simple'; + expect(child.marginType).toBe('simple'); + } + }); + + // Issue #10278 (margins): a single asymmetric side must switch marginType to "multiple". + test('setting an individual margin side switches marginType to multiple', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + expect(child.marginType).toBe('simple'); + child.leftMargin = 12; + expect(child.leftMargin).toBeCloseTo(12, 0); + expect(child.marginType).toBe('multiple'); + } + }); + + // Symmetric margins collapse the child back to "simple", mirroring padding. + test('margin collapsing back to symmetric restores simple type', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + child.leftMargin = 12; + expect(child.marginType).toBe('multiple'); + child.leftMargin = 0; + expect(child.marginType).toBe('simple'); + } + }); + // Community report (forum #10700, issue #12): layoutChild was said to be // null for children appended to a flex board that is re-found (fresh // proxy) instead of using the creation-time reference. Did not reproduce; diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 6150e6b136..1dbe005a3e 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -747,6 +747,13 @@ export interface CommonLayout { * The `leftPadding` property specifies the padding at the left of the container. */ leftPadding: number; + /** + * The `paddingType` property specifies how the four padding values are applied. + * It can be one of the following values: + * - 'simple': the vertical and horizontal paddings are mirrored across both sides. + * - 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. + */ + paddingType: 'simple' | 'multiple'; /** * The `horizontalSizing` property specifies the horizontal sizing behavior of the container. @@ -2576,6 +2583,14 @@ export interface LayoutChildProperties { */ leftMargin: number; + /** + * The `marginType` property specifies how the four margin values are applied. + * It can be one of the following values: + * - 'simple': the vertical and horizontal margins are mirrored across both sides. + * - 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. + */ + marginType: 'simple' | 'multiple'; + /** * Defines the maximum width of the child element. * If set to null, there is no maximum width constraint. From e73aa9e981dd2857459ea0b2495237cf36e064e1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Fri, 10 Jul 2026 10:41:49 +0200 Subject: [PATCH 35/63] :sparkles: Add PENPOT_INTERNAL_URI to exporter for separate internal and public URI handling (#10630) Add PENPOT_INTERNAL_URI environment variable to the exporter. This allows separating the URI used for internal communication (headless browser to frontend) from the public URI used for resource references in exported SVGs. Previously, PENPOT_PUBLIC_URI served both purposes, which caused exported SVGs to contain broken font URLs when the internal Docker address was used. Changes: - Add :internal-uri to exporter config schema with fallback to :public-uri - Add get-internal-uri helper function - Use internal-uri for browser navigation in SVG/PDF/bitmap renderers - Post-process SVG output to replace internal URI with public URI - Use internal-uri for backend API calls in resource handler - Log both URIs on startup - Update docker-compose.yaml to use both variables - Document the new variable in configuration.md Closes #10627 AI-assisted-by: mimo-v2.5-pro --- docker/images/docker-compose.yaml | 4 ++-- docs/technical-guide/configuration.md | 15 +++++++++++++++ exporter/src/app/config.cljs | 8 ++++++++ exporter/src/app/core.cljs | 1 + exporter/src/app/handlers/resources.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/pdf.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 18 ++++++++++++++++-- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 94193a977c..59b326c76d 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -197,10 +197,10 @@ services: - penpot environment: - << : [*penpot-secret-key] + << : [*penpot-secret-key, *penpot-public-uri] # Don't touch it; this uses an internal docker network to # communicate with the frontend. - PENPOT_PUBLIC_URI: http://penpot-frontend:8080 + PENPOT_INTERNAL_URI: http://penpot-frontend:8080 ## Valkey (or previously Redis) is used for the websockets notifications. PENPOT_REDIS_URI: redis://penpot-valkey/0 diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index eec62f63e4..49d5a23e8d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -651,6 +651,21 @@ PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061 These variables are used for generate correct nginx.conf file on container startup. +### Exporter + +The exporter uses this variable: + +```bash +# Exporter +PENPOT_INTERNAL_URI: http://penpot-frontend:8080 +``` + +- `PENPOT_INTERNAL_URI`: The URI used by the exporter's headless browser to + communicate with the frontend (internal Docker network). Defaults to + `PENPOT_PUBLIC_URI` if not set. The default value + `http://penpot-frontend:8080` used in the docker-compose is a good default and + it is recommended to keep it unchanged. + ## Other flags There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index ff32391f82..8010032680 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -22,6 +22,7 @@ (def ^:private defaults {:public-uri "http://localhost:3449" + :internal-uri nil :tenant "default" :host "localhost" :http-server-port 6061 @@ -33,6 +34,7 @@ [:map {:title "config"} [:secret-key :string] [:public-uri {:optional true} ::sm/uri] + [:internal-uri {:optional true} ::sm/uri] [:exporter-shared-key {:optional true} :string] [:host {:optional true} :string] [:tenant {:optional true} :string] @@ -100,6 +102,12 @@ ([key default] (c/get config key default))) +(defn get-internal-uri + "Returns internal-uri if set, otherwise falls back to public-uri." + [] + (or (c/get config :internal-uri) + (c/get config :public-uri))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index 91d4ee702e..5ce68ebf98 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -21,6 +21,7 @@ [& _] (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) + :internal-uri (str (cf/get-internal-uri)) :version (:full cf/version)) (p/do! (bwr/init) diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 9b2f37d6cc..4c4bb7225c 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -79,7 +79,7 @@ :method "POST" :body fdata :dispatcher agent} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "api/management/methods/upload-tempfile") (str))] diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index 3579a4839e..c2720eb025 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -62,7 +62,7 @@ :skip-children skip-children :wasm (when is-wasm "true") :scale scale} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index b953730be4..ba4118c1e8 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -77,7 +77,7 @@ (on-object (assoc object :path path)) (p/recur (rest objects))))))] - (let [base-uri (-> (cf/get :public-uri) + (let [base-uri (-> (cf/get-internal-uri) (u/ensure-path-slash))] (bw/exec! (prepare-options base-uri) (partial render base-uri))))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 7a47e46fe0..c9fee2f764 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -108,6 +108,18 @@ {:width width :height height})) +(defn- replace-internal-uris + "Replaces internal-uri references with public-uri in SVG output. + This ensures that font URLs and other resource references in the + exported SVG use the public-facing URI accessible to end users." + [svg-content] + (let [internal-uri (str (cf/get-internal-uri)) + public-uri (str (cf/get :public-uri))] + (if (and (not= internal-uri public-uri) + (str/includes? svg-content internal-uri)) + (str/replace svg-content internal-uri public-uri) + svg-content))) + (defn render [{:keys [page-id file-id share-id objects token scale type]} on-object] (letfn [(convert-to-ppm [pngpath] @@ -320,7 +332,9 @@ result (if (contains? cf/flags :exporter-svgo) (svgo/optimize result svgo/defaultOptions) - result)] + result) + + result (replace-internal-uris result)] ;; (println "------- ORIGIN:") ;; (cljs.pprint/pprint (xml->clj xmldata)) @@ -349,7 +363,7 @@ :render-embed true :object-id (mapv :id objects) :route "objects"} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] From 3708cf31d40581fb14c9b928a21b8b8f8c7be0ae Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Fri, 10 Jul 2026 11:05:09 +0200 Subject: [PATCH 36/63] :bug: Fix stroke cap selects (#10631) --- .../src/app/main/ui/components/select.scss | 2 +- .../sidebar/options/rows/stroke_row.cljs | 50 +++++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index 6baab75ac1..438e0873fa 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -150,7 +150,7 @@ .separator { margin: 0; block-size: $sz-12; - border-block-start: $b-1 solid var(--color-background-primary); + border-block-start: $b-1 solid var(--color-background-quaternary); } &[data-direction="up"] { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index b39d62c1cb..8e21f18232 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -171,12 +171,20 @@ on-caps-start-change (mf/use-fn (mf/deps index on-stroke-cap-start-change) - #(on-stroke-cap-start-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-start-change index cap)))) on-caps-end-change (mf/use-fn (mf/deps index on-stroke-cap-end-change) - #(on-stroke-cap-end-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-end-change index cap)))) on-detach-token-color (mf/use-fn @@ -191,16 +199,16 @@ (on-detach-token token #{:stroke-width}))) stroke-caps-options - [{:value nil :label (tr "workspace.options.stroke-cap.none")} - :separator - {:value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon :stroke-arrow} - {:value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon :stroke-triangle} - {:value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon :stroke-rectangle} - {:value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon :stroke-circle} - {:value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon :stroke-diamond} - :separator - {:value :round :label (tr "workspace.options.stroke-cap.round") :icon :stroke-rounded} - {:value :square :label (tr "workspace.options.stroke-cap.square") :icon :stroke-squared}] + [{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")} + {:label "" :type :separator :id "separator"} + {:id "line-arrow" :value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon i/stroke-arrow} + {:id "triangle-arrow" :value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon i/stroke-triangle} + {:id "square-marker" :value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon i/stroke-rectangle} + {:id "circle-marker" :value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon i/stroke-circle} + {:id "diamond-marker" :value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon i/stroke-diamond} + {:label "" :type :separator :id "separator"} + {:id "round" :value :round :label (tr "workspace.options.stroke-cap.round") :icon i/stroke-rounded} + {:id "square" :value :square :label (tr "workspace.options.stroke-cap.square") :icon i/stroke-squared}] on-cap-switch (mf/use-fn @@ -340,16 +348,18 @@ ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} - [:& select {:default-value (:stroke-cap-start stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-start-change}] + [:> select* {:default-selected (or (d/name (:stroke-cap-start stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-start" + :disabled hidden? + :on-change on-caps-start-change}] [:> icon-button* {:variant "secondary" :aria-label (tr "labels.switch") :disabled hidden? :on-click on-cap-switch :icon i/switch}] - [:& select {:default-value (:stroke-cap-end stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-end-change}]])])) + [:> select* {:default-selected (or (d/name (:stroke-cap-end stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-end" + :disabled hidden? + :on-change on-caps-end-change}]])])) From 3469867cf5a6ddce3fc63106527053067763a42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= <belen.albeza@kaleidos.net> Date: Fri, 10 Jul 2026 11:08:10 +0200 Subject: [PATCH 37/63] :bug: Fix text editor not auto-selecting all text on mount (#10573) --- .../ui/specs/text-editor-v3.spec.js | 24 +++++++++++++++++++ .../ui/workspace/shapes/text/v3_editor.cljs | 5 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 4a5964ea01..53b439ab19 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -129,3 +129,27 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({ await workspace.waitForSelectedShapeName("hello world"); }); +test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the existing text shape and enter edit mode via Enter + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + + // Copying while editing exports only the selected text as raw text. + // Since we just entered the editor, the whole text should be selected. + await workspace.copy("keyboard"); + + // Assert the text was copied correctly + const copiedText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(copiedText).toBe("Lorem ipsum"); +}); + diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 458153f3b5..55310b0b87 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -350,7 +350,10 @@ (mf/deps contenteditable-ref) (fn [] (when-let [node (mf/ref-val contenteditable-ref)] - (.focus node)) + ;; Focus and select all text on mount (this will trigger on-focus) + (.focus node) + (text-editor/text-editor-select-all) + (wasm.api/request-render "text-editor-select-all-on-mount")) ;; On unmount, finalize the editor content and then dispose the WASM editor. ;; We finalize on unmount instead of relying on the browser blur event, because ;; it was not being reliable (timing issues, Firefox issues…) From 89551b24159672a6803dd5ab7e04171df7e6c61b Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:21:21 -0400 Subject: [PATCH 38/63] :bug: Fix dashboard user menu submenu not closing on hover out (#10639) The dashboard profile menu submenus (Help & Learning, Community & Contributions, About Penpot) opened on pointer enter but nothing closed them when the pointer left the option, leaving a stale submenu visible until the whole menu closed. Close the open submenu when the pointer leaves an expandable option or its submenu, with a 200ms grace period (same approach as the workspace context menu) so the submenu survives the pointer crossing the gap between the parent menu and the floating submenu. Keyboard navigation is unchanged and now covered by tests. Closes #10549 AI-assisted-by: claude-fable-5 Signed-off-by: Akshit Nassa <nassaakshit@gmail.com> Co-authored-by: Andrey Antukh <niwi@niwi.nz> --- .../playwright/ui/specs/profile-menu.spec.js | 86 +++++++++++++++++++ .../app/main/ui/components/dropdown_menu.cljs | 9 +- .../src/app/main/ui/dashboard/sidebar.cljs | 63 +++++++++++--- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/frontend/playwright/ui/specs/profile-menu.spec.js b/frontend/playwright/ui/specs/profile-menu.spec.js index 71bdbb4199..3c12fbb151 100644 --- a/frontend/playwright/ui/specs/profile-menu.spec.js +++ b/frontend/playwright/ui/specs/profile-menu.spec.js @@ -28,6 +28,92 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => { ); }); +test("Submenu closes when hovering a menu option without submenu", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("About Penpot").hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + await dashboardPage.userProfileOption.hover(); + await expect(changelogSubmenuItem).toBeHidden(); +}); + +test("Submenu stays open while moving the pointer into it", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + const aboutPenpotItem = page.getByText("About Penpot"); + await aboutPenpotItem.hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + // Walk the pointer from the parent option into the submenu the way a + // real user does — gradually, crossing the gap between the two menus. + const from = await aboutPenpotItem.boundingBox(); + const to = await changelogSubmenuItem.boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { + steps: 20, + }); + + await expect(changelogSubmenuItem).toBeVisible(); +}); + +test("Submenu closes when the pointer leaves the menu entirely", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("About Penpot").hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + await dashboardPage.mainHeading.hover(); + await expect(changelogSubmenuItem).toBeHidden(); +}); + +test("Hovering another expandable option switches submenus", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("Help & Learning").hover(); + + const helpCenterSubmenuItem = page.getByText("Help Center"); + await expect(helpCenterSubmenuItem).toBeVisible(); + + await page.getByText("About Penpot").hover(); + await expect(page.getByText("Penpot Changelog")).toBeVisible(); + await expect(helpCenterSubmenuItem).toBeHidden(); +}); + +test("Submenu opens with keyboard navigation", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await dashboardPage.sidebarMenu + .getByRole("menuitem", { name: "Help & Learning" }) + .press("Enter"); + + await expect(page.getByText("Help Center")).toBeVisible(); +}); + test("Opens release notes from current version from profile menu", async ({ page, }) => { diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index aad1e9a47e..681682fdae 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -27,7 +27,7 @@ (mf/defc internal-dropdown-menu* {::mf/private true} - [{:keys [on-close children class id]}] + [{:keys [on-close children class id on-pointer-enter on-pointer-leave]}] (assert (fn? on-close) "missing `on-close` prop") @@ -106,7 +106,12 @@ #(doseq [key keys] (events/unlistenByKey key)))) - [:ul {:class class :role "menu" :ref container} children])) + [:ul {:class class + :role "menu" + :ref container + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave} + children])) (mf/defc dropdown-menu* [{:keys [show] :as props}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 420383aa60..bea1060183 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -1133,7 +1133,7 @@ (mf/defc help-learning-menu* {::mf/private true} - [{:keys [on-close on-click]}] + [{:keys [on-close on-click on-pointer-enter on-pointer-leave]}] (let [handle-click-url (mf/use-fn (fn [event] @@ -1150,7 +1150,9 @@ [:> dropdown-menu* {:show true :class (stl/css :sub-menu :help-learning) - :on-close on-close} + :on-close on-close + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave} [:> dropdown-menu-item* {:class (stl/css :submenu-item) :data-url "https://help.penpot.app" @@ -1177,7 +1179,7 @@ (mf/defc community-contributions-menu* {::mf/private true} - [{:keys [on-close]}] + [{:keys [on-close on-pointer-enter on-pointer-leave]}] (let [handle-click-url (mf/use-fn (fn [event] @@ -1191,7 +1193,9 @@ [:> dropdown-menu* {:show true :class (stl/css :sub-menu :community) - :on-close on-close} + :on-close on-close + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave} [:> dropdown-menu-item* {:class (stl/css :submenu-item) :data-url "https://github.com/penpot/penpot" @@ -1207,7 +1211,7 @@ (mf/defc about-penpot-menu* {::mf/private true} - [{:keys [on-close]}] + [{:keys [on-close on-pointer-enter on-pointer-leave]}] (let [version cf/version show-release-notes (mf/use-fn @@ -1230,7 +1234,9 @@ [:> dropdown-menu* {:show true :class (stl/css :sub-menu :about) - :on-close on-close} + :on-close on-close + :on-pointer-enter on-pointer-enter + :on-pointer-leave on-pointer-leave} [:> dropdown-menu-item* {:class (stl/css :submenu-item) :on-click show-release-notes} @@ -1255,6 +1261,11 @@ show-profile-menu? (deref show-profile-menu*) sub-menu* (mf/use-state false) sub-menu (deref sub-menu*) + + ;; Tracks whether the pointer is over an expandable option or + ;; its floating submenu, so the submenu survives the gap + ;; between them while the pointer travels across. + hovering?* (mf/use-ref false) version (:base cf/version) close-sub-menu @@ -1320,6 +1331,24 @@ (keyword))] (reset! sub-menu* menu)))) + on-menu-pointer-enter + (mf/use-fn + (fn [event] + (mf/set-ref-val! hovering?* true) + (on-menu-click event))) + + on-menu-pointer-leave + (mf/use-fn + (fn [_] + (mf/set-ref-val! hovering?* false) + (ts/schedule 200 #(when-not (mf/ref-val hovering?*) + (reset! sub-menu* nil))))) + + on-sub-menu-pointer-enter + (mf/use-fn + (fn [_] + (mf/set-ref-val! hovering?* true))) + on-power-up-click (mf/use-fn (fn [] @@ -1393,7 +1422,8 @@ :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) - :on-pointer-enter on-menu-click + :on-pointer-enter on-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave :data-testid "help-learning" :id "help-learning"} [:span {:class (stl/css :item-name)} (tr "labels.help-learning")] @@ -1404,7 +1434,8 @@ :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) - :on-pointer-enter on-menu-click + :on-pointer-enter on-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave :data-testid "community-contributions" :id "community-contributions"} [:span {:class (stl/css :item-name)} (tr "labels.community-contributions")] @@ -1415,7 +1446,8 @@ :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) - :on-pointer-enter on-menu-click + :on-pointer-enter on-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave :data-testid "about-penpot" :id "about-penpot"} @@ -1440,13 +1472,20 @@ (when show-profile-menu? (case sub-menu :help-learning - [:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}] + [:> help-learning-menu* {:on-close close-sub-menu + :on-click on-click + :on-pointer-enter on-sub-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave}] :community-contributions - [:> community-contributions-menu* {:on-close close-sub-menu}] + [:> community-contributions-menu* {:on-close close-sub-menu + :on-pointer-enter on-sub-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave}] :about-penpot - [:> about-penpot-menu* {:on-close close-sub-menu}] + [:> about-penpot-menu* {:on-close close-sub-menu + :on-pointer-enter on-sub-menu-pointer-enter + :on-pointer-leave on-menu-pointer-leave}] nil))])) (mf/defc sidebar* From 1f4b85209ebe6e1831fa08f2df581e9049689e61 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Fri, 10 Jul 2026 11:22:44 +0200 Subject: [PATCH 39/63] :books: Simplify the ia asistance note on creating-prs serena workflow --- .serena/memories/workflow/creating-prs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 204fd49c76..c0f763f9e3 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -38,7 +38,7 @@ Include concise sections covering: PR descriptions follow this structure: ```markdown -**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative. +**Note:** This PR was created with AI assistance. ## What From 176a813fb9c793348b88135bfb4626d14f8da7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= <belen.albeza@kaleidos.net> Date: Fri, 10 Jul 2026 12:02:30 +0200 Subject: [PATCH 40/63] :bug: Fix selected text background color in light theme (#10614) --- .../app/main/ui/workspace/viewport_wasm.cljs | 11 +++++++ frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 30 ++++++++++++++++++- frontend/src/app/util/color.cljs | 17 +++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 1c4384f1e6..4a0c4a80b2 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -575,6 +575,17 @@ (wasm.api/push-ruler-theme-colors!) (wasm.api/request-render "rulers-colors-theme"))))) + ;; Text-editor-wasm: push the theme colors (selection background, caret) + ;; into the WASM text editor so the selection follows the design tokens per + ;; theme (purple on light, teal on dark) instead of a hardcoded default. + (mf/with-effect [@canvas-init?] + (when @canvas-init? + (wasm.api/text-editor-apply-theme) + (theme/add-color-scheme-listener! + (fn [] + (wasm.api/text-editor-apply-theme) + (wasm.api/request-render "text-editor-colors-theme"))))) + ;; Ruler overlay updates below only change the UI surface, not the shapes. ;; They use `render-from-cache!` (cached tiles + UI, atomic) instead of a full ;; `request-render`, which would kick off a progressive tile-by-tile shape diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cea8256980..e4be664616 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -273,6 +273,7 @@ (def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas) ;; Re-export public text editor functions +(def text-editor-apply-theme text-editor/text-editor-apply-theme) (def text-editor-focus text-editor/text-editor-focus) (def text-editor-blur text-editor/text-editor-blur) (def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 5dba3e675c..d630346159 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -15,7 +15,10 @@ [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm])) + [app.render-wasm.serializers.color :as sr-clr] + [app.render-wasm.wasm :as wasm] + [app.util.color :as uc] + [app.util.dom :as dom])) (def multiple-state-multiple (sr/translate-multiple-state :multiple)) @@ -124,6 +127,31 @@ nil))) +(def ^:private selection-color-css-var "--text-editor-selection-background-color") +(def ^:private caret-color-css-var "--text-editor-caret-color") + +(defn- resolve-theme-color + "Resolve a themed CSS color variable (read from the document body) into a + 32-bit argb value for the WASM text editor, preserving the variable's alpha + channel." + [css-var] + (when-let [{:keys [color opacity]} + (uc/parse-css-color-opacity + (dom/get-css-variable css-var js/document.body))] + (sr-clr/hex->u32argb color opacity))) + +(defn text-editor-apply-theme + "Push the current theme's selection and caret colors (read from the CSS + custom properties on the document body) into the WASM text editor. The + editor theme is a persistent singleton, so call once after init and again + on every color-scheme change." + [] + (when wasm/context-initialized? + (let [selection (resolve-theme-color selection-color-css-var) + caret (resolve-theme-color caret-color-css-var)] + (when (and selection caret) + (h/call wasm/internal-module "_text_editor_apply_theme" selection caret))))) + (defn text-editor-focus [id] (when wasm/context-initialized? diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index 58cd64beb7..d28c3ccfb6 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -104,3 +104,20 @@ (cc/valid-hex-color? s) (-> (subs s 1) cc/expand-hex cc/prepend-hash) :else (some-> (cc/parse-rgb s) cc/rgb->hex))))) + +(def ^:private rgba-color-re + #"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([0-9]*\.?[0-9]+)\s*)?\)") + +(defn parse-css-color-opacity + "Like `parse-css-color` but also extracts the alpha channel, returning a + `{:color \"#rrggbb\" :opacity <0..1>}` map (opacity defaults to 1 when the + source has none). Handles #rrggbb, #rgb, rgb() and rgba(). Returns nil when + the string cannot be parsed." + [raw] + (let [s (some-> raw str/trim)] + (when (and (string? s) (seq s)) + (if (cc/valid-hex-color? s) + {:color (-> (subs s 1) cc/expand-hex cc/prepend-hash) :opacity 1} + (when-let [[_ r g b a] (re-matches rgba-color-re s)] + {:color (cc/rgb->hex [(js/parseInt r 10) (js/parseInt g 10) (js/parseInt b 10)]) + :opacity (if a (js/parseFloat a) 1)}))))) From 2c15dcdb84561b3b98872ef38e47482c7f9a985a Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" <dominik.jain@oraios-ai.de> Date: Fri, 10 Jul 2026 12:17:43 +0200 Subject: [PATCH 41/63] :sparkles: Add systematic component tests via a composable test model (#10529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add systematic component tests via a composable test model Introduce a framework for systematically testing Penpot component behaviour (synchronisation/propagation, swaps, variant switches, nesting), plus a first suite of cases built on it. A test is expressed as a COMPOSITION OF OPERATIONS over a "situation" (an in-memory file value plus named role bindings). Operations are reified as data and composed by two combinators — `in-sequence` (threads the situation) and `one-of`/`optional` (alternatives, enumerated into concrete variants). So one written case stands for a whole matrix of variants, and coverage grows by composition rather than by copying tests. Operations drive the REAL production change pipeline, and event-operations dispatch the REAL workspace events and await settlement, so the production watcher's automatic propagation is what is exercised — the tests reflect genuine app behaviour, not a reimplementation. Structure (frontend/test/frontend_tests/composable_tests/): - core — the domain-agnostic engine: situation, the operation and enumeration protocols, the combinators, and the runners. - comp/nodes — the component operations (create/instantiate/reset, nesting, swap, the variant ops, child add/remove/move, change, undo, library sync). - comp/setups — component-shaped starting configurations. - interpreter — runs a case against the real frontend store: sync-ops apply directly, event-ops dispatch real events and await settlement (absorbing sync-file's delayed status RPC, which would otherwise leak an error into subsequent tests). - comp/sync-test — the cases (B-F, H, I, K, L, M). This is test-only code with a single consumer — the frontend test suite (the layer that runs the real app) — so it lives entirely under the frontend test tree as .cljs, not under app/common. The framework and its cases are documented in the project memory frontend/composable-component-tests, added alongside. Co-authored-by: Claude <noreply@anthropic.com> * :bug: Guard WASM mock teardown against an empty snapshot `teardown-wasm-mocks!` unconditionally restored from the `originals` atom. When run without a matching setup (double teardown, or `with-wasm-mocks*` misused around an async test body), the snapshot is empty and every WASM API function was `set!` to nil — permanently, for the remainder of the test run. Any later code calling one of them (e.g. a leaked debounced resize-wasm-text event firing during a subsequent test namespace) then crashed with "initialized? is not a function". Make the restore a no-op when there is nothing to restore. Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> --- .../frontend/composable-component-tests.md | 317 +++++ .../composable_tests/comp/nodes.cljs | 1020 +++++++++++++++++ .../composable_tests/comp/setups.cljs | 203 ++++ .../composable_tests/comp/sync_test.cljs | 346 ++++++ .../frontend_tests/composable_tests/core.cljs | 611 ++++++++++ .../composable_tests/interpreter.cljs | 471 ++++++++ .../test/frontend_tests/helpers/wasm.cljs | 8 +- frontend/test/frontend_tests/runner.cljs | 2 + 8 files changed, 2976 insertions(+), 2 deletions(-) create mode 100644 .serena/memories/frontend/composable-component-tests.md create mode 100644 frontend/test/frontend_tests/composable_tests/comp/nodes.cljs create mode 100644 frontend/test/frontend_tests/composable_tests/comp/setups.cljs create mode 100644 frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs create mode 100644 frontend/test/frontend_tests/composable_tests/core.cljs create mode 100644 frontend/test/frontend_tests/composable_tests/interpreter.cljs diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md new file mode 100644 index 0000000000..28c0605569 --- /dev/null +++ b/.serena/memories/frontend/composable-component-tests.md @@ -0,0 +1,317 @@ +# Composable component tests + +A framework for systematically testing Penpot's component subsystem (synchronisation/propagation, +swaps, variant switches, nesting), plus the suite of cases built on it. Lives entirely in the +**frontend** test tree as `.cljs`; it is test-only code with a single consumer (the frontend test +suite, which runs the real app). There is nothing "common" about it — it is not under `app/common`. + +## Core idea +A test is a **composition of operations** over a **situation**, plus assertions. You describe a +test as data (a setup + a sequence of operations) rather than writing bespoke imperative code, and +coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing pieces, +not a copied test. One written case stands for a whole matrix of concrete cases. + +- **Situation** — the in-memory Penpot file value, plus named **roles** (meaningful shapes, e.g. + `:main-instance`/`:copy-instance`), a `:vars` map (named non-shape values), and an ordered + **applied-log** of what ran. +- **Operation** — a step, reified as a DATA record implementing `IOperation` (single method + `apply-to`; `apply` collides with core). Operations are printable, navigable, and enumerable. + Most transform the situation; some do not — hence the genus is "operation", not "transformation" + (`Test` asserts and returns the situation unchanged; `Skip` is a no-op). +- **Assertions** — inline `Test` operations placed in the sequence (assert at intermediate points) + and/or a trailing asserter (a `situation -> any` lambda calling `t/is`). The runner makes NO + judgment; it applies operations and returns the situation. Only *retrieval* helpers live outside + the test (role accessors, `has-property-of`, `applied?`). + +## Principles +- **Operations are data with identity.** Each node gets a unique id at construction (`assign-id`), + records what it did under that id (`record-application`), and is interrogated by identity + (`applied?`, `get-choice`). No flat keyword-tagged log. +- **Drive the real production pipeline.** Every operation routes through the actual production + change functions (`generate-update-shapes`, `generate-component-swap`, `generate-reset-component`, + `generate-sync-file-changes`, …) — never raw field writes, or propagation would have nothing to + react to. The test exercises genuine Penpot logic, not a reimplementation. +- **The frontend runs the real app.** Synchronous file-ops apply directly to the store; event-ops + dispatch the REAL workspace events and await settlement, so the production watcher's AUTOMATIC + propagation is what's under test. Observed semantics are genuine. +- **Roles resolve to ids at setup time.** The global label→id map (`thi`) is shared and + time-varying, so a role is captured as an id when the situation is built; resolving late (across + enumerated variants) would be unsound. Absence throws a diagnostic (never silent nil). +- **Targets resolve at apply-time and may be rebound.** A target is a `(situation -> id)` FUNCTION, + else a currently-bound ROLE, else a LABEL (`target-shape-id`). So one operation targeting a role + follows that role as state-building ops re-point it — which is what lets a single operation be + swept across depth. +- **Enumeration is authored, not exhaustive.** You compose only VALID cases (every `one-of` branch + must be valid against the setup), so outcomes are just pass / fail / error — no not-applicable + cells. Adding a variation across a matrix is a one-expression edit. +- **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework + abstractions (they collide with real domain concepts). Framework vocabulary is testing concepts + only; an operation may name the domain *action* it performs (`swap`, `propagate`). + +## Composition operators +- `in-sequence` — ordered application, threads the situation; enumerates to the CARTESIAN PRODUCT + of its steps' variants. Operations do not commute, so order is explicit. The workhorse. +- `one-of` — exactly one branch; applying it throws (must be enumerated); enumerates to the UNION + of branches, each wrapped in a `RecordedChoice` so `get-choice` recovers which ran. +- `optional(X)` = `one-of([X, skip])` — sweeps "with and without X" (two variants). `skip` is the + identity operation. The workhorse for adding a state-building step as an axis over a case. +- `test-that [assert-fn]` — an inline `Test`: asserts at this point in the sequence, situation + unchanged. Lets checkpoints sit at intermediate steps. Engine stays clojure.test-free (it just + calls the supplied fn). +- `applied? [situation operation]` — whether that exact node ran (identity-based). Composes with + `optional`/`one-of` for free. REQUIREMENT: bind an operation to a value ONCE and reuse it (in the + composition AND any `Test` querying it), so the id you ask about is the id that ran. + +## Structure (frontend/test/frontend_tests/composable_tests/) +Two boundaries: the domain-agnostic **engine** and the **comp** subject library (about components; +naming the domain is correct there). + +- `core.cljs` (ns `frontend-tests.composable-tests.core`) — the engine, one file: + - situation: `make-situation`, `file`/`with-file`, `with-aux-files`/`aux-files` (carry extra + files, e.g. a library for case H), the applied-log. + - identity & transcript: `assign-id`, `node-id`, `record-application` (also stores a `::kind` + from the record type), `node-data`, `describe-applied` (readable ordered transcript, attached + to every failure so a failing variant in a sweep is identifiable). + - roles & lookup: `role-shape` (strict, by stored id), `has-role?`, `rebind-role`/`rebind-role-id` + (re-point a role — used by state-building ops), `target-shape-id` (fn | role | label), + `shape-by-id` (read a shape whose id is held in a `:vars` object), `resolve-shape`/`-id`. + - protocols: `IOperation`(`apply-to`); `IEnumerable`(`-enumerate`) + `enumerate`. + - operators: `Sequence`/`in-sequence`; `OneOf`/`one-of`/`RecordedChoice`/`get-choice`; + `Skip`/`skip`; `optional`; `Test`/`test-that`; `applied?`; vars `set-var`/`get-var`. + - runners (clojure.test-free): `run-variant`, `run-all` (enumerate → vector, `thi/reset-idmap!` + per variant). + - per-op-interpreter helpers: `sequence-ops` (flatten a concrete variant — flattens `Sequence`, + keeps `RecordedChoice` as a unit), `recorded-choice?`, `choice-of`, `choice-one-of-id`. +- `comp/setups.cljs` — setup fns returning a situation, + role accessors (`main-instance`/ + `copy-instance`/`main-root`/`copy-root`, `copy-child` 1-based): `simple-component-with-copy`, + `simple-component-with-labeled-copy`, `component-with-many-children` (E, F), + `nested-component-with-copy`, `cross-file-component-with-copy` (H: main in a linked library, copy + in the consuming file; primary `:file` = consuming, aux = library), and `empty-situation` (empty + file, no roles — the `:setup` for the sweep cases, whose first operation is `create-component`). +- `comp/nodes.cljs` — the component OPERATIONS. General edit/structure ops: + `change-property [target property value]` (`:fills`/`:opacity`) with its dual `has-property-of` + (`IPropertyCheck`); `change-attr`/`has-attr?` are aliases. `add-child`/`remove-child`/`move-child` + (with `IStructuralCheck`). `sync-from-library` (H: `generate-sync-file-changes`, libraries = + primary + aux). `undo` (I; frontend `dwu/undo`). Plus the scenario building blocks below. +- `interpreter.cljs` (ns `frontend-tests.composable-tests.interpreter`) — runs a case against the + real frontend (see "Interpreter"). +- `comp/sync_test.cljs` (ns `frontend-tests.composable-tests.comp.sync-test`) — the cases; + registered in `frontend_tests/runner.cljs`. + +## Scenario object model (behind the sweep cases) +Scenario ops track one or more named component LINEAGES as OBJECTS under `:vars :components` (keyed +by name, e.g. "main"). Grouping a lineage's fields into one object lets several lineages coexist +(a swap targets a DIFFERENT component) and makes each op "read object `name`, update, write back". + +A lineage object has: +- `:main-component-id` — the component the next instantiate/nest uses (advances to the new OUTER + component per `make-nested-component`). +- `:remote-head`/`:remote-rect` — the FIXED deepest origin (the original component everything is + derived from). For a plain lineage this is never re-pointed; a variant nesting DOES re-point it + (see below). +- `:main-head`/`:main-rect` — the current outer main (advances per nesting). +- `:nesting-count`; `:copies`, `:copy-head`/`:copy-rect` (from `instantiate-copy`). +- `:nesting-data` — vector, one entry per level i: `{:main-head, :nested-head, :nested-rect, + :nested-head-parent}`. + - `:nested-head` is the DEEPEST instance at level i — the descendant of the level's copy head + that corresponds to `:remote-head`, found by descending the `:shape-ref` chain (at level 0 the + inner copy itself; deeper, the corresponding shape nested within the outer wrapper). This is THE + SWAP / SWITCH TARGET; it carries a `:component-id`. + - `:nested-head-parent` is the SWAP-STABLE anchor: a swap/switch replaces the head in place but + keeps its parent, so assertions re-resolve parent → current head → rect. + +Accessors / targets (in nodes): `lineage-component-id`, `lineage-rect`, `lineage-copy-rect`, +`lineage-nesting`, `level-rect`/`level-rect-of` (level i's CURRENT rect via the parent anchor); +target-fns `remote-rect-of`/`main-rect-of`/`copy-rect-of` and `nested-head-of` (return a +`(situation -> id)` for use as an operation target). "Corresponds to" across layers follows the +`:shape-ref` CHAIN, matching on chain MEMBERSHIP not terminus (a copy rect refs its near-main, +which carries a further `:shape-ref`, so the terminus over-walks). Single-file setups, so the chain +resolves in the local page objects. + +Scenario operations (each takes a lineage `name`): +- `create-component [name color]` — a component (frame + rect child of `color`); remote == main, + count 0. +- `make-nested-component [name]` — wrap `name`'s component in a NEW OUTER component whose main + contains a COPY of it inside a board (add-frame → instantiate inside → make-component); the OUTER + becomes `:main-component-id`; advance `:main-*`; append a `:nesting-data` entry; bump count. + ITERABLE: `×N` = board-within-board nesting with one rect at the bottom. Each level's + `:nested-head` is the deepest instance there, so swapping/switching it propagates (via the + watcher) outward to copies of it in OUTER levels. +- `instantiate-copy [name]` — instantiate `name`'s current component; track `:copy-head` and the + rect corresponding to its main rect as `:copy-rect`. +- `reset-copy-instance [name]` — reset overrides on `:copy-head` (production + `generate-reset-component`, `:validate? false` — a file-op on the frontend too: the real reset + event reads browser globals and cannot run headless). +- `swap-component [name level target & {:keys [keep-touched?]}]` — swap level `level`'s + `:nested-head` for lineage `target`'s component, via production `generate-component-swap`. + Frontend = the REAL `dwl/component-swap` event, so the watcher AUTOMATICALLY propagates the swap + to copies (incl. deeper levels). A swap replaces the head in place: Penpot keeps the head id, + rewrites it to the new component, stamps a `:swap-slot-<uuid>` touched group. `keep-touched?` + default false (discards overrides); true is the variant-switch flavour. +- Shared nesting helper `nest-in-new-outer-component [situation name op seek-rect-id seek-head-id + instantiate-inner-fn]` — the contain-outward mechanism behind BOTH nesting ops. Adds the outer + frame, calls `instantiate-inner-fn` to place the inner instance, makes the outer a component, + then computes this level's `:nested-rect`/`:nested-head` as the IMAGES (inside the new inner copy) + of `seek-rect-id`/`seek-head-id` — the FIXED deepest origin — and does the bookkeeping. A flavour + supplies only the two origin ids + the instantiate fn. Seeking the FIXED origin (not the advancing + `:main-*`) is what makes `:nested-head` land on the deepest instance at every level. + `self-or-descendant-corresponding-to` is the chain-descent that also matches the head itself + (needed at level 0, where the inner copy head IS the origin's image). + +## Variant operations +A variant switch IS a keep-touched swap whose target is resolved by a property VALUE (the +production `variants-switch`/`variant-switch` reduces to `component-swap … keep-touched? true`), so +it routes through the SAME `generate-component-swap` and the watcher auto-propagates it across +nesting levels exactly like a swap. +- `make-variant-container [name members]` (sync-op) — build a variant SET synchronously, mirroring + the test-helpers' `add-variant` idiom: a container frame (`:is-variant-container`), each `members` + entry `[value color]` becoming a member component whose ROOT is a child of the container carrying + the shared `:variant-id`/`:variant-name`, then `update-component` stamps `:variant-id` + + `:variant-properties [{:name "Property 1" :value value}]` on the component. Read the container id + via `(thi/id container-label)` only AFTER adding the container frame (`thi/id` returns nil before + the shape exists). Records the set in `:vars` (`variant-set`/`variant-member`/ + `variant-member-component-id` read it back). +- `make-nested-component-with-variant [name set-name value]` (sync-op) — nest a chosen member via + the shared nesting helper, AND re-point the lineage's `:remote-head`/`:remote-rect` to that + member's root/rect: nesting a variant makes the member the new deepest origin, so subsequent plain + `make-nested-component` descends to the variant's image (its `:nested-head`) at every level. +- `switch-variant [target value]` (frontend event) — switch the variant copy head bound to `target` + to the sibling member with property value `value`, via the REAL `dwv/variants-switch` event (which + DISCOVERS the sibling in the container via `find-variant-components`). `target` uses the standard + resolution (role | label | fn), so the op knows nothing about nesting; cases supply + `nested-head-of name i`. + +## Interpreter (interpreter.cljs) +Drives the real app: +- `op->events [op situation]` maps each event-dispatching operation to its real workspace event(s): + `ChangeProperty`→`dwsh/update-shapes` (the SHARED `set-property`, target via `target-shape-id`); + `MoveChild`→`dwsh/relocate-shapes`; `RemoveChild`→`dwsh/delete-shapes`; `AddChild`→`dwsh/add-shape`; + `SyncFromLibrary`→`dwl/sync-file`; `Undo`→`dwu/undo`; `SwapComponent`→`dwl/component-swap`; + `SwitchVariant`→`dwv/variants-switch {:shapes [head] :pos 0 :val value}`. All real events, so the + watcher auto-propagates. +- `sync-op?` ops (`MakeNestedComponent`, `CreateComponent`, `InstantiateCopy`, `ResetCopyInstance`, + `MakeVariantContainer`, `MakeNestedComponentWithVariant`, `Skip`, `Test`) are NOT dispatched as + events: `run-sync-op` runs the shared `apply-to` against a situation whose `:file` is the live + store file, then writes back synchronously. The property under test is still exercised by the + subsequent real-event ops + the watcher. +- It installs the situation's files into the global `st/state` store (primary as current; aux files + tagged `:library-of` the current file so the library-sync machinery treats them as linked), starts + the real `watch-component-changes` and the harness `watch-undo-stack`, then folds the operations: + dispatch events → await settlement → re-read the current file into `:file` → record. Re-reading + `:file` each step is why the shared role accessors keep working. +- `watch-undo-stack` mirrors the production undo-append subscription from `initialize-workspace` + (which the harness does not run); without it `dwu/undo` has an empty stack. +- Settlement (`await-settle`): subscribe to the commit stream, resolve on the first 60ms idle gap + after a commit (captures the edit commit and the watcher's follow-up sync commit), 2000ms timeout. + Debounce-based, not a deterministic per-op stopper. After settling, `op-grace-ms` adds a per-op + grace wait (currently only `SyncFromLibrary`, ~3.2s — see the Running note). +- Thumbnail rendering is stubbed for this suite (`install-thumbnail-noop!` no-ops + `dwth/update-thumbnail`): the propagation watcher schedules thumbnail renders that reach `window`, + absent headless. +- `check [done case-map asserter]` enumerates, runs each variant via the async fold, wraps the + asserter in `describe-applied`, and calls `done`. Per-variant isolation = id-map reset + global + state re-install (the global `st/state` is a shared `defonce`). +- STORE-SWAP IMMUNITY (`original-store`/`restore-global-store!`): many plugins-suite namespaces + `set!` `st/state`/`st/stream` to isolated stores and never restore them. The `app.main.refs` + lenses (through which the watcher observes commits) are okulary lenses bound to the ORIGINAL + atom instance at load time — after such a swap, events commit to a store the watcher cannot see + and ALL propagation dies silently (assertions see base/unsynced state; no error). The interpreter + therefore captures `st/state`/`st/stream` at namespace-LOAD time (before any test runs) and + re-`set!`s them at the start of every variant, making the harness immune to run order. Diagnosed + by bisecting the runner's deterministic execution order (it is NOT the `test-namespaces` vector + order: `t/test-vars-block` groups vars by namespace, and the group-by hash order decides — same + order locally and in CI). + +## Cases +Asserters are inline; no `doseq`, no count assertions. +- **B** — an override on the copy survives a later main change (override present + `:touched` + contains the fill group + `:shape-ref` present). +- **C** — attribute sweep via `one-of {fills, opacity}`: assert `(has-attr? (get-choice …) copy)` + per enumerated variant. +- **D** — `add-child` to the main gives the copy a ref-integral child (`is-main-of?` + + `parent-of?` + untouched). +- **E** — `remove-child` of the MIDDLE of three: survivors keep order `[child1, child3]`, + `:shape-ref` intact, untouched (middle removal is where index maintenance is tested). +- **F** — `move-child` of child1 to index 2: copy mirrors `[child2, child1, child3]`, identity + preserved. +- **H** — locality (cross-file): main in a linked library, copy in the consuming (current) file, + library main diverged. The in-file watcher does not cross a library boundary; the cross-file + mechanism is the library-update action (`sync-from-library` → `sync-file file-id library-id`). + Setup order matters: instantiate the copy first (captures the old value), diverge the library main + after. +- **I** — undo: an edit then `undo`; copy and main return to baseline, copy untouched. A single + `dwu/undo` reverses the whole logical action — the edit AND its auto-propagation — because the two + commits share an undo-group. +- **K** — SYNC-SCENARIO SWEEP (the flagship). On `empty-situation`: `create-component` → two + `(optional make-nested-component)` (depths 0/1/2) → `instantiate-copy` → three `(optional change-*)` + over remote/main/copy → INLINE checkpoints: (1) override-precedence at the copy (copy wins, else + main, else remote — branch via `applied?`), (2) force a copy override and confirm it wins, (3) + after `reset-copy-instance`, copy reverts to main's value if main changed, else remote's. No + explicit propagate (the watcher auto-propagates at all depths, incl. chained remote→deep-copy). +- **L** — SWAP SWEEP. On `empty-situation`: `create-component` (base) + one swap-target lineage per + level → three `make-nested-component` → three `(optional (swap-component "main" i target_i))` → + one `Test` asserting each level's colour. A swap at level i auto-propagates to level i and every + OUTER level until a higher swap overrides; colour at level i = applied swap at highest j<=i, else + base. +- **M** — VARIANT-SWITCH SWEEP (case L with a variant switch instead of the plain swap, driving the + REAL variant-switch machinery). On `empty-situation`: `create-component` (base lineage "main") + + `make-variant-container` (4 peer members `v0..v3`) → ONE `make-nested-component-with-variant + "main" "vset" "v0"` (introduce the variant innermost) + two plain `make-nested-component` + (progressive wraps, so each outer level CONTAINS the one below) → three + `(optional (switch-variant (nested-head-of "main" i) v_{i+1}))` → one `Test` with case L's exact + precedence asserter. Because the single variant instance has a switchable `:nested-head` at EVERY + level and the levels are progressively nested, a switch at level i propagates outward like a swap. + NOTE on structure: ONE variant + plain wraps is required for cross-level propagation (the levels + nest WITHIN each other). Three independent `make-nested-component-with-variant` would nest SIBLING + variants (none a descendant of another), so switches would not propagate between them — the right + construction for a different test (one asserting switches DON'T cross unrelated instances). + +Running: `cd frontend && pnpm run build:test` then `node target/tests/test.js --focus +frontend-tests.composable-tests.comp.sync-test`. To run one case, use var-level focus, e.g. +`…/case-m-variant-switch-scenarios`. NOTE: the production `sync-file` event (case H) additionally +schedules a 3s-delayed `update-file-library-sync-status` RPC, which fails headless (no backend; a +swallowed URL-parse trace is benign). The interpreter absorbs it: `op-grace-ms` makes the run wait +~3.2s after a `SyncFromLibrary` settles, so the failure lands inside case H instead of leaking into +(and potentially destabilising) whichever test runs next. + +## Frontend fidelity — read before extending +The frontend runs the REAL production logic from the dispatched event onward, so observed semantics +are genuine. But it drives a MINIMALLY-ASSEMBLED app: it installs a file into the global store and +starts only the watchers known to be needed. The real app assembles its workspace via +`initialize-workspace`, which wires many subscriptions; the harness reproduces only some. + +The risk is SILENT UNDER-WIRING — a behaviour that works in the real app can be silently absent in +the harness with no error (e.g. the undo stack is empty unless `watch-undo-stack` is started). +Therefore: when adding a case needing app behaviour beyond a raw edit (undo, persistence, selection, +layout, thumbnails, library auto-detection), first check whether that behaviour lives in an +`initialize-workspace` subscription the harness has not wired — and verify by PROBING store state, +not by trusting a green assertion. The harness hand-wires two stand-ins (`install-file-event`, +`watch-undo-stack`); track them for drift. A durable fix would be to drive the real +`initialize-workspace` headlessly (not done — full init may pull in machinery that doesn't run +cleanly headless). + +## Other caveats +- Cross-namespace leaks land in this suite first because it (correctly) uses the real global + store: besides the store swap above, `frontend-tests.helpers.wasm/teardown-wasm-mocks!` used to + `set!` every WASM fn to nil when run against an empty snapshot (double teardown / async misuse of + `with-wasm-mocks*`), which a leaked debounced `resize-wasm-text` event then tripped over during + our cases (a "Store error: initialized? is not a function"). The teardown is now guarded + (no-op on empty snapshot). If a new inexplicable full-run-only failure appears here, suspect + leaked global state from a preceding namespace before suspecting the framework. +- INLINE `Test` exceptions on the frontend are UNCAUGHT (they run during the async fold, not under + `check`'s try): a throwing checkpoint crashes the whole runner rather than failing one test. +- A `RecordedChoice` wrapping a `Sequence` (i.e. `(optional (in-sequence […]))`) is NOT flattened by + `sequence-ops`, so `op->events` chokes on the `Sequence`. Use independent optionals instead (as + case K does with two `(optional make-nested-component)`), or have the interpreter recurse into a + choice's composite alternative. +- The Serena symbol index / clj-kondo cache for `nodes.cljs` can go STALE and report PHANTOM symbols + or spurious "unresolved symbol" errors against code that compiles — TRUST THE BUILD (a real + `pnpm run build:test`), not the lint or the symbol overview, for this file. +- Label-after-the-fact resolution: `add-child`'s `added-shape` resolves `:new-label` via `thi/id`, + relying on the global label map still reflecting that run's setup — unsound if a structural node + is swept via `one-of`. Fix when needed by capturing the created shape's id at apply-time (as roles + already do). + +## Substrate +`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, +`mem:frontend/testing`. diff --git a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs new file mode 100644 index 0000000000..61c91d62c8 --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs @@ -0,0 +1,1020 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.composable-tests.comp.nodes + "Component-specific operation nodes for the test model. + + These are the `IOperation` implementations whose subject is Penpot component + behaviour: they wrap component/shape operations and drive the real production + change pipeline. This namespace sits behind the `comp` boundary precisely + because it is about components; the generic engine in + `frontend-tests.composable-tests.core` has no domain terms. See + `mem:frontend/composable-component-tests`. + + Nodes deliberately call the production change pipeline directly + (`cls/generate-update-shapes` + `thf/apply-changes`, + `cll/generate-sync-file-changes` + `thf/apply-changes`) in the same way the + existing `app.common.test-helpers.compositions` helpers do. + + Each node, on apply, records a self-description into the situation's applied-log + so conditions (and later undo) can read back what it did, keeping the + operation the single source of truth for what should hold." + (:require + [app.common.data :as d] + [app.common.files.changes-builder :as pcb] + [app.common.files.helpers :as cfh] + [app.common.logic.libraries :as cll] + [app.common.logic.shapes :as cls] + [app.common.logic.variants :as clv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.container :as ctn] + [frontend-tests.composable-tests.core :as tm])) + +;; --------------------------------------------------------------------------- +;; Properties — an OPEN, extensible vocabulary of "what about a shape can change" +;; +;; A property is named by a keyword (`:fills`, `:opacity`, … extensible to +;; `:text`). Two pure functions know how to WRITE a property onto a shape (the +;; edit) and how to READ its comparable value back (the check). `change-property` +;; (the edit operation) uses `set-property`; `has-property-of` (the inspection) +;; uses `read-property` — so the change and its check stay duals, and adding a new +;; property kind is one clause in each `case`. (`change-attr`/`has-attr?` remain as +;; aliases for the common `:fills`/`:opacity` use.) +;; --------------------------------------------------------------------------- + +(defn set-property + "Write `property` = `value` onto `shape`, returning the updated shape. The edit + half of a property; extend the `case` to support more properties. Public so the + frontend interpreter applies the IDENTICAL edit the common node does." + [shape property value] + (case property + :fills (assoc shape :fills (ths/sample-fills-color :fill-color value)) + :opacity (assoc shape :opacity value) + (throw (ex-info (str "set-property: unsupported property " (pr-str property) + " (supported: :fills, :opacity)") + {:property property})))) + +(defn- read-property + "Read the comparable value of `property` from `shape`. The check half of a + property; extend the `case` to support more properties." + [shape property] + (case property + :fills (-> shape :fills first :fill-color) + :opacity (:opacity shape) + (throw (ex-info (str "read-property: unsupported property " (pr-str property) + " (supported: :fills, :opacity)") + {:property property})))) + +(defrecord ChangeProperty [target property value] + tm/IOperation + (apply-to [this situation] + ;; Goes through the production change path, exactly like tho/update-color, + ;; then records under its own identity. + (let [the-file (tm/file situation) + ;; `target` may be a ROLE (resolved via the situation, so the edit + ;; FOLLOWS the role as state-building ops like make-nested-component re-point it) + ;; or a label; strict-presence throws if neither resolves. + shape-id (tm/target-shape-id situation target) + page (thf/current-page the-file) + changes (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) + #{shape-id} + (fn [shape] (set-property shape property value)) + (:objects page) + {}) + file' (thf/apply-changes the-file changes)] + (-> situation + (tm/with-file file') + (tm/record-application this {:target target :property property :value value}))))) + +(defn change-property + "Constructor for the property-change operation: change `property` of the shape + named by `target` (a role or a label) to `value`. Stamps a node identity so the + test can interrogate it (via `has-property-of`). The general form; `change-attr` + is the same thing for the common `:fills`/`:opacity` properties." + [target property value] + (tm/assign-id (->ChangeProperty target property value))) + +(def ^{:doc "Alias of `change-property` (the historical name for :fills/:opacity + changes). `[target attr value]`."} + change-attr change-property) + +(defprotocol IPropertyCheck + "Inspection capability of a property-changing operation: report on its OWN + effect, applied to a shape the caller supplies. Makes no judgment about which + shape — the test supplies that (e.g. `(role-shape s :copy-child-rect)`)." + (applied-property [node] "The property this node changed.") + (applied-value [node] "The value this node applied.") + (has-property-of [node shape] + "True if `shape` carries this node's changed property at this node's applied + value. The node reports against its own change; the test chooses the shape.")) + +(extend-type ChangeProperty + IPropertyCheck + (applied-property [node] (:property node)) + (applied-value [node] (:value node)) + (has-property-of [node shape] + (= (:value node) (read-property shape (:property node))))) + +;; Aliases for the historical `has-attr?` name (used by the earlier cases). +(def ^{:doc "Alias of `has-property-of`."} has-attr? has-property-of) +(def ^{:doc "Alias of `applied-property`."} applied-attr applied-property) + +;; =========================================================================== +;; Synchronisation-scenario building blocks +;; +;; A family of operations that build up a component/copy configuration STEP BY +;; STEP, tracking a small, explicit set of named things in the situation so that +;; later edits and assertions can refer to them regardless of how much nesting was +;; applied. The tracked things per lineage (see the "Component objects" block +;; below for the exact fields): +;; +;; - the REMOTE head/rect — the ORIGINAL instance and its rect (the fixed +;; deepest origin; never re-pointed by plain nesting), +;; - the MAIN head/rect — the CURRENT outer main and its rect (the shape an +;; edit-to-main targets; advances inward with each `make-nested-component`), +;; - the COPY head/rect — the copy produced by `instantiate-copy` and its rect +;; (the shape an edit-to-copy targets / assertions observe), +;; - the component id the next instantiate/nest uses, and the nesting count. +;; +;; "corresponds to" between layers is resolved by following the :shape-ref chain +;; (a copy shape refs the main shape it mirrors); within these single-file setups +;; the chain resolves in the local page objects, so a local walk suffices. +;; =========================================================================== + +(defn- ref-chain-ids + "The set of ids on `shape`'s :shape-ref chain within `objects`, INCLUDING the + shape's own id and every shape its :shape-ref transitively points at. So a copy + shape's chain contains the near-main it mirrors, that near-main's near-main, and + so on — letting us recognise the descendant that is the image of a given origin + shape regardless of how many layers sit between." + [objects shape] + (loop [s shape, acc #{}] + (let [acc (conj acc (:id s))] + (if-let [ref (:shape-ref s)] + (recur (get objects ref) acc) + acc)))) + +(defn- descendant-corresponding-to + "The id of the shape in `head-id`'s subtree that is the propagated image of + `target-id` — i.e. whose :shape-ref chain PASSES THROUGH `target-id`. Throws if + not found or ambiguous — a structural invariant of these setups." + [objects head-id target-id] + (let [descendants (cfh/get-children-ids objects head-id) + matches (filter (fn [id] + (contains? (ref-chain-ids objects (get objects id)) target-id)) + descendants)] + (case (count matches) + 1 (first matches) + 0 (throw (ex-info "descendant-corresponding-to: no descendant corresponds to target" + {:head head-id :target target-id})) + (throw (ex-info "descendant-corresponding-to: ambiguous correspondence" + {:head head-id :target target-id :matches matches}))))) + +(defn- self-or-descendant-corresponding-to + "Like `descendant-corresponding-to`, but considers `head-id` ITSELF as well as + its subtree. Needed for the nested HEAD: when the instance nested at a level is + directly the image of the origin instance (e.g. level 0, where the inner copy + head IS that image), the match is the head itself, not a descendant." + [objects head-id target-id] + (if (contains? (ref-chain-ids objects (get objects head-id)) target-id) + head-id + (descendant-corresponding-to objects head-id target-id))) + +;; =========================================================================== +;; Component objects — the situation tracks NAMED component lineages +;; +;; The sync/swap scenario operations track one or more COMPONENT LINEAGES, each an +;; addressable OBJECT under `:vars :components`, keyed by a name (e.g. "main"). This +;; replaces the earlier flat, single-lineage roles (:main-child-rect, …): grouping +;; one lineage's fields into one object (a) lets several lineages coexist — required +;; for swap, whose target is a *different* component — and (b) makes each operation +;; "read object `name`, update its fields, write it back". +;; +;; A component object has: +;; :main-component-id - the component to instantiate next for this lineage +;; (advances to the new OUTER component on make-nested-component) +;; :remote-head/:remote-rect - the fixed deepest origin (NEVER re-pointed) +;; :main-head/:main-rect - the current main (advances inward on make-nested-component) +;; :nesting-count +;; :nesting-data - vector, one entry per nesting level i, each: +;; {:main-head <id of that level's outer main> +;; :nested-head <id of the subinstance head introduced at level i> +;; :nested-rect <id of that nested head's rect, AT CREATION> +;; :nested-head-parent <id of the shape containing the nested head>} +;; The PARENT is the swap-stable anchor: a swap replaces the head in place but +;; keeps its parent, so an assertion re-resolves parent -> current head -> rect. +;; +;; (Child shapes are addressed via the `*-rect` fields / `lineage-rect`; if other +;; child kinds are added later, they get parallel fields, not a restructure.) +;; =========================================================================== + +(defn- get-component-obj + "The component object named `name` from the situation (nil if absent)." + [situation name] + (get (tm/get-var situation :components {}) name)) + +(defn- put-component-obj + "Store/replace the component object named `name`." + [situation name obj] + (tm/set-var situation :components + (assoc (tm/get-var situation :components {}) name obj))) + +(defn- update-component-obj + "Apply `f` (obj -> obj) to the component object named `name`." + [situation name f] + (put-component-obj situation name (f (get-component-obj situation name)))) + +(defn lineage-component-id + "The component id the lineage `name` will instantiate next." + [situation name] + (:main-component-id (get-component-obj situation name))) + +(defn lineage-rect + "The id of lineage `name`'s current main rect (the edit-to-main target)." + [situation name] + (:main-rect (get-component-obj situation name))) + +(defn lineage-nesting + "The `nesting-data` entry for level `i` of lineage `name`." + [situation name i] + (get-in (get-component-obj situation name) [:nesting-data i])) + +;; Target helpers — return a (situation -> shape-id) fn for `change-property`'s +;; target (resolved at apply-time against the lineage object, so it follows the +;; current ids). `applied`-querying `Test`s read the same fields via the accessors +;; below. + +(defn remote-rect-of + "Target: lineage `name`'s fixed remote (deepest-origin) rect." + [name] + (fn [s] (:remote-rect (get-component-obj s name)))) + +(defn main-rect-of + "Target: lineage `name`'s current main rect." + [name] + (fn [s] (:main-rect (get-component-obj s name)))) + +(defn copy-rect-of + "Target: lineage `name`'s current copy rect (from the latest instantiate-copy)." + [name] + (fn [s] (:copy-rect (get-component-obj s name)))) + +(defn lineage-copy-rect + "The id of lineage `name`'s current copy rect (for assertions)." + [situation name] + (:copy-rect (get-component-obj situation name))) + +(defn level-rect + "The id of the rect currently at lineage `name`'s nesting level `level`, + re-resolved from the swap-stable :nested-head-parent: parent -> current + subinstance head -> its rect descendant. Robust to a swap having replaced the + head (and the rect) in place." + [situation name level] + (let [objects (:objects (thf/current-page (tm/file situation))) + nd (lineage-nesting situation name level) + parent (:nested-head-parent nd) + head (->> (cfh/get-immediate-children objects parent) + (filter :component-id) + first + :id) + ;; the rect is the (single) rect descendant under the head + rect (->> (cfh/get-children-ids objects head) + (map #(get objects %)) + (filter #(= :rect (:type %))) + first + :id)] + rect)) + +(defn level-rect-of + "Target/accessor fn: the rect currently at lineage `name`'s nesting level + `level` (see `level-rect`)." + [name level] + (fn [s] (level-rect s name level))) + +;; --------------------------------------------------------------------------- +;; create-component — the starting operation: a component with a rect child +;; --------------------------------------------------------------------------- + +(defrecord CreateComponent [name color] + tm/IOperation + (apply-to [this situation] + ;; fresh, name-scoped labels so several lineages don't clash + (let [head-label (keyword (str "sync-" name "-head")) + rect-label (keyword (str "sync-" name "-rect")) + comp-label (keyword (str "sync-" name "-component")) + file' (-> (tm/file situation) + (tho/add-simple-component comp-label head-label rect-label + :child-params + {:fills (ths/sample-fills-color + :fill-color color)})) + head-id (thi/id head-label) + rect-id (thi/id rect-label) + comp-id (thi/id comp-label)] + (-> situation + (tm/with-file file') + ;; create the lineage object: remote == main at creation (no nesting yet) + (put-component-obj name + {:main-component-id comp-id + :remote-head head-id :remote-rect rect-id + :main-head head-id :main-rect rect-id + :nesting-count 0 + :nesting-data []}) + (tm/record-application this {:component name :id comp-id}))))) + +(defn create-component + "Starting operation: create a component lineage named `name` (root frame + one + rect child of fill `color`) and track it as a component OBJECT. Sets + remote==main head+rect, :main-component-id, nesting-count 0. Begin a scenario + with this; create more lineages (e.g. swap targets) with further calls." + [name color] + (tm/assign-id (->CreateComponent name color))) + +;; --------------------------------------------------------------------------- +;; make-nested-component — wrap the tracked component in a NEW OUTER component +;; +;; ONE specific notion of nesting (there are several): a new ENCLOSING component +;; whose main CONTAINS a COPY of the lineage's current component (contain-outward), +;; and the OUTER component becomes the lineage's new :main-component-id. The inner +;; copy's rect that corresponds (via :shape-ref) to the lineage's :remote-rect +;; becomes the new :main-rect, so an edit-to-main now targets one level deeper +;; while :remote-rect stays the fixed origin. Iterable: apply twice for 2 layers. +;; This is the structural family of penpot#9304 (a subinstance head inside a copy; +;; that issue adds a variant inner component + an extra frame — separate axes). +;; --------------------------------------------------------------------------- + +(defn- nest-in-new-outer-component + "Shared nesting mechanism (contain-outward): add a fresh outer board, run + `instantiate-inner-fn` to place THE INNER INSTANCE inside it, turn the board + into a NEW OUTER component, then advance lineage `name`'s object (main -> + deeper rect, append a `nesting-data` entry with the swap-stable + :nested-head-parent, bump nesting-count) and record `op`. + + `instantiate-inner-fn` is `(file outer-frame-label inner-copy-label) -> file`: + it instantiates whatever is being nested (the lineage's own component, or a + chosen variant) into `outer-frame-label`, registering `inner-copy-label` as + the inner copy's root. + + `seek-rect-id` / `seek-head-id` are the ORIGIN rect and the ORIGIN instance head + whose IMAGES inside the new inner copy become this level's nested rect and nested + head — found by following the shape-ref chain. For nesting the lineage's own + component these are the lineage's :remote-rect / :remote-head; for nesting a + variant member they are THAT MEMBER's rect / root (the inner copy refs the member, + not the lineage). `nested-head` is thus the DEEPEST instance (the image of the + original component's instance), per spec — at level 0 it is the inner copy itself; + at deeper levels it is the corresponding shape nested within. Everything else is + identical across nesting flavours, so a new flavour supplies only these three." + [situation name op seek-rect-id seek-head-id instantiate-inner-fn] + (let [the-file (tm/file situation) + obj (get-component-obj situation name) + level (:nesting-count obj) + ;; level-scoped labels so repeated nestings of one lineage don't clash + outer-frame (keyword (str "sync-" name "-outer-" level)) + inner-copy (keyword (str "sync-" name "-innercopy-" level)) + outer-comp (keyword (str "sync-" name "-outercomp-" level)) + file' (-> the-file + (tho/add-frame outer-frame {:name "OuterFrame"}) + (instantiate-inner-fn outer-frame inner-copy) + (thc/make-component outer-comp outer-frame)) + objects (:objects (thf/current-page file')) + inner-copy-id (thi/id inner-copy) + ;; images, inside the new inner copy, of the origin rect and origin instance + new-main-rect (descendant-corresponding-to objects inner-copy-id seek-rect-id) + ;; nested-head = the DEEPEST instance (image of the original component's + ;; instance), per spec — the switchable subinstance. Its parent is the + ;; swap-stable anchor for re-resolving it after a swap/switch replaces it. + nested-head (self-or-descendant-corresponding-to objects inner-copy-id seek-head-id) + nested-parent (:parent-id (get objects nested-head)) + outer-id (thi/id outer-comp) + outer-head-id (thi/id outer-frame)] + (-> situation + (tm/with-file file') + (update-component-obj + name + (fn [o] + (-> o + (assoc :main-component-id outer-id + :main-head outer-head-id + :main-rect new-main-rect + :nesting-count (inc level)) + (update :nesting-data conj + {:main-head outer-head-id + :nested-head nested-head + :nested-rect new-main-rect + :nested-head-parent nested-parent})))) + (tm/record-application op {:component name :level level :outer outer-id})))) + +(defrecord MakeNestedComponent [name] + tm/IOperation + (apply-to [this situation] + ;; nest a COPY of the lineage's own current component. Seek the FIXED deepest + ;; origin (:remote-rect / :remote-head): every level's copy refs back through it, + ;; so its image identifies this level's nested rect and deepest instance. (Variant + ;; nesting re-points :remote-* to the variant member, keeping this uniform.) + (let [obj (get-component-obj situation name)] + (nest-in-new-outer-component + situation name this + (:remote-rect obj) + (:remote-head obj) + (fn [file outer-frame inner-copy] + (let [inner-label (thi/label (:main-component-id obj))] + (thc/instantiate-component file inner-label inner-copy + {:parent-label outer-frame}))))))) + +(defn make-nested-component + "Wrap lineage `name`'s component in a NEW OUTER component (containing a copy of + it) and make the OUTER component its next-to-instantiate; advance :main to the + deeper rect while :remote stays fixed; append a `nesting-data` entry (with the + swap-stable :nested-head-parent) and bump nesting-count. ONE notion of nesting + (contain-outward). Apply repeatedly to deepen." + [name] + (tm/assign-id (->MakeNestedComponent name))) + +;; --------------------------------------------------------------------------- +;; instantiate-copy — instantiate the lineage's current component, track the copy +;; --------------------------------------------------------------------------- + +(defrecord InstantiateCopy [name] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + obj (get-component-obj situation name) + comp-id (:main-component-id obj) + comp-label (thi/label comp-id) + main-rect (:main-rect obj) + copy-head (keyword (str "sync-" name "-copyhead-" + (count (:copies obj)))) + file' (-> the-file + (thc/instantiate-component comp-label copy-head)) + objects (:objects (thf/current-page file')) + copy-id (thi/id copy-head) + ;; the rect inside the copy corresponding to the current main rect + copy-rect (descendant-corresponding-to objects copy-id main-rect)] + (-> situation + (tm/with-file file') + (update-component-obj + name + (fn [o] + (-> o + (assoc :copy-head copy-id :copy-rect copy-rect) + (update :copies (fnil conj []) {:copy-head copy-id :copy-rect copy-rect})))) + (tm/record-application this {:component name :copy copy-id}))))) + +(defn instantiate-copy + "Instantiate lineage `name`'s current component; track the copy's head and the + rect corresponding to its current main rect on the lineage object (as + :copy-head/:copy-rect, and appended to :copies). The copy rect is the shape + edits-to-copy target and assertions observe." + [name] + (tm/assign-id (->InstantiateCopy name))) + +;; --------------------------------------------------------------------------- +;; reset-copy-instance — reset overrides on the tracked copy instance +;; --------------------------------------------------------------------------- + +(defrecord ResetCopyInstance [name] + tm/IOperation + (apply-to [this situation] + ;; Production reset path (generate-reset-component), validation OFF — mirrors + ;; SyncFromLibrary. We call the generator directly rather than via + ;; tho/reset-overrides because that helper validates the file, which the + ;; assembled-context frontend store does not always satisfy; and it stays a + ;; SYNC-op (`apply-to` on the live store file) rather than an event-op because + ;; the real reset event transitively reads browser globals, so it cannot run + ;; headless. + (let [the-file (tm/file situation) + copy-id (:copy-head (get-component-obj situation name)) + page (thf/current-page the-file) + container (ctn/make-container page :page) + file-id (:id the-file) + changes (-> (pcb/empty-changes) + (cll/generate-reset-component + the-file {file-id the-file} container copy-id)) + file' (thf/apply-changes the-file changes :validate? false)] + (-> situation + (tm/with-file file') + (tm/record-application this {:component name :reset copy-id}))))) + +(defn reset-copy-instance + "Reset overrides on lineage `name`'s tracked copy instance (its :copy-head) — + discards the copy's own divergences so it reflects its main again." + [name] + (tm/assign-id (->ResetCopyInstance name))) + +;; --------------------------------------------------------------------------- +;; swap-component — replace a nested subinstance head with an instance of a +;; different component (the general "Swap component" action), in place. +;; +;; Targets lineage `name`'s nesting level `level` (its `nesting-data[level] +;; .nested-head`) and swaps it for lineage `target`'s component +;; (`:main-component-id`). Drives the production `generate-component-swap` +;; (validation off). The `apply-to` below is the pure realisation; the frontend +;; interpreter instead dispatches the real `dwl/component-swap` event, so the +;; watcher auto-propagates the swap. keep-touched? defaults +;; false (the general swap discards overrides; variant-switch would pass true). +;; +;; A swap REPLACES the head in place (Penpot keeps the head's id but rewrites it to +;; reference the new component and stamps a :swap-slot touched group). The level's +;; :nested-head-parent is unchanged, so assertions re-resolve parent -> current +;; head -> rect. +;; --------------------------------------------------------------------------- + +(defrecord SwapComponent [name level target keep-touched?] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + page (thf/current-page the-file) + objects (:objects page) + nested-head (:nested-head (lineage-nesting situation name level)) + shape (get objects nested-head) + target-id (lineage-component-id situation target) + libraries {(:id the-file) the-file} + orig-shapes (when keep-touched? + (cfh/get-children-with-self objects nested-head)) + [new-shape _parents changes] + (cll/generate-component-swap (pcb/empty-changes) + objects shape (:data the-file) page libraries + target-id 0 nil {} (boolean keep-touched?)) + [changes _] (if keep-touched? + (clv/generate-keep-touched changes new-shape shape orig-shapes + page libraries (:data the-file)) + [changes nil]) + file' (thf/apply-changes the-file changes :validate? false)] + (-> situation + ;; record the swapped-in head id on the level (its parent is unchanged) + (tm/with-file file') + (update-component-obj + name + (fn [o] (assoc-in o [:nesting-data level :swapped-head] (:id new-shape)))) + (tm/record-application this {:component name :level level + :target target :new-head (:id new-shape)}))))) + +(defn swap-component + "Swap lineage `name`'s nesting level `level` for lineage `target`'s component + (the general Swap-component action). `keep-touched?` (default false) preserves + the swapped instance's overrides (true = the variant-switch flavour)." + [name level target & {:keys [keep-touched?] :or {keep-touched? false}}] + (tm/assign-id (->SwapComponent name level target keep-touched?))) + +;; =========================================================================== +;; VARIANT operations (case M) — the variant-switch flavour of the swap sweep. +;; +;; A VARIANT SET is N peer components grouped in a container, distinguished by a +;; selector PROPERTY (here a single property at pos 0). Instantiation selects a +;; member by its property VALUE; a later `switch-variant` re-selects within the +;; set. Under the hood a switch is a keep-touched swap whose target is resolved +;; by property value (see `app.main.data.workspace.variants/variant-switch`), so +;; it routes through the SAME `generate-component-swap` as `swap-component` and +;; the watcher auto-propagates it across nesting levels exactly like a swap. +;; +;; OP SPLIT: only the SWITCH is a real workspace event (`variant-switch` has no +;; pure generator to call directly), so `SwitchVariant` is an EVENT-op: its +;; `apply-to` throws, and the frontend interpreter realises it by dispatching the +;; event. The container build and the variant nesting are SYNC-ops (test-helper +;; assembly + the shared nesting helper), applied via `apply-to` against the live +;; store file; they record the variant-set in vars. Event-ops do NOT run +;; `apply-to` (only sync-ops do), but the switch needs no bookkeeping: the +;; asserter re-resolves heads via the swap-stable :nested-head-parent +;; (`nested-head-of`/`level-rect`), exactly as case L does for swaps. +;; +;; Member SELECTOR: `make-variant-container` assigns each member an EXPLICIT property +;; value we choose (the `value` in its [value color] specs). A member is addressed by +;; that value in `make-nested-component-with-variant` / `switch-variant`, resolved via +;; the recorded variant-set in vars. +;; =========================================================================== + +(def ^:private variant-property-name + "The single selector property's name (these tests use one property)." + "Property 1") + +(defn variant-set + "Read the variant-set record `set-name` from the situation's vars (written by + `make-variant-container`): {:variant-id, :container-label, :members [{:value + :component-label :component-id} ...]}." + [situation set-name] + (or (tm/get-var situation [::variant-set set-name]) + (throw (ex-info "variant-set: no such set (was make-variant-container applied?)" + {:set set-name})))) + +(defn variant-member + "The member record of set `set-name` whose selector value is `value` ({:value + :component-label :component-id :root-label :rect-label :rect-id}). Throws if no + unique member matches — a structural invariant." + [situation set-name value] + (let [members (:members (variant-set situation set-name)) + matches (filter #(= value (:value %)) members)] + (case (count matches) + 1 (first matches) + 0 (throw (ex-info "variant-member: no member has that value" + {:set set-name :value value + :available (mapv :value members)})) + (throw (ex-info "variant-member: ambiguous value" + {:set set-name :value value :count (count matches)}))))) + +(defn variant-member-component-label + "The component label of set `set-name`'s member whose selector value is `value`." + [situation set-name value] + (:component-label (variant-member situation set-name value))) + +(defn variant-member-component-id + "The component id of set `set-name`'s member whose selector value is `value`." + [situation set-name value] + (:component-id (variant-member situation set-name value))) + +;; --------------------------------------------------------------------------- +;; make-variant-container — build a variant SET synchronously +;; +;; On our branch the only user-facing combine is the async `combine-as-variants` +;; (behind `penpot.createVariantFromComponents`), whose settlement + positional, +;; non-chosen property values make it a poor fit — and case M tests the SWITCH, not +;; the combine. So we assemble the container the way the variant test-helpers do +;; (`add-variant`): a container frame (:is-variant-container) whose children are the +;; member component roots, each carrying the shared :variant-id and an EXPLICIT +;; selector value we choose. This is synchronous (a sync-op, like create-component), +;; produces exactly the structure `variant-switch`/`find-variant-components` +;; consume, and lets us address members by a value of our choosing. +;; +;; `members` is a vector of [value color] — value = the selector value used by +;; `make-nested-component-with-variant` / `switch-variant`; color = the member's +;; rect fill (how the asserter tells members apart). The set is recorded in vars +;; under `name` (variant-id + per-member component labels/ids). +;; --------------------------------------------------------------------------- + +(defrecord MakeVariantContainer [name members] + tm/IOperation + (apply-to [this situation] + ;; Build the set exactly as the variant test-helpers' `add-variant` do (the + ;; proven idiom): a container frame (:is-variant-container), then each member's + ;; ROOT as a child carrying :variant-id + :variant-name, made into a component, + ;; then `update-component` stamps :variant-id + :variant-properties ON THE + ;; COMPONENT. (Routing this through add-simple-component's positional component + ;; params silently dropped :variant-id — hence the explicit two-step here.) + (let [container-label (keyword (str "sync-" name "-vcontainer")) + ;; create the container frame FIRST, THEN read its id (thi/id is a lookup + ;; that only resolves once the shape exists — reading it earlier yields nil). + base-file (-> (tm/file situation) + (ths/add-sample-shape container-label + :type :frame + :is-variant-container true)) + variant-id (thi/id container-label) + [file' member-recs] + (reduce + (fn [[file recs] [idx [value color]]] + (let [comp-label (keyword (str "sync-" name "-v" idx "-component")) + root-label (keyword (str "sync-" name "-v" idx "-root")) + rect-label (keyword (str "sync-" name "-v" idx "-rect")) + file2 (-> file + ;; member root as a child of the container, with variant id/name + ;; (mirrors add-variant's `add-sample-shape :type :frame ...`) + (ths/add-sample-shape root-label + :type :frame + :parent-label container-label + :variant-id variant-id + :variant-name value) + (ths/add-sample-shape rect-label + :parent-label root-label + :fills (ths/sample-fills-color :fill-color color)) + ;; make it a component, then stamp variant metadata ON THE COMPONENT + (thc/make-component comp-label root-label) + (thc/update-component + comp-label + {:variant-id variant-id + :variant-properties [{:name variant-property-name :value value}]}))] + [file2 (conj recs {:value value + :component-label comp-label + :component-id (thi/id comp-label) + :root-label root-label + :rect-label rect-label + :rect-id (thi/id rect-label)})])) + [base-file []] + (map-indexed vector members))] + (-> situation + (tm/with-file file') + (tm/set-var [::variant-set name] + {:variant-id variant-id + :container-label container-label + :members member-recs}) + (tm/record-application this {:variant-set name :variant-id variant-id + :count (count members)}))))) + +(defn make-variant-container + "Build a variant SET named `name` synchronously (test-helper assembly, like the + variant test-helpers' `add-variant`): a container holding N member components, + each with the shared variant-id and an EXPLICIT selector value. `members` is a + vector of [value color]. Members are addressed by `value` in + `make-nested-component-with-variant` / `switch-variant`; `color` distinguishes + them for the asserter." + [name members] + (tm/assign-id (->MakeVariantContainer name members))) + +;; --------------------------------------------------------------------------- +;; make-nested-component-with-variant — nest a chosen VARIANT, deeply +;; +;; Same contain-outward nesting as `make-nested-component` (shares +;; `nest-in-new-outer-component`), except the inner instance is a SPECIFIC variant +;; member (selected by property `value`) rather than the lineage's own component. +;; So every nesting level introduces a variant subinstance head — the switch +;; target. Sync-op (test-helper instantiation), like `make-nested-component`. +;; --------------------------------------------------------------------------- + +(defrecord MakeNestedComponentWithVariant [name set-name value] + tm/IOperation + (apply-to [this situation] + (let [member (variant-member situation set-name value) + root-id (thi/id (:root-label member)) + rect-id (:rect-id member)] + (-> situation + (nest-in-new-outer-component + name this + rect-id ; origin rect = the variant member's own rect + root-id ; origin head = the variant member's own root + (fn [file outer-frame inner-copy] + (thc/instantiate-component file (:component-label member) inner-copy + {:parent-label outer-frame}))) + ;; nesting a variant makes the MEMBER the new deepest origin: re-point the + ;; lineage's fixed origin so subsequent plain make-nested-component descends to the + ;; variant's image (its nested-head) at every level. + (update-component-obj name #(assoc % :remote-head root-id :remote-rect rect-id)))))) + +(defn make-nested-component-with-variant + "Nest (contain-outward, like `make-nested-component`) a SPECIFIC variant member of set + `set-name` — the member whose selector value is `value` — under lineage `name`, + advancing `name`'s nesting. Every level so nested gets a variant subinstance + head that `switch-variant` can later re-select. Apply repeatedly to deepen." + [name set-name value] + (tm/assign-id (->MakeNestedComponentWithVariant name set-name value))) + +;; --------------------------------------------------------------------------- +;; switch-variant — switch a variant copy head to a sibling member +;; +;; The variant-switch action: switch the variant copy head bound to `target` to the +;; sibling member whose selector property (pos 0) has `value`. FRONTEND-only: +;; dispatches the production `variants-switch`, which DISCOVERS the sibling by value +;; within the variant container and routes through `component-swap` with +;; keep-touched? true — so the watcher auto-propagates it across nesting levels +;; exactly like case L's swap. A switch REPLACES the head in place; the head's +;; :parent is unchanged, so assertions re-resolve parent -> current head -> rect. +;; +;; `target` is the head to switch, resolved by the standard `target-shape-id` (role +;; | label | (situation -> id) fn, e.g. `nested-head-of` for the stored nested head +;; at a level — exactly the head `swap-component` targets). So the op knows NOTHING +;; about nesting. +;; --------------------------------------------------------------------------- + +(defrecord SwitchVariant [target value] + tm/IOperation + (apply-to [_ _] + (throw (ex-info (str "switch-variant is an EVENT-op with no pure `apply-to` realisation: " + "it is the real `variants-switch` workspace event (which discovers the " + "sibling via the variant container), dispatched by the frontend " + "interpreter. See the VARIANT operations note above.") + {:type ::switch-variant-is-event-op})))) + +(defn switch-variant + "Switch the variant copy head bound to `target` to the sibling member whose + selector property (pos 0) has value `value`, via the real `variants-switch` + machinery (which discovers the sibling within the container). Propagates across + nesting exactly like case L's swap. `target` is resolved like any operation target + (role | label | (situation -> id) fn, e.g. `nested-head-of`), so the op is + structure-agnostic. FRONTEND-only." + [target value] + (tm/assign-id (->SwitchVariant target value))) + +;; nested-head-of — a (situation -> id) target fn for the nested head at a level, +;; supplied to `switch-variant` so the operation needs no nesting knowledge. +(defn nested-head-of + "Target fn: the subinstance head introduced at lineage `name`'s nesting level `i` + — the `:nested-head` stored in nesting-data for that level (exactly the head + `swap-component` targets). This is the variant instance to switch. For + `switch-variant`'s `target`." + [name i] + (fn [s] (:nested-head (lineage-nesting s name i)))) + + +;; --------------------------------------------------------------------------- +;; sync-from-library — pull a linked library's changes into the consuming file +;; +;; LOCALITY axis (case H): the main lives in a SEPARATE library file and the copy +;; in the consuming (current) file. Propagation here crosses a FILE boundary and +;; is NOT the in-file component watcher — on the real frontend it is the library +;; UPDATE action (the user accepts "library updated"), realised as +;; `sync-file file-id library-id`. This node models that action. +;; +;; The situation carries the consuming file as its primary `:file` and the library +;; as an AUXILIARY file (see `tm/with-aux-files`). The pure `apply-to` realisation +;; below builds the libraries map from BOTH and runs `generate-sync-file-changes` +;; for the whole library (asset-id nil), mirroring `test-sync-when-changing- +;; lower-remote`. The frontend interpreter instead dispatches the real +;; `sync-file` event (see its `op->events`). +;; --------------------------------------------------------------------------- + +(defrecord SyncFromLibrary [] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + file-id (:id the-file) + aux (tm/aux-files situation) + library-id (first (keys aux)) + libraries (assoc aux file-id the-file) + changes (-> (pcb/empty-changes) + (cll/generate-sync-file-changes + nil + :components + file-id + nil ; whole library (no single asset-id) + library-id + libraries + file-id)) + file' (thf/apply-changes the-file changes :validate? false)] + (-> situation + (tm/with-file file') + (tm/record-application this {:library-id library-id}))))) + +(defn sync-from-library + "Constructor for the cross-file library-sync node (case H). Takes no parameters; + the situation supplies the consuming file (primary) and the library (auxiliary)." + [] + (tm/assign-id (->SyncFromLibrary))) + + +;; --------------------------------------------------------------------------- +;; undo — reverse the immediately preceding operation(s) +;; +;; Undo is "just another operation node": the APP owns reversal; this node does +;; not snapshot-and-compare. It is an EVENT-op: the frontend interpreter +;; dispatches the real `dwu/undo` event, which pops the workspace undo stack +;; (maintained automatically by the prior operations' commits) and applies the +;; inverse changes. "Test undo everywhere" = append this node after any case; the +;; post-undo assertion is an ordinary condition about the resulting state. +;; +;; A pure `apply-to` realisation is NOT built: it would require every node to +;; stash its produced change value (the engine `:undo-changes`) into its record +;; so this node could apply the inverse — a retrofit across all nodes that no +;; case needs. It therefore throws loudly rather than pretending. +;; --------------------------------------------------------------------------- + +(defrecord Undo [] + tm/IOperation + (apply-to [_ _] + (throw (ex-info (str "Undo is an EVENT-op with no pure `apply-to` realisation (change " + "values are not stashed per-node). It is realised by the frontend " + "interpreter via the real undo event.") + {:type ::undo-is-event-op})))) + +(defn undo + "Constructor for the undo node (case I). Takes no parameters; on the frontend it + dispatches the real undo, reversing the immediately preceding operation(s)." + [] + (tm/assign-id (->Undo))) + + +;; --------------------------------------------------------------------------- +;; add-child — add a new shape into a (main) component, structurally +;; +;; This is STRUCTURAL modification (changes the tree shape), as opposed to +;; change-attr (attribute modification). It mirrors the established +;; comp-sync-test "add shape" pattern: create a free shape on the page, then +;; relocate it INTO the target parent via the production `generate-relocate` +;; path. Propagation then materializes a corresponding child in copies. +;; +;; `parent` is the binding name of the shape to add the new child under (e.g. +;; :main-root). `new-label` is the label assigned to the created shape, so a +;; condition can find the corresponding copy child by what was recorded. +;; --------------------------------------------------------------------------- + +(defrecord AddChild [parent new-label shape-params] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + ;; strict-presence on the parent (it must exist before we add under it) + parent-id (tm/resolve-shape-id situation parent) + ;; 1) create the free shape on the page (registers `new-label`) + file-1 (ths/add-sample-shape the-file new-label (or shape-params {})) + page (thf/current-page file-1) + new-id (thi/id new-label) + ;; 2) relocate it into the parent through the production change path + changes (cls/generate-relocate + (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + parent-id ; parent-id + 0 ; to-index + #{new-id}) ; ids to move + file' (thf/apply-changes file-1 changes)] + (-> situation + (tm/with-file file') + (tm/record-application this {:parent parent :new-label new-label}))))) + +(defn add-child + "Constructor for the structural add-child node: create a new shape (labeled + `new-label`) and relocate it under the shape bound to `parent`. Stamps a node + identity so the test can interrogate it." + [parent new-label & {:as shape-params}] + (tm/assign-id (->AddChild parent new-label shape-params))) + + +;; --------------------------------------------------------------------------- +;; remove-child — delete a shape from a (main) component, structurally +;; +;; Subtractive structural modification (twin of add-child). Mirrors the +;; established comp-sync-test "delete shape" pattern: `generate-delete-shapes` +;; on the named shape in the main. Propagation then removes the corresponding +;; child from clean copies. (Domain note from comp-sync-test: deleting from a +;; COPY would only hide; here we delete from the MAIN and propagate the removal.) +;; +;; `target` is the binding name (label) of the shape to remove from the main. +;; --------------------------------------------------------------------------- + +(defrecord RemoveChild [target] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + ;; strict-presence: the target must exist before removal + shape-id (tm/resolve-shape-id situation target) + page (thf/current-page the-file) + [_all-parents changes] + (cls/generate-delete-shapes (pcb/empty-changes) + the-file + page + (:objects page) + #{shape-id} + {:components-v2 true}) + file' (thf/apply-changes the-file changes)] + (-> situation + (tm/with-file file') + (tm/record-application this {:target target}))))) + +(defn remove-child + "Constructor for the structural remove-child node: delete the shape bound to + `target` from the main. Stamps a node identity." + [target] + (tm/assign-id (->RemoveChild target))) + + +;; --------------------------------------------------------------------------- +;; move-child — reorder an existing shape within a (main) component +;; +;; Structural modification where nothing is created or destroyed; only ORDER +;; changes, and that order should propagate to clean copies while :shape-ref +;; identity stays decoupled from position. Mirrors the established comp-sync-test +;; "move shape" pattern: `generate-relocate` of an EXISTING child to a new index +;; under the same parent. +;; +;; `target` is the binding name (label) of the existing main child to move; +;; `to-index` is its destination index under `parent`. +;; --------------------------------------------------------------------------- + +(defrecord MoveChild [target parent to-index] + tm/IOperation + (apply-to [this situation] + (let [the-file (tm/file situation) + shape-id (tm/resolve-shape-id situation target) + parent-id (tm/resolve-shape-id situation parent) + page (thf/current-page the-file) + changes (cls/generate-relocate + (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + parent-id + to-index + #{shape-id}) + file' (thf/apply-changes the-file changes)] + (-> situation + (tm/with-file file') + (tm/record-application this {:target target :parent parent :to-index to-index}))))) + +(defn move-child + "Constructor for the structural move-child node: relocate the existing shape + bound to `target` to `to-index` under the shape bound to `parent`. Stamps a + node identity." + [target parent to-index] + (tm/assign-id (->MoveChild target parent to-index))) + + +(defprotocol IStructuralCheck + "Inspection capability of a structural node: retrieve the shapes involved in its + own effect, so the test can state ref-integrity / placement assertions itself." + (added-shape [node situation] + "The shape this node added to the main (live, from the current file).") + (materialized-instance-child [node situation container-shape] + "The child of `container-shape` that is the materialized instance of this + node's added shape (i.e. whose :shape-ref points at it), or nil. Retrieval + only — the test asserts the relationship.")) + +(extend-type AddChild + IStructuralCheck + (added-shape [node situation] + (ths/get-shape (tm/file situation) (:new-label node))) + (materialized-instance-child [node situation container-shape] + (let [the-file (tm/file situation) + added-id (thi/id (:new-label node))] + (->> (:shapes container-shape) + (map #(ths/get-shape-by-id the-file %)) + (d/seek #(= added-id (:shape-ref %))))))) diff --git a/frontend/test/frontend_tests/composable_tests/comp/setups.cljs b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs new file mode 100644 index 0000000000..056da9c450 --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs @@ -0,0 +1,203 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.composable-tests.comp.setups + "Component-specific setups for the test model: named functions that build an + in-memory file value for a particular component configuration. They are the + subject-specific counterpart to the generic engine; a 'simple component with + a copy' is an entirely component-shaped configuration, so it lives behind the + `comp` boundary. See `mem:frontend/composable-component-tests`. + + Setups are kept as plain functions (not data): structure resists + declarativization and forcing it here has the worst cost/benefit. Each returns + a *situation* (file + ROLE bindings — meaningful named objects of the + configuration, e.g. :main-instance, :copy-instance), which the test retrieves + via the accessors below and asserts on directly. + + For these one-child-per-instance configurations the meaningful objects are the + child shapes that participate in propagation, so :main-instance is the main's + child and :copy-instance is the copy's child (this makes a test read like + `(has-attr? change-attr (copy-instance situation))`)." + (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [frontend-tests.composable-tests.core :as tm])) + +(defn simple-component-with-copy + "A situation with a simple component (root + one child) and one clean copy. + Shape labels: :main-root :main-child :copy-root :copy-child. Roles: + :main-instance -> the main child, :copy-instance -> the copy child." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy + :component1 :main-root :main-child :copy-root + :copy-root-params {:children-labels [:copy-child]}))] + (tm/make-situation file {:main-instance :main-child + :copy-instance :copy-child + :main-root :main-root + :copy-root :copy-root}))) + + +(defn empty-situation + "A situation with an empty file and NO roles — the starting point for scenarios + that build their own configuration via operations (e.g. `create-component`, + `make-nested-component`, `instantiate-copy`). Use as the `:setup` when the first + operation creates the component." + [] + (tm/make-situation (thf/sample-file :file1))) + +(defn simple-component-with-labeled-copy + "Like `simple-component-with-copy`, but the main child starts with a known fill + (so 'value stayed' is distinguishable from coincidence). Same labels and roles." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy + :component1 :main-root :main-child :copy-root + :main-child-params {:fills (ths/sample-fills-color :fill-color "#abcdef")} + :copy-root-params {:children-labels [:copy-child]}))] + (tm/make-situation file {:main-instance :main-child + :copy-instance :copy-child + :main-root :main-root + :copy-root :copy-root}))) + +(defn main-instance + "The main child shape of the configuration (role :main-instance), live." + [situation] + (tm/role-shape situation :main-instance)) + +(defn copy-instance + "The copy child shape of the configuration (role :copy-instance), live." + [situation] + (tm/role-shape situation :copy-instance)) + +(defn main-root + "The main component root shape (role :main-root), live." + [situation] + (tm/role-shape situation :main-root)) + +(defn copy-root + "The copy root shape (role :copy-root), live." + [situation] + (tm/role-shape situation :copy-root)) + + +(defn component-with-many-children + "A situation with a component whose main has three children and one clean copy. + Shape labels: :main-root, :main-child1/2/3, :copy-root, :copy-child1/2/3. + Roles: :main-root, :copy-root, and :copy-child-1/2/3 -> the copy's children + (so a test can assert their post-operation order in the copy root)." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-component-with-many-children-and-copy + :component1 + :main-root + [:main-child1 :main-child2 :main-child3] + :copy-root + :copy-root-params {:children-labels [:copy-child1 :copy-child2 :copy-child3]}))] + (tm/make-situation file {:main-root :main-root + :copy-root :copy-root + :copy-child-1 :copy-child1 + :copy-child-2 :copy-child2 + :copy-child-3 :copy-child3}))) + +(defn copy-child + "The copy child shape for 1-based index `n` (role :copy-child-n), live. Lets a + test retrieve specific copy children to assert their order/identity." + [situation n] + (tm/role-shape situation (keyword (str "copy-child-" n)))) + + +(defn nested-component-with-copy + "A situation with a NESTED component configuration (depth axis): component2's + main contains a nested instance of component1 (`:main2-nested-head`); a clean + copy of component2 exists, so the copy contains a copy of that nested instance + whose child (`:copy2-child`) reaches the main through a :shape-ref CHAIN. + + This is the depth-1 analogue of the simple-with-copy setup: editing + `:main2-nested-head` (the nested instance inside component2's main) and + propagating component2 must reach `:copy2-child` — the same attribute- + propagation property as the flat case, one level deeper. (Editing the + INNERMOST :main1-child and propagating only :component1 would instead be a + CHAINED propagation across two component levels — a different property; not + this setup.) + + Shape labels from the helper: :main1-root/:main1-child, :main2-root, + :main2-nested-head, :copy2-root, and :copy2-child (the deep copy child, labeled + via the helper's children-labels). Roles: :main-instance -> :main2-nested-head, + :copy-instance -> :copy2-child, plus :main2-root / :copy-root for structure." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-nested-component-with-copy + :component1 :main1-root :main1-child + :component2 :main2-root :main2-nested-head + :copy2-root + :copy2-root-params {:children-labels [:copy2-child]}))] + (tm/make-situation file {:main-instance :main2-nested-head + :copy-instance :copy2-child + :main2-root :main2-root + :copy-root :copy2-root}))) + + +(defn- change-main-child-fill + "Apply `fill-color` to the library's :main-child via the production change path + (mirrors how a real edit to the main would have changed the library), returning + the updated library file value. Used to make the library main DIVERGE from the + already-instantiated copy, so a subsequent cross-file sync has something to pull." + [library fill-color] + (let [page (thf/current-page library) + main-id (thi/id :main-child) + changes (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) + #{main-id} + (fn [shape] (assoc shape :fills (ths/sample-fills-color :fill-color fill-color))) + (:objects page) + {})] + (thf/apply-changes library changes))) + +(defn cross-file-component-with-copy + "LOCALITY axis (case H): a component whose main lives in a SEPARATE, shared + LIBRARY file, and a clean copy that lives in a CONSUMING file. The library main + has since DIVERGED from the copy (its :main-child now carries `new-fill`, while + the copy still carries the original `#abcdef`), modelling 'the library was + changed elsewhere'. Propagating that change to the copy is therefore a CROSS- + FILE sync (the library-update flow), not the in-file watcher. + + The returned situation's PRIMARY file is the CONSUMING file (the current file, + where the copy lives and which the copy roles resolve against); the library + travels as an AUXILIARY file (see `tm/with-aux-files`) so an interpreter can + install it alongside (e.g. into the frontend store's `:files`) and run the + cross-file sync. Roles: :copy-instance -> the copy child, :copy-root -> the copy + root (both in the consuming file). `new-fill` should match the value the test + uses for its expected-value descriptor. + + Labels: in the LIBRARY — :component1, :main-root, :main-child; in the CONSUMING + file — :copy-root, :copy-child." + [new-fill] + (let [library (-> (thf/sample-file :library :is-shared true) + (tho/add-simple-component + :component1 :main-root :main-child + :child-params {:fills (ths/sample-fills-color :fill-color "#abcdef")})) + ;; instantiate the copy in the consuming file FIRST (copy gets the old fill) + file (-> (thf/sample-file :file) + (thc/instantiate-component :component1 :copy-root + :library library + :children-labels [:copy-child])) + ;; THEN diverge the library main (copy is now stale) + library' (change-main-child-fill library new-fill)] + (-> (tm/make-situation file {:copy-instance :copy-child + :copy-root :copy-root}) + (tm/with-aux-files {(:id library') library'})))) + +(defn library-id + "The id of the library file in a cross-file situation (the single auxiliary + file). Used by the cross-file sync operation." + [situation] + (-> situation tm/aux-files keys first)) diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs new file mode 100644 index 0000000000..79a32492a4 --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -0,0 +1,346 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.composable-tests.comp.sync-test + "Component-behaviour cases authored on the composable test model, run against + the REAL app (frontend interpreter). In-file propagation is AUTOMATIC: a case + contains ONLY the user edit(s) (no propagate op); the app's component-change + watcher syncs copies on its own, and that is what we assert. + + The cases (see `mem:frontend/composable-component-tests` for details): + B — an override on a copy child survives a later main change (touched gate). + C — a sweep (one-of) over several attribute changes, each auto-propagating. + D — a shape added to the main is structurally auto-propagated (ref-integrity). + E — a middle shape removed from the main; survivors keep order. + F — reordering a shape in the main; order auto-propagates, identity preserved. + H — locality: a library main's change reaches the consuming file's copy on + the explicit library sync (cross-file propagation). + I — undo reverses an edit AND its auto-propagation. + K — the synchronisation sweep: depth x edit-targets, with override-precedence + and reset checkpoints. + L — the swap sweep: swaps at any subset of nesting levels. + M — the variant-switch sweep: case L with variant switches. + + Async: each deftest uses `t/async`; `ftm/check` drives the store and calls + `done` when finished." + (:require + [app.common.types.component :as ctk] + [app.common.types.shape-tree :as ctst] + [cljs.test :as t :include-macros true] + [frontend-tests.composable-tests.comp.nodes :as n] + [frontend-tests.composable-tests.comp.setups :as setup] + [frontend-tests.composable-tests.core :as tm] + [frontend-tests.composable-tests.interpreter :as ftm])) + +(def ^:private red "#ff0000") +(def ^:private green "#00ff00") + +;; Disable thumbnail rendering for the duration of each (async) test: the +;; propagation watcher schedules thumbnail renders that reach `window`, absent in +;; the headless runner. `:each` `:after` runs only after the test's `done` fires +;; (same guarantee the wasm-mock fixtures rely on), so the no-op covers the whole +;; async lifetime and is scoped to THIS namespace. See ftm/install-thumbnail-noop!. +(t/use-fixtures :each + {:before ftm/install-thumbnail-noop! + :after ftm/restore-thumbnail!}) + +;; (Cases A, G, J retired — subsumed by case K's depth-swept propagation scenario.) + +(t/deftest case-b-copy-override-survives-later-main-change + (t/async + done + (let [override (n/change-attr :copy-child :fills green)] ; touch the copy first + (ftm/check + done + {:setup setup/simple-component-with-labeled-copy + ;; override the copy, then change the main; the watcher auto-syncs after + ;; each edit. The override must survive (touched-flag gate). + :operation (tm/in-sequence + [override + (n/change-attr :main-child :fills red)])} + (fn [situation] + (let [copy-child (setup/copy-instance situation)] + (t/is (n/has-attr? override copy-child)) + (t/is (contains? (:touched copy-child) :fill-group)) + (t/is (some? (:shape-ref copy-child))))))))) + +(t/deftest case-c-attribute-sweep-auto-propagates-to-clean-copy + (t/async + done + (let [sweep (tm/one-of + [(n/change-attr :main-child :fills red) + (n/change-attr :main-child :opacity 0.5)])] + (ftm/check + done + {:setup setup/simple-component-with-copy + :operation (tm/in-sequence [sweep])} + (fn [situation] + (let [chosen (tm/get-choice situation sweep)] + (t/is (some? chosen)) + (t/is (n/has-attr? chosen (setup/copy-instance situation))))))))) + +(t/deftest case-d-add-shape-to-main-auto-propagates-to-clean-copy + (t/async + done + (let [add (n/add-child :main-root :main-child-2)] + (ftm/check + done + {:setup setup/simple-component-with-labeled-copy + :operation (tm/in-sequence [add])} + (fn [situation] + (let [copy-root (setup/copy-root situation) + main-new (n/added-shape add situation) + copy-new (n/materialized-instance-child add situation copy-root)] + (t/is (some? copy-new)) + (t/is (ctk/is-main-of? main-new copy-new)) + (t/is (ctst/parent-of? copy-root copy-new)) + (t/is (nil? (:touched copy-new))))))))) + +(t/deftest case-e-remove-shape-from-main-auto-propagates-to-clean-copy + (t/async + done + (let [removal (n/remove-child :main-child2)] + (ftm/check + done + {:setup setup/component-with-many-children + :operation (tm/in-sequence [removal])} + (fn [situation] + (let [copy-root (setup/copy-root situation) + order (vec (:shapes copy-root)) + c1 (setup/copy-child situation 1) + c3 (setup/copy-child situation 3)] + (t/is (= 2 (count order))) + (t/is (= (nth order 0) (:id c1))) + (t/is (= (nth order 1) (:id c3))) + (t/is (some? (:shape-ref c1))) + (t/is (some? (:shape-ref c3))) + (t/is (nil? (:touched c1))) + (t/is (nil? (:touched c3))) + (t/is (nil? (:touched copy-root))))))))) + +(t/deftest case-f-move-shape-in-main-auto-propagates-order-to-clean-copy + (t/async + done + (let [move (n/move-child :main-child1 :main-root 2)] + (ftm/check + done + {:setup setup/component-with-many-children + :operation (tm/in-sequence [move])} + (fn [situation] + (let [copy-root (setup/copy-root situation) + order (vec (:shapes copy-root)) + c1 (setup/copy-child situation 1) + c2 (setup/copy-child situation 2) + c3 (setup/copy-child situation 3)] + (t/is (= (nth order 0) (:id c2))) + (t/is (= (nth order 1) (:id c1))) + (t/is (= (nth order 2) (:id c3))) + (t/is (some? (:shape-ref c1))) + (t/is (some? (:shape-ref c2))) + (t/is (some? (:shape-ref c3))) + (t/is (nil? (:touched c1))) + (t/is (nil? (:touched c2))) + (t/is (nil? (:touched c3))))))))) + +(t/deftest case-i-undo-reverts-edit-and-its-auto-propagation + (t/async + done + ;; UNDO axis (case I), built on case A: change the main (which auto-propagates + ;; to the clean copy), then UNDO. A single undo reverses the whole logical + ;; action — the edit AND its propagation — so the copy returns to baseline and + ;; is left untouched. Undo is just another op (`n/undo`); the engine owns + ;; reversal (frontend realisation dispatches the real `dwu/undo`). + (let [original "#abcdef" ; the labeled setup's starting fill + baseline (n/change-attr :main-child :fills original) ; expected-VALUE descriptor + change (n/change-attr :main-child :fills red)] + (ftm/check + done + {:setup setup/simple-component-with-labeled-copy + :operation (tm/in-sequence [change (n/undo)])} + (fn [situation] + (let [copy (setup/copy-instance situation) + main (setup/main-instance situation)] + ;; the edit was reversed on the main … + (t/is (n/has-attr? baseline main)) + ;; … and on the copy (the propagation was reversed too) … + (t/is (n/has-attr? baseline copy)) + ;; … leaving the copy clean. + (t/is (nil? (:touched copy))))))))) + +(t/deftest case-h-library-change-propagates-across-file-boundary-on-sync + (t/async + done + ;; LOCALITY axis: the main lives in a linked LIBRARY, the copy in the consuming + ;; (current) file. The library main has diverged (setup applied `red` to it, + ;; leaving the copy stale). Unlike the in-file cases, the watcher does NOT cross the + ;; file boundary; the real app propagates via the library-UPDATE action, so the + ;; transformation is `sync-from-library` (dispatches the real `sync-file`). + ;; `expected` is only an expected-VALUE descriptor (its target is irrelevant; + ;; `has-attr?` uses just attr+value), so the asserter reads exactly like case A. + (let [expected (n/change-attr :main-child :fills red)] + (ftm/check + done + {:setup #(setup/cross-file-component-with-copy red) + :operation (tm/in-sequence [(n/sync-from-library)])} + (fn [situation] + (t/is (n/has-attr? expected (setup/copy-instance situation)))))))) + +(def ^:private blue "#0000ff") + +(t/deftest case-k-synchronisation-scenarios + (t/async + done + ;; CONSOLIDATED SCENARIO SWEEP — one composition standing in for many cases. + ;; Built from the sync-scenario operations on an empty situation. It sweeps: + ;; - DEPTH 0/1/2 via two independent `(optional (make-nested-component ...))` + ;; - which EDITS were made via three independent `(optional change-*)` + ;; and asserts, at INLINE checkpoints, the override-precedence and reset rules. + ;; The change targets are the tracked ROLES (:remote/:main/:copy-child-rect), so + ;; the same composition holds at any depth. Propagation is AUTOMATIC (no + ;; propagate op). Subsumes the flat/nested propagation cases (A/G/J). + (let [m "main" + change-remote (n/change-property (n/remote-rect-of m) :fills red) + change-main (n/change-property (n/main-rect-of m) :fills green) + change-copy (n/change-property (n/copy-rect-of m) :fills blue) + copy-rect (fn [s] (n/lineage-copy-rect s m)) + ;; precedence at the copy: copy override wins; else main; else remote. + expected-after-edits + (fn [s] + (cond + (tm/applied? s change-copy) (n/has-property-of change-copy (tm/shape-by-id s (copy-rect s))) + (tm/applied? s change-main) (n/has-property-of change-main (tm/shape-by-id s (copy-rect s))) + (tm/applied? s change-remote) (n/has-property-of change-remote (tm/shape-by-id s (copy-rect s))) + :else true))] + (ftm/check + done + {:setup setup/empty-situation + :operation (tm/in-sequence + [(n/create-component m red) + ;; depth sweep: two independent optionals give depths 0/1/2 + ;; (depth 1 appears twice — harmless) without nesting a + ;; Sequence inside an optional. + (tm/optional (n/make-nested-component m)) + (tm/optional (n/make-nested-component m)) + (n/instantiate-copy m) + (tm/optional change-remote) + (tm/optional change-main) + (tm/optional change-copy) + (tm/test-that (fn [s] (t/is (expected-after-edits s)))) + ;; force a copy override, observe it wins, then reset it away + change-copy + (tm/test-that + (fn [s] (t/is (n/has-property-of change-copy (tm/shape-by-id s (copy-rect s)))))) + (n/reset-copy-instance m) + (tm/test-that + (fn [s] + ;; after reset: main's value if main changed, else remote's + ;; if remote changed (else the original — not asserted). + (cond + (tm/applied? s change-main) (t/is (n/has-property-of change-main (tm/shape-by-id s (copy-rect s)))) + (tm/applied? s change-remote) (t/is (n/has-property-of change-remote (tm/shape-by-id s (copy-rect s)))) + :else true)))])})))) + +(defn- level-color + "The fill colour of the rect currently at lineage `name`'s nesting level `i`." + [s name i] + (-> (tm/shape-by-id s (n/level-rect s name i)) :fills first :fill-color)) + +(def ^:private base-color "#aaaaaa") +(def ^:private swap-colors ["#ff0000" "#00ff00" "#0000ff"]) ; level 0/1/2 targets + +(t/deftest case-l-swap-scenarios + (t/async + done + ;; SWAP SWEEP — build a 3-level nesting, then OPTIONALLY swap the nested + ;; component at each level for a differently-coloured one, and assert the colour + ;; that surfaces at every level. A swap at level i propagates (automatically, via + ;; the watcher) to level i and every OUTER (higher-index) level, until a swap at + ;; a higher level overrides it. So the colour at level i is the swap at the + ;; HIGHEST index j <= i that was applied, else the base colour. (Generalises the + ;; "single swap in copy" diagram across which levels are swapped.) + (let [m "main" + targets ["s0" "s1" "s2"] + ;; swap[i] swaps level i's nested component for target lineage i (color i) + swaps (mapv (fn [i] (n/swap-component m i (nth targets i))) (range 3)) + expected-at + (fn [s i] + ;; the colour of the applied swap at the highest j <= i, else base + (or (some (fn [j] (when (tm/applied? s (nth swaps j)) (nth swap-colors j))) + (range i -1 -1)) + base-color))] + (ftm/check + done + {:setup setup/empty-situation + :operation (tm/in-sequence + (concat + [(n/create-component m base-color)] + ;; a target lineage per level + (map-indexed (fn [i c] (n/create-component (nth targets i) c)) swap-colors) + [(n/make-nested-component m) (n/make-nested-component m) (n/make-nested-component m)] + ;; optionally swap at each level + (map (fn [sw] (tm/optional sw)) swaps) + [(tm/test-that + (fn [s] + (doseq [i (range 3)] + (t/is (= (expected-at s i) (level-color s m i)) + (str "level " i)))))]))})))) + +(t/deftest case-m-variant-switch-scenarios + (t/async + done + ;; VARIANT-SWITCH SWEEP — the variant-switch flavour of case L. Build a variant + ;; SET of peer members and nest the base member at EVERY level (so each level has + ;; a variant head, just as case L's swap target exists at every level). Then + ;; OPTIONALLY switch the variant head at each level to a differently-coloured + ;; sibling and assert the colour that surfaces at every level. A variant switch + ;; routes through the SAME component-swap as case L (keep-touched? true), so the + ;; watcher auto-propagates it identically: the colour at level i is the switch at + ;; the HIGHEST index j <= i that was applied, else the base member's colour. Same + ;; asserter as L — the test of "a variant switch propagates like a swap". + (let [m "main" ; the nesting lineage (holds the nesting-data) + vset "vset" ; the variant set + vals ["v0" "v1" "v2" "v3"] + colors (into [base-color] swap-colors) ; base + sibling colours + ;; switch[i] switches level i's variant head to member i+1 (colour i). The + ;; single variant instance has a corresponding (switchable) head at every + ;; level — `nested-head` IS the deepest instance there — so we can switch at + ;; ANY level, exactly like case L's per-level swap. + switches (mapv (fn [i] (n/switch-variant (n/nested-head-of m i) (nth vals (inc i)))) + (range 3)) + expected-at + (fn [s i] + ;; same precedence as case L: the colour at level i is the switch at the + ;; HIGHEST index j <= i that was applied (a switch propagates outward), + ;; else base. + (or (some (fn [j] (when (tm/applied? s (nth switches j)) (nth swap-colors j))) + (range i -1 -1)) + base-color))] + (ftm/check + done + {:setup setup/empty-situation + :operation (tm/in-sequence + (concat + ;; the nesting lineage, and the variant set (members = [value color]) + [(n/create-component m base-color) + (n/make-variant-container vset (mapv vector vals colors))] + ;; introduce the variant instance ONCE (innermost), then wrap it + ;; with plain nesting so each outer level CONTAINS the one below + ;; (progressive nesting, like case L's make-nested-component x3). nested-head + ;; at every level is then the variant (the deepest instance), so a + ;; switch at level i targets it and propagates OUTWARD via the + ;; watcher — exactly like case L's swap. + [(n/make-nested-component-with-variant m vset "v0") + (n/make-nested-component m) + (n/make-nested-component m)] + ;; optionally switch each level's variant head to its target sibling + (map (fn [sw] (tm/optional sw)) switches) + [(tm/test-that + (fn [s] + (doseq [i (range 3)] + (t/is (= (expected-at s i) (level-color s m i)) + (str "level " i)))))]))})))) + + diff --git a/frontend/test/frontend_tests/composable_tests/core.cljs b/frontend/test/frontend_tests/composable_tests/core.cljs new file mode 100644 index 0000000000..6c1221658b --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/core.cljs @@ -0,0 +1,611 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.composable-tests.core + "The domain-agnostic ENGINE of the composable test model (see + `mem:frontend/composable-component-tests`): + + - a `situation` carrying the in-memory file value, named ROLE bindings, + a `:vars` map, and an ordered record of applied operations, + - node identity (`assign-id`) + the applied-log (`record-application`, + `applied?`, `describe-applied`), + - strict-presence lookup that throws a diagnostic error on a missing name, + - the `IOperation` protocol (single method `apply-to`) and the + `IEnumerable` protocol behind variant enumeration, + - the composition operators `in-sequence`, `one-of`, `optional`, `skip`, + and the inline-assertion operation `test-that`, + - the enumerating pure runners `run-variant` / `run-all` (the frontend + interpreter in `frontend-tests.composable-tests.interpreter` is the + event-dispatching counterpart and the test-facing entry point). + + No Penpot domain terms live here; the component-specific operations sit + behind the `comp` boundary (`frontend-tests.composable-tests.comp.*`). + + Shape references use the existing label->uuid system (`app.common.test-helpers.ids-map`) + rather than a parallel binding map: a binding name in an operation IS a + shape label, resolved through `thi/id`. The situation adds the applied-log and + the strict-presence discipline on top of that substrate." + (:refer-clojure :exclude [apply-to]) + (:require + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.types.shape-tree :as ctst] + [app.common.uuid :as uuid] + [clojure.string :as str])) + +;; --------------------------------------------------------------------------- +;; Situation +;; +;; A situation wraps the in-memory Penpot file value together with the record of +;; what has been applied to it. The environment of *shape* bindings is the +;; global label->uuid map (reset per variant by the runners), so the situation +;; does not duplicate it; it only adds: +;; :file - the current in-memory file value +;; :applied-log - vector of application records, in application order +;; --------------------------------------------------------------------------- + +;; resolve-shape is defined below (strict-presence section) but referenced by +;; role-shape above it; declare it so the forward reference resolves at load. +(declare resolve-shape) +(declare resolve-shape-id) + +(defn make-situation + "Create a situation from an initial in-memory file value, optionally with role + bindings. Roles are meaningful named objects of the configuration the setup + built (e.g. :main-instance, :copy-instance), passed as shape *labels*. The + labels are resolved to ids IMMEDIATELY (at construction, while the labels are + freshly valid) and the situation stores the resolved ids. This is essential: + the global label->id map is time-varying and shared, so resolving a role label + later (e.g. in another enumerated variant's run) would be unsound; capturing + the id at setup time makes each situation resolve its roles against the file it + was built with, independent of the global map's later state. + + The situation also carries: + :applied - ordered vector of operation ids, in application order + (so the full sequence is always recoverable from the situation) + :node-data - map of operation-id -> the record that operation + wrote about its own application (keyed by NODE IDENTITY, not by + a node-kind tag), so each node retrieves its own record." + ([file] (make-situation file {})) + ([file roles] + {:file file + ;; resolve role labels to ids now, while the labels are valid + :roles (into {} (map (fn [[k label]] [k (thi/id label)])) roles) + :applied [] + :node-data {}})) + +(defn file + "The current in-memory file value of the situation." + [situation] + (:file situation)) + +(defn with-file + "Return a situation with its file value replaced (the threaded result of + applying a transformation through the production change pipeline)." + [situation file'] + (assoc situation :file file')) + + +(defn with-aux-files + "Return a situation carrying AUXILIARY files (a map of file-id -> file value) + beyond the primary `:file`. Used by cross-file configurations (e.g. a copy in + the primary/current file whose main lives in a linked LIBRARY file): the + primary `:file` stays the current file the role accessors resolve against, + while the auxiliary files travel alongside so an interpreter can install them + too (e.g. into the frontend store's `:files`, so cross-file sync can run). + The primary file is NOT included here." + [situation files-by-id] + (assoc situation :aux-files files-by-id)) + +(defn aux-files + "The situation's auxiliary files (map of file-id -> file), or an empty map. + See `with-aux-files`." + [situation] + (get situation :aux-files {})) + +;; ----- Node identity (central, no inheritance) ----------------------------- +;; +;; Records are value-equal in Clojure, so two structurally-identical nodes would +;; collide as map keys. Each node therefore carries an explicit unique id under +;; the reserved key ::id, stamped at construction by `assign-id` (the single +;; thing every node constructor must call). The id machinery — stamping, and the +;; record/retrieve-by-id pair below — is implemented ONCE here and shared by all +;; nodes as plain functions; nodes never reimplement it, they just pass `this`. + +(defn assign-id + "Stamp a fresh unique id onto a node record (reserved key ::id). Every node + constructor's final step. Returns the node." + [node] + (assoc node ::id (uuid/next))) + +(defn node-id + "The unique id of a node." + [node] + (::id node)) + +;; ----- Self-description, keyed by node identity ---------------------------- + +(defn- node-kind + "A short readable kind name for a node, derived from its record type (no + per-node boilerplate, faithful to the actual type). E.g. a ChangeAttr record + yields \"change-attr\"." + [node] + (let [cls (or (some-> node type .-name (str/split #"\.") last) + "node") + ;; CamelCase -> kebab-case + kebab (-> cls + (str/replace #"([a-z0-9])([A-Z])" "$1-$2") + (str/lower-case))] + kebab)) + +(defn record-application + "Record what an operation `node` did into `situation`, keyed by the node's + identity, and append its id to the ordered :applied sequence. `record` is an + arbitrary map describing the application; the node retrieves it later via + `node-data` by passing itself. The node's kind (from its type) is merged in + under ::kind so the application can be rendered (see `describe-applied`) + without the node needing to name itself. Both consumers (inspection methods, + undo) read through node identity and ignore the extra ::kind key." + [situation node record] + (-> situation + (update :applied conj (node-id node)) + (assoc-in [:node-data (node-id node)] (assoc record ::kind (node-kind node))))) + +(defn node-data + "The record that `node` wrote about its own application in `situation`, or nil + if it has not been applied. Keyed by node identity." + [situation node] + (get-in situation [:node-data (node-id node)])) + +(defn applied-ids + "The ordered vector of operation ids applied to the situation (the full + sequence, recoverable from the situation)." + [situation] + (:applied situation)) + +(defn applied? + "Whether `operation` ran in this situation. Identity-based: true iff this exact + operation node (by its `::id`) recorded an application. Works through `optional` + / `one-of`: a chosen alternative records itself under its own identity, so + querying with the raw operation answers correctly. Bind an operation to a value + ONCE and reuse it (in the composition AND in any `Test` that queries it), so the + id you ask about is the id that ran." + [situation operation] + (contains? (:node-data situation) (node-id operation))) + + +(defn- render-application + "Render one (non-choice) application record as a short human-readable string. + Content-agnostic: uses ::kind and the record's own fields." + [record] + (let [kind (::kind record) + args (dissoc record ::kind)] + (str kind (when (seq args) (str " " (pr-str args)))))) + +(defn describe-applied + "A human-readable, ordered transcript of the operations that produced + `situation` — one line per application, in order. Built purely from the + situation's recorded :applied ids and :node-data records (each node's own + self-description), so it is faithful to what actually ran, including the branch + a one-of chose. Used to label failures with the exact sequence at hand." + [situation] + (let [node-data (:node-data situation)] + (->> (:applied situation) + (keep (fn [id] + (let [record (get node-data id)] + (if (contains? record :chosen) + ;; a one-of's choice record: if the chosen node recorded its + ;; own application, its own line (also in :applied) already + ;; tells the story — emit nothing to avoid a duplicate line. + ;; Only a non-recording choice (a skipped `optional`) is + ;; rendered here, so a skip stays visible in the transcript. + (let [chosen (:chosen record)] + (when-not (contains? node-data (::id chosen)) + (str "one-of -> " (render-application {::kind (node-kind chosen)})))) + (render-application record))))) + (str/join "\n ")))) + +;; ----- Role accessors ------------------------------------------------------ +;; +;; A setup records role bindings (meaningful named objects of the configuration) +;; as shape labels. `role-shape` resolves a role to the live shape in the current +;; file, with strict presence: an absent role throws diagnostically (never nil). +;; Specific named accessors (e.g. copy-instance) are thin wrappers, so tests read +;; `(copy-instance situation)` rather than knowing the role key. + +(defn role-shape + "Resolve role `role-key` to the live shape in the situation's current file, + using the id captured at setup time. Throws a diagnostic error if the role is + not bound, or if its captured shape is no longer present in the current page + (e.g. it was deleted) — never returns nil silently." + [situation role-key] + (let [id (get-in situation [:roles role-key]) + page (thf/current-page (file situation))] + (when (nil? id) + (throw (ex-info (str "Unbound role: " (pr-str role-key) + ". Roles present: " (pr-str (vec (keys (:roles situation))))) + {:type ::unbound-role + :role role-key}))) + (let [shape (ctst/get-shape page id)] + (when (nil? shape) + (throw (ex-info (str "Role " (pr-str role-key) " resolves to a shape no longer " + "present in the current page (id " (pr-str id) ").") + {:type ::role-shape-absent + :role role-key + :id id}))) + shape))) + + +(defn has-role? + "Whether `role-key` is currently bound in the situation." + [situation role-key] + (contains? (:roles situation) role-key)) + +(defn shape-by-id + "The live shape with `id` in the situation's current page (nil if absent). For + reading a shape whose id is held outside the role map — e.g. a field of a + tracked component object." + [situation id] + (ctst/get-shape (thf/current-page (file situation)) id)) + +(defn rebind-role + "Re-point `role-key` to the current id of shape `label` (resolved via the global + label map now, while it is valid). Used by state-building operations that move + the configuration (e.g. `make-nested-component`) and must update where a role points so + that role-targeted operations keep acting on the intended shape." + [situation role-key label] + (assoc-in situation [:roles role-key] (thi/id label))) + +(defn rebind-role-id + "Like `rebind-role`, but re-points `role-key` to a shape `id` directly (when the + operation discovered the shape by id, e.g. by following a :shape-ref chain, + rather than by label)." + [situation role-key id] + (assoc-in situation [:roles role-key] id)) + + +;; --------------------------------------------------------------------------- +;; Situation vars — arbitrary named values beyond roles +;; +;; Roles name SHAPES (resolved from labels to ids). Some operations need to track +;; named values that are NOT shapes — e.g. a component id to instantiate next, or +;; a counter (nesting depth). Those live in a separate `:vars` map so they do not +;; get confused with shape roles. Set/read with `set-var`/`get-var`. +;; --------------------------------------------------------------------------- + +(defn set-var + "Associate `k` with `v` in the situation's `:vars` (arbitrary named values that + are NOT shapes — e.g. a component id, a counter). Returns the situation." + [situation k v] + (assoc-in situation [:vars k] v)) + +(defn get-var + "Read the value of `k` from the situation's `:vars`, or `default` (nil if + omitted) when absent. See `set-var`." + ([situation k] (get-var situation k nil)) + ([situation k default] (get-in situation [:vars k] default))) + +(defn target-shape-id + "Resolve an operation's target to a shape id: + - a FUNCTION `(situation -> id)` is called with the situation (lets the target + be computed from situation state — e.g. a field of a tracked object — + resolved at apply-time so it follows state changes); + - a currently-bound ROLE resolves via the role (so the operation follows the + role as state-building ops re-point it); + - otherwise `target` is a label (strict-presence). + This is what makes a single operation composable across depth / with + make-nested-component et al." + [situation target] + (cond + (fn? target) (target situation) + (has-role? situation target) (:id (role-shape situation target)) + :else (resolve-shape-id situation target))) + +;; --------------------------------------------------------------------------- +;; Strict-presence lookup +;; +;; Resolving a binding name (shape label) that is absent must throw a DIAGNOSTIC +;; error: it names the absent binding and lists those present. A silently-nil +;; lookup is forbidden (it would make a declarative case a lie / a green test +;; that asserts against nothing). Acting on a present-but-wrong-kind binding is +;; left to crash naturally downstream. +;; --------------------------------------------------------------------------- + +(defn resolve-shape + "Resolve a shape binding name (label) to the actual shape in the situation's + current page, throwing a diagnostic error if the name is not bound to a shape + that exists in that page. The diagnostic lists the labels of the shapes that + ARE present, derived from the file itself (not from a parallel binding map)." + [situation name] + (let [the-file (file situation) + page (thf/current-page the-file) + id (thi/id name) + shape (when id (ctst/get-shape page id))] + (when (nil? shape) + (let [present (->> (vals (:objects page)) + (map (comp thi/label :id)) + (sort) + (vec))] + (throw (ex-info (str "Unbound shape name: " (pr-str name) + ". Shapes present in current page: " (pr-str present)) + {:type ::unbound-shape-name + :name name + :present present})))) + shape)) + +(defn resolve-shape-id + "Resolve a shape binding name (label) to its uuid, with strict-presence + diagnostics (see `resolve-shape`)." + [situation name] + (:id (resolve-shape situation name))) + +;; --------------------------------------------------------------------------- +;; Operation protocol +;; +;; An OPERATION is a step in a composition. Most operations TRANSFORM the +;; situation (an edit, a structural change, a nesting), but some do not (a `Test` +;; asserts and returns the situation unchanged; `Skip` is a no-op) — hence the +;; genus is "operation", not "transformation". An operation is reified as DATA (a +;; record), not a bare function, so it is printable and navigable. `apply-to` +;; takes the operation and a situation and returns a (possibly identical) +;; situation; it may also record its own application. The method cannot be named +;; `apply` (core clash). +;; --------------------------------------------------------------------------- + +(defprotocol IOperation + (apply-to [operation situation] + "Apply this operation to `situation`, returning a (possibly identical) + situation.")) + + +;; --------------------------------------------------------------------------- +;; Enumeration +;; +;; A composed operation may stand for MANY concrete cases (because `one-of` +;; offers alternatives). Enumeration turns one composed operation into the +;; sequence of fully-concrete operations it represents — each of which has +;; no remaining choice and can be `apply-to`'d directly. +;; +;; Enumeration is a generic-engine concern: only the composition operators +;; (`in-sequence`, `one-of`) implement it. Leaf/subject operations (e.g. the +;; component nodes) need not know about it — the top-level `enumerate` falls back +;; to "an operation with no choice enumerates to itself", so a plain node yields a +;; single variant (itself). This keeps `apply-to` and `enumerate` orthogonal and +;; keeps the `comp` node library free of enumeration code. +;; --------------------------------------------------------------------------- + +(defprotocol IEnumerable + (-enumerate [operation] + "Return a sequence of fully-concrete operations this one represents.")) + +(defn enumerate + "The concrete operations represented by `operation`. Falls back to a single + variant (the operation itself) for anything that does not implement + IEnumerable (i.e. leaf operations with no internal choice)." + [operation] + (if (satisfies? IEnumerable operation) + (-enumerate operation) + [operation])) + +;; --------------------------------------------------------------------------- +;; Composition operator: sequence +;; +;; A `Sequence` holds an ordered vector of child operations and applies them +;; left-to-right, THREADING the situation through each: the situation returned +;; by one child is the input to the next. Operations do not commute, so order +;; is explicit. Its enumeration is the CARTESIAN PRODUCT of its children's +;; variants (with single-variant children this is exactly one Sequence), which +;; is what makes a `one-of`/`optional` composed inside a sequence multiply the +;; case out into a matrix. +;; +;; The constructor is named `in-sequence` because `sequence` is a clojure.core +;; name and must not be shadowed. +;; --------------------------------------------------------------------------- + +(declare ->Sequence) + +(defrecord Sequence [steps] + IOperation + (apply-to [_ situation] + (reduce (fn [sit step] (apply-to step sit)) + situation + steps)) + + IEnumerable + (-enumerate [_] + ;; Cartesian product: every combination of one concrete variant per step, + ;; each combination rewrapped as a concrete Sequence. With single-variant + ;; steps this yields exactly one Sequence (the all-singletons case). + (->> steps + (map enumerate) ; step -> seq of concrete variants + (reduce (fn [acc step-variants] + (for [combo acc + variant step-variants] + (conj combo variant))) + [[]]) ; seed: one empty combination + (map ->Sequence)))) + +(defn in-sequence + "Constructor for the sequence operator: apply `steps` (a seq of + transformations) in order, threading the situation through each." + [steps] + (->Sequence (vec steps))) + + +;; --------------------------------------------------------------------------- +;; Composition operator: one-of +;; +;; `one-of` offers a set of alternative transformations: it represents N cases, +;; one per alternative. It is a pure *enumeration* construct — it has no single +;; apply semantics, because applying it would mean arbitrarily picking one +;; alternative. So `apply-to` on a raw OneOf is an error; it must be enumerated +;; first (the runner does this). Its `-enumerate` is the UNION of its +;; alternatives' enumerations, so `one-of` composed inside `in-sequence` +;; multiplies out correctly via the sequence's cartesian product. +;; --------------------------------------------------------------------------- + +(defrecord RecordedChoice [one-of-id chosen] + ;; Internal node produced by OneOf enumeration. Applying it records, under the + ;; originating one-of's identity, which alternative was chosen, then applies + ;; that alternative. This is how `get-choice` (read by the test holding the + ;; one-of) recovers the choice from the resulting situation. It is already + ;; concrete, so it enumerates to itself. + IOperation + (apply-to [_ situation] + (-> situation + (assoc-in [:node-data one-of-id] {:chosen chosen}) + (update :applied conj one-of-id) + (->> (apply-to chosen)))) + + IEnumerable + (-enumerate [this] [this])) + +(defrecord OneOf [alternatives] + IOperation + (apply-to [_ _] + (throw (ex-info (str "OneOf cannot be applied directly; it represents a choice " + "and must be enumerated first (the runners do this via `enumerate`).") + {:type ::one-of-applied-directly + :alternative-count (count alternatives)}))) + + IEnumerable + (-enumerate [this] + ;; Each alternative may itself enumerate to several concrete variants; every + ;; resulting concrete variant is wrapped so that, when applied, it records + ;; this one-of's choice under this one-of's identity. + (for [alt alternatives + variant (enumerate alt)] + (->RecordedChoice (node-id this) variant)))) + +(defn one-of + "Constructor for the one-of operator: represents one case per alternative in + `alternatives`. Used to sweep a set of variants (e.g. several attribute + changes) across an otherwise-shared case. Carries an identity so the choice it + made in a given enumerated run can be recovered via `get-choice`." + [alternatives] + (assign-id (->OneOf (vec alternatives)))) + + +(defrecord Skip [] + IOperation + (apply-to [_ situation] situation) + IEnumerable + (-enumerate [self] [self])) + +(defn skip + "The identity operation: applies as a no-op, enumerates to itself. The building + block of `optional`." + [] + (->Skip)) + +(defrecord Test [assert-fn] + ;; An operation that makes ASSERTIONS at this point in the sequence and leaves + ;; the situation unchanged. `assert-fn` is a (situation -> any) that performs the + ;; assertions itself (e.g. calls `t/is`), exactly like an external asserter — so + ;; checks can be placed at INTERMEDIATE steps, not only at the end. It records + ;; its application (so the transcript shows where checkpoints sit) but changes + ;; nothing. Concrete, so it enumerates to itself. + IOperation + (apply-to [this situation] + (assert-fn situation) + (record-application situation this {})) + IEnumerable + (-enumerate [self] [self])) + +(defn test-that + "Constructor for an inline `Test` operation. `assert-fn` is a (situation -> any) + that performs assertions (it is run for side effects; its return is ignored). + Place it inside an `in-sequence` to assert at that point in the trajectory. + Typically queries `applied?`/`get-choice`/`has-property-of` to decide what to + assert for the current enumerated variant." + [assert-fn] + (assign-id (->Test assert-fn))) + +(defn optional + "`(optional t)` = `(one-of [t (skip)])`: sweeps WITH and WITHOUT `t` across a + case (two enumerated variants). Kept as its own named constructor for + readability. The workhorse for adding a state-building step (e.g. + `(optional (make-nested-component))`) as an axis over an existing case." + [t] + (one-of [t (skip)])) + +(defn get-choice + "The alternative this `one-of` chose in `situation` (the concrete transformation + that actually ran for this branch), or nil if it did not run in this variant. + Recovered by node identity, so the test holds the one-of and asks it." + [situation one-of] + (:chosen (node-data situation one-of))) + +;; --------------------------------------------------------------------------- +;; Executors +;; +;; Build a fresh situation from a setup thunk and apply a (possibly composed) +;; OPERATION. `run-variant` runs one already-concrete variant; `run-all` +;; enumerates a composed operation and runs every variant. Neither makes a +;; judgment or touches clojure.test — `check` (in +;; `frontend-tests.composable-tests.interpreter`) is the test-facing entry point. (Assertions can also live INSIDE the operation, via +;; `Test`; those fire as the operation is applied.) +;; --------------------------------------------------------------------------- + +(defn run-variant + "Run one concrete (already-enumerated) variant: build a fresh situation via + `setup` (a 0-arg fn returning a *situation*) and apply `operation`, + returning the resulting situation. Makes no judgment. Exceptions propagate. + This is the per-variant step `check` builds on; tests use `check`, not this." + [{:keys [setup operation]}] + (->> (setup) + (apply-to operation))) + + +(defn run-all + "Enumerate `operation` into its concrete variants and run each (fresh setup + per variant, isolated label space — the id map is reset before each variant's + setup so per-variant setups don't clobber labels). Returns a vector of the + resulting situations, one per enumerated variant, in enumeration order. Makes + no judgment and does not touch clojure.test — see `check` (in + `frontend-tests.composable-tests.interpreter`) for the test-facing entry point that asserts." + [{:keys [setup operation]}] + (->> (enumerate operation) + (mapv (fn [variant] + (thi/reset-idmap!) + (run-variant {:setup setup + :operation variant}))))) + + +(defn sequence-ops + "Flatten a CONCRETE (already-enumerated) operation into its ordered units. + Used by alternative interpreters (e.g. the frontend one) that dispatch + per-operation effects rather than calling `apply-to`. + + A concrete variant is a single operation record, or a `Sequence` of them, with + `RecordedChoice` (the enumerated form of a one-of) appearing where a one-of was. + `Sequence` is flattened; a `RecordedChoice` is KEPT as a unit (it carries both + the chosen operation and the one-of identity, so a per-op interpreter can + dispatch the chosen op's effect AND record the choice for `get-choice`). Use + `recorded-choice?`, `choice-of`, and `choice-one-of-id` to handle those units." + [variant] + (cond + (instance? Sequence variant) + (vec (mapcat sequence-ops (:steps variant))) + + :else + [variant])) + +(defn recorded-choice? + "True if `op` is a one-of's enumerated choice wrapper." + [op] + (instance? RecordedChoice op)) + +(defn choice-of + "The concrete operation a `RecordedChoice` selected." + [recorded-choice] + (:chosen recorded-choice)) + +(defn choice-one-of-id + "The identity of the one-of that produced this `RecordedChoice` (so a per-op + interpreter can record the choice under it, enabling `get-choice`)." + [recorded-choice] + (:one-of-id recorded-choice)) diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs new file mode 100644 index 0000000000..1890fd0faf --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -0,0 +1,471 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.composable-tests.interpreter + "FRONTEND interpreter + test-facing `check` for the composable test model. + + The generic engine lives in `frontend-tests.composable-tests.core` and the + component instruments (operation records, setups, role accessors, inspection + methods) behind the `comp` boundary. What lives HERE is: + 1. `op->events` — the event realisation of each EVENT-op: the real + workspace event(s) it dispatches (it depends on + `app.main.data.workspace.*`). + 2. an ASYNC interpreter that drives a sequence of operations through the REAL + app: it installs the situation's file into the global store, starts the + real component-change watcher, then for each operation dispatches its + event(s) and AWAITS settlement (so the app's AUTOMATIC propagation — not a + manual sync — is what runs), re-reading the file from the store after each. + 3. `check` — the test-facing entry: takes {:setup :operation} + an OPTIONAL + asserter; enumerates the operation and runs each variant; assertions may + be inline (`Test` ops) and/or in the asserter. + + IN-FILE PROPAGATION IS AUTOMATIC: there is no propagate operation. The watcher + (`dwl/watch-component-changes`), running on the global store, detects + main-instance changes in the CURRENT file and syncs that file's copies on its + own. That automatic behaviour is precisely what the cases exercise. The one + deliberate exception is CROSS-FILE propagation (case H): when the main lives + in a linked LIBRARY and the copy in the consuming file, the watcher does NOT + cross the file boundary — the real app propagates via the library-UPDATE + action, so the `SyncFromLibrary` op explicitly dispatches `sync-file` (this is + faithful: it is exactly the user action, not a test shortcut). See + `mem:frontend/composable-component-tests`. + + NOTE on the store: this uses the GLOBAL `st/state` store (not the isolated + `setup-store`), because the watcher reads `refs/workspace-data` which derives + from `st/state`. State is re-installed per variant for isolation." + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.main.data.changes :as dch] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.thumbnails :as dwth] + [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.variants :as dwv] + [app.main.store :as st] + [beicon.v2.core :as rx] + [cljs.test :as t] + [frontend-tests.composable-tests.comp.nodes :as n] + [frontend-tests.composable-tests.core :as tm] + [potok.v2.core :as ptk])) + +;; -------------------------------------------------------------------------- +;; (0) Thumbnail rendering — disabled in this headless suite +;; +;; The propagation watcher we start (`dwl/watch-component-changes`) ALSO schedules +;; THUMBNAIL renders on every component change (its `component-changed` event has a +;; thumbnail branch). Thumbnail rendering reaches `app.util.dom/get-css-variable`, +;; which calls `window.getComputedStyle` — and there is no `window` in the headless +;; runner, so a (queued, async) render fires LATER and crashes the run. Thumbnails +;; are pure UI side-effect, irrelevant to what we test, so we stub the public seam +;; `dwth/update-thumbnail` to a no-op event for the duration of OUR tests (installed +;; / restored by a `:each` fixture in the case namespace — the same `set!`-and- +;; restore pattern as `frontend_tests.helpers.wasm`, whose `:after` correctly runs +;; only after each async test's `done`). Scope is thus our suite only. +;; -------------------------------------------------------------------------- + +(defonce ^:private original-update-thumbnail (atom nil)) + +(defn- noop-thumbnail-event [] + (ptk/reify ::noop-thumbnail + ptk/WatchEvent + (watch [_ _ _] (rx/empty)))) + +(defn install-thumbnail-noop! + "Replace `dwth/update-thumbnail` with a no-op event so no thumbnail rendering + runs (it would reach `window`, absent headless). Restore with + `restore-thumbnail!`. Idempotent." + [] + (when (nil? @original-update-thumbnail) + (reset! original-update-thumbnail dwth/update-thumbnail) + (set! dwth/update-thumbnail (fn [& _] (noop-thumbnail-event))))) + +(defn restore-thumbnail! + "Restore the real `dwth/update-thumbnail` saved by `install-thumbnail-noop!`." + [] + (when-let [orig @original-update-thumbnail] + (set! dwth/update-thumbnail orig) + (reset! original-update-thumbnail nil))) + +;; -------------------------------------------------------------------------- +;; (1) Frontend realisation of operations: op record -> workspace event(s) +;; +;; The operation's "what" (its update-fn / target) is shared in common; this maps +;; it to the "how" on the frontend — the real event a user interaction dispatches. +;; Returns a vector of events to emit for that operation. +;; -------------------------------------------------------------------------- + +;; The edit a ChangeProperty performs is the SHARED `n/set-property`, so the +;; frontend applies the identical change the common node does (only the wrapping +;; differs: a real workspace event here vs. apply-changes there). + +(defn- add-child->shape + "Build the shape value AddChild introduces, parented under the target parent in + the current store page (mirrors the common add-child's sample shape). Returns + the shape with parent/frame set to the target parent." + [parent-label new-label shape-params] + (let [parent-id (cthi/id parent-label) + shape (cths/sample-shape new-label (or shape-params {}))] + (assoc shape + :parent-id parent-id + :frame-id parent-id))) + +(defn op->events + "Map a comp operation record to the real workspace event(s) it dispatches. + `op` is one of the comp node records (frontend-tests.composable-tests.comp.nodes); + `situation` provides cross-file context (e.g. the library id) for ops that need + it. + + Each operation is realised with the SAME production change builder the common + node uses, but wrapped in the real workspace event (so it commits to the store + and the watcher auto-propagates): + ChangeProperty -> update-shapes (generate-update-shapes; n/set-property) + MoveChild -> relocate-shapes (generate-relocate, existing shape) + AddChild -> add-shape (create a shape under the parent) + RemoveChild -> delete-shapes (generate-delete-shapes) + SyncFromLibrary -> sync-file (the cross-file library-update action, H) + Undo -> dwu/undo (reverse the previous operation(s), I) + (In-file propagation is automatic — the watcher — so no propagate op exists.)" + [op situation] + (cond + (instance? n/ChangeProperty op) + (let [{:keys [target property value]} op] + ;; `target` may be a ROLE (resolved via the situation, so the edit follows + ;; the role as make-nested-component re-points it) or a label — same dual resolution + ;; as the common ChangeProperty. The edit uses the SHARED `n/set-property`. + [(dwsh/update-shapes #{(tm/target-shape-id situation target)} + (fn [shape] (n/set-property shape property value)))]) + + (instance? n/SwapComponent op) + ;; Swap lineage `name`'s nesting level `level` for lineage `target`'s component + ;; via the REAL swap event (dwl/component-swap), so it commits through the + ;; normal path and the watcher AUTOMATICALLY propagates the swap to copies + ;; (incl. the deeper nesting levels). This is the behaviour under test. + (let [{:keys [name level target keep-touched?]} op + file-id (:id (tm/file situation)) + nested (n/lineage-nesting situation name level) + shape (tm/shape-by-id situation (:nested-head nested)) + target-id (n/lineage-component-id situation target)] + [(dwl/component-swap shape file-id target-id (boolean keep-touched?))]) + + (instance? n/SwitchVariant op) + ;; The variant-switch action: switch the resolved variant copy head to the + ;; sibling member whose selector property (pos 0) has `value`, via the REAL + ;; `variants-switch` event (which discovers the sibling in the variant container + ;; and routes through component-swap keep-touched? true, so the watcher + ;; auto-propagates the switch across nesting levels exactly like a swap). `target` + ;; uses the standard resolution (role | label | (situation -> id) fn), so the op + ;; is structure-blind. + (let [{:keys [target value]} op + head-id (tm/target-shape-id situation target) + shape (tm/shape-by-id situation head-id)] + [(dwv/variants-switch {:shapes [shape] :pos 0 :val value})]) + + (instance? n/MoveChild op) + (let [{:keys [target parent to-index]} op] + [(dwsh/relocate-shapes #{(cthi/id target)} (cthi/id parent) to-index)]) + + (instance? n/RemoveChild op) + (let [{:keys [target]} op] + [(dwsh/delete-shapes #{(cthi/id target)})]) + + (instance? n/AddChild op) + (let [{:keys [parent new-label shape-params]} op] + [(dwsh/add-shape (add-child->shape parent new-label shape-params) + {:no-select? true})]) + + (instance? n/SyncFromLibrary op) + ;; The cross-file library-update action: sync the current (consuming) file + ;; from its linked library — exactly what the "library updated" dialog does. + (let [file-id (:current-file-id @st/state) + library-id (first (keys (tm/aux-files situation)))] + [(dwl/sync-file file-id library-id)]) + + (instance? n/Undo op) + ;; Reverse the previous operation(s) via the real undo event. The workspace + ;; undo stack was maintained automatically by the prior ops' commits. + [dwu/undo] + + :else + (throw (ex-info (str "op->events: no frontend realisation for " (pr-str (type op))) + {:op op})))) + +;; -------------------------------------------------------------------------- +;; (2) Async interpreter +;; -------------------------------------------------------------------------- + +(def ^:private settle-debounce-ms + "Resolve a step once the store's commit stream has been idle this long. This + captures the edit commit AND the watcher's follow-up sync commit, without a + fixed total delay. (Provisional: a fully deterministic per-op stopper would + await that op's specific component-changed/sync; debounce-idle is robust enough + for now.)" + 60) + +(def ^:private settle-timeout-ms 2000) + +(defn- install-situation-event + "An UpdateEvent installing the situation's files into the (global) store: the + primary file as the current/workspace file, plus any AUXILIARY files (e.g. a + linked library, for the cross-file case H) alongside in `:files`, each tagged + `:library-of` the current file so the library-sync machinery treats them as + linked libraries." + [situation] + (let [file (tm/file situation) + aux (tm/aux-files situation)] + (ptk/reify ::install-file-event + ptk/UpdateEvent + (update [_ state] + (assoc state + :current-file-id (:id file) + :current-page-id (cthf/current-page-id file) + :permissions {:can-edit true} + :files (into {(:id file) file} + (map (fn [[lib-id lib]] [lib-id (assoc lib :library-of (:id file))])) + aux)))))) + +(defn- current-file + "Read the live workspace file out of the global store." + [] + (let [st @st/state] + (get-in st [:files (:current-file-id st)]))) + +(defn- await-settle + "Dispatch `events` into the global store, then call `k` once the commit stream + has gone idle (debounced). A hard timeout guarantees progress." + [events k] + (let [stream (ptk/input-stream st/state) + commits (->> stream (rx/filter dch/commit?)) + ;; resolve on first idle gap after a commit, or on timeout + settled (->> commits + (rx/debounce settle-debounce-ms) + (rx/take 1) + (rx/timeout settle-timeout-ms (rx/of :settle/timeout)))] + (rx/subscribe settled (fn [_] (k))) + (doseq [e events] (st/emit! e)))) + +(defn- record-op + "Record an operation's application onto the situation (after its effect settled), + re-reading the file from the store. For a RecordedChoice (one-of), record the + chosen op's application AND the choice under the one-of identity (so the + asserter's `get-choice` works), mirroring `RecordedChoice`'s own `apply-to`." + [situation op] + (let [situation (tm/with-file situation (current-file))] + (if (tm/recorded-choice? op) + (let [chosen (tm/choice-of op) + descriptor (dissoc (into {} chosen) :frontend-tests.composable-tests.core/id)] + (-> situation + ;; record the chosen op under its own identity … + (tm/record-application chosen descriptor) + ;; … and the choice under the one-of's identity, keyed for get-choice + (update :node-data assoc (tm/choice-one-of-id op) {:chosen chosen}) + (update :applied conj (tm/choice-one-of-id op)))) + (let [descriptor (dissoc (into {} op) :frontend-tests.composable-tests.core/id)] + (tm/record-application situation op descriptor))))) + +(defn- op-events + "The workspace events to dispatch for an op unit (a plain op or a one-of's + RecordedChoice — for the latter, the chosen op's events). `situation` provides + cross-file context to ops that need it." + [op situation] + (op->events (if (tm/recorded-choice? op) (tm/choice-of op) op) situation)) + +(defn- install-file-event + "An UpdateEvent that replaces the current file in the (global) store with `file` + (keeping aux files and everything else). Used to write back the result of a + FILE-TRANSFORMING op (see `file-op?`)." + [file] + (ptk/reify ::install-file + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :current-file-id (:id file) + :current-page-id (cthf/current-page-id file)) + (assoc-in [:files (:id file)] file))))) + +(defn- sync-op? + "Whether `op` is a SYNCHRONOUS, `apply-to`-based operation rather than one that + dispatches a workspace event and needs settling. Two kinds: + - FILE-TRANSFORMING (`make-nested-component`, `skip`): a pure file-value transformation + that arranges the CONFIGURATION (deepens it, re-points roles) — applied by + running `apply-to` against the live store file and writing the result back. + The property under test is still exercised by the SUBSEQUENT real-event ops. + - `Test`: an inline assertion checkpoint — its `apply-to` runs the assertion + against the current situation and returns it unchanged. + Both are handled by `run-sync-op` (no async settle — any store write is a + synchronous UpdateEvent)." + [op] + (let [op (if (tm/recorded-choice? op) (tm/choice-of op) op)] + (or (instance? n/MakeNestedComponent op) + ;; the structural building blocks are also file-transforming: they arrange + ;; the configuration via the shared `apply-to`. ResetCopyInstance is also a + ;; file-op: the real reset event transitively reads browser globals (CSS + ;; vars), so it cannot run headless; the shared apply-to runs the production + ;; reset generator with validation off. + (instance? n/CreateComponent op) + (instance? n/InstantiateCopy op) + (instance? n/ResetCopyInstance op) + ;; the variant container (test-helper assembly) and variant nesting (the + ;; shared nesting helper) are file-transforming sync-ops; the SWITCH is the + ;; real `variants-switch` workspace event (handled by op->events), not here. + (instance? n/MakeVariantContainer op) + (instance? n/MakeNestedComponentWithVariant op) + (instance? tm/Skip op) + (instance? tm/Test op)))) + +(defn- run-sync-op + "Apply a synchronous (`sync-op?`) operation: run its shared `apply-to` against a + situation whose `:file` is the live store file, write the resulting file back + into the store, and return the updated situation (with any re-pointed roles). + For `Test` this runs the inline assertion (against the live store state) and the + file is unchanged; for `make-nested-component`/`skip` it writes back the transformed file. + Synchronous." + [situation op] + (let [op' (if (tm/recorded-choice? op) (tm/choice-of op) op) + situation (tm/with-file situation (current-file)) + situation (tm/apply-to op' situation)] + (st/emit! (install-file-event (tm/file situation))) + ;; record the choice too, if this came wrapped in a one-of + (if (tm/recorded-choice? op) + (-> situation + (update :node-data assoc (tm/choice-one-of-id op) {:chosen op'}) + (update :applied conj (tm/choice-one-of-id op))) + situation))) + +(defn- op-grace-ms + "Extra wait AFTER an event-op has settled, before proceeding. Zero for all ops + except `SyncFromLibrary`: the production `sync-file` event additionally + schedules `rx/timer 3000` + an `:update-file-library-sync-status` RPC. There is + no backend in the headless runner, so that delayed call fails (benignly) — but + 3s after the sync it would land INSIDE whatever test is then running, leaking + an error trace across test boundaries (and historically destabilising + whole-suite runs). Waiting it out here absorbs the failure within the test that + caused it." + [op] + (let [op (if (tm/recorded-choice? op) (tm/choice-of op) op)] + (if (instance? n/SyncFromLibrary op) 3200 0))) + +(defn- run-ops + "Async fold over `ops` (concrete operation units, in order — plain ops and/or + one-of RecordedChoice wrappers). Threads the situation. A SYNCHRONOUS op + (`sync-op?` — file-transforming or an inline `Test`) is applied synchronously + via `apply-to`; every other op dispatches its real workspace event(s) and awaits + settlement (plus a per-op grace period, see `op-grace-ms`). The file is re-read + from the store after each. Calls `k` with the final situation." + [situation ops k] + (if (empty? ops) + (k situation) + (let [op (first ops)] + (if (sync-op? op) + (run-ops (run-sync-op situation op) (rest ops) k) + (await-settle + (op-events op situation) + (fn [] + (let [continue #(run-ops (record-op situation op) (rest ops) k) + grace (op-grace-ms op)] + (if (pos? grace) + (js/setTimeout continue grace) + (continue))))))))) + +(defn- watch-undo-stack + "Maintain the workspace UNDO STACK in the (global) store, mirroring the + production subscription in `app.main.data.workspace/initialize-workspace`: + for every local commit with `save-undo?` and non-empty `:undo-changes`, dispatch + `dwu/append-undo`. We start this in the harness because the minimal store setup + does not run the full `initialize-workspace` (which is where the real app wires + it). Re-emitting this event stops any previous instance (so re-install per + variant does not stack watchers)." + [] + (ptk/reify ::watch-undo-stack + ptk/WatchEvent + (watch [_ _ stream] + (let [stopper-s (->> stream (rx/filter (ptk/type? ::watch-undo-stack)))] + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(= :local (:source %))) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo? selected-before]}] + (if (and save-undo? (seq undo-changes)) + (rx/of (dwu/append-undo + {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags + :selected-before selected-before} + stack-undo?)) + (rx/empty)))) + (rx/take-until stopper-s)))))) + +(defonce ^:private original-store + ;; Captured at namespace-LOAD time — i.e. before any test in the run executes. + ;; Several test namespaces (the plugins suite) `set!` st/state / st/stream to + ;; isolated stores and never restore them. That silently kills this harness: + ;; the refs in `app.main.refs` (through which `watch-component-changes` + ;; observes commits) are okulary lenses bound to THIS atom instance at load + ;; time, so once the var points elsewhere, our events commit to a store the + ;; watcher does not see and propagation dies with no error. Re-installing the + ;; original per variant makes the harness immune to run order. + {:state st/state :stream st/stream}) + +(defn- restore-global-store! + "Point st/state / st/stream back at the load-time originals (see + `original-store`)." + [] + (set! st/state (:state original-store)) + (set! st/stream (:stream original-store))) + +(defn- run-variant + "Set up one variant on the global store and run its ops. `setup` returns a + situation (file + roles). Restores the global store (a preceding namespace may + have swapped it), installs the file + starts the watcher, then folds the ops. + Calls `k` with the final situation." + [setup ops k] + ;; fresh label space per variant (mirrors the pure `tm/run-all`) + (cthi/reset-idmap!) + (restore-global-store!) + (let [situation (setup)] + (st/emit! (install-situation-event situation)) + (st/emit! (dwl/watch-component-changes)) + (st/emit! (watch-undo-stack)) + (run-ops situation ops k))) + +;; -------------------------------------------------------------------------- +;; (3) Test-facing check +;; -------------------------------------------------------------------------- + +(defn check + "Frontend `check`: run `case-map` ({:setup :operation}) through the REAL app and, + if `asserter` is given, apply it (a situation -> any fn performing assertions) + to the resulting situation of EACH enumerated variant. Async — `done` is the + cljs.test async callback and MUST be called when finished. + + Assertions may be INLINE (via `Test` operations in the `:operation` sequence, + firing as the op runs) and/or via the trailing `asserter`; `asserter` is + optional (omit when all assertions are inline). The asserter closes over node + references the test holds (e.g. `has-property-of` on a change node). In-file + propagation is AUTOMATIC (the watcher) — no propagate op is added. + + Arities: `(check done case-map)` or `(check done case-map asserter)`." + ([done case-map] (check done case-map nil)) + ([done {:keys [setup operation]} asserter] + (let [variants (tm/enumerate operation)] + (letfn [(run-next [vs] + (if (empty? vs) + (done) + (run-variant + setup + ;; a variant is a composed operation; flatten to its ordered leaf + ;; ops. `enumerate` already removed all one-of choices, so the + ;; variant is a Sequence (or a single op). + (tm/sequence-ops (first vs)) + (fn [situation] + (when asserter + (t/testing (str "operations:\n " (tm/describe-applied situation)) + (asserter situation))) + (run-next (rest vs))))))] + (run-next variants))))) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 497b4c22f1..4529e34582 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -202,9 +202,13 @@ (set! wasm.fonts/get-content-fonts mock-get-content-fonts)) (defn teardown-wasm-mocks! - "Restore the original WASM functions saved by `setup-wasm-mocks!`." + "Restore the original WASM functions saved by `setup-wasm-mocks!`. + No-op when there is nothing to restore (`originals` empty): restoring from an + empty snapshot would `set!` every WASM function to nil, breaking any later + code that calls them (e.g. a leaked debounced event firing during a + subsequent test namespace)." [] - (let [orig @originals] + (when-let [orig (not-empty @originals)] (set! wasm.api/initialized? (:initialized? orig)) (set! wasm.api/use-shape (:use-shape orig)) (set! wasm.api/calculate-position-data (:calculate-position-data orig)) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b6f1a32a80..2adae3e61e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -6,6 +6,7 @@ [clojure.tools.cli :refer [parse-opts]] [frontend-tests.basic-shapes-test] [frontend-tests.code-gen-style-test] + [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.copy-as-svg-test] [frontend-tests.data.nitrate-test] [frontend-tests.data.repo-test] @@ -79,6 +80,7 @@ (def test-namespaces ['frontend-tests.basic-shapes-test 'frontend-tests.code-gen-style-test + 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.nitrate-test 'frontend-tests.data.repo-test From a006a12ab6929804af918ec1f5c845d0aeab8b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Fri, 10 Jul 2026 12:33:59 +0200 Subject: [PATCH 42/63] :bug: Fix render stroke caps on drag (#10634) --- render-wasm/src/shapes.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 92d49c7c30..5dfbe9e384 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1132,6 +1132,10 @@ impl Shape { .fold(0.0, f32::max) } + pub fn has_cap_bounds(&self) -> bool { + self.cap_bounds_margin() > 0.0 + } + pub fn mask_id(&self) -> Option<&Uuid> { self.children.first() } @@ -1452,7 +1456,8 @@ impl Shape { } } - self.blur.is_none() + !self.has_cap_bounds() + && self.blur.is_none() && self.background_blur.is_none() && self.shadows.is_empty() && (self.opacity - 1.0).abs() <= 1e-4 From 33e18c72e2007337051eccf9e5f7d28b9cd74bd4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Sat, 11 Jul 2026 10:12:07 +0200 Subject: [PATCH 43/63] :paperclip: Add minor improvements to opencode setup --- .opencode/agents/commiter.md | 83 ++-- .opencode/commands/implement-plan.md | 43 ++ .opencode/commands/review.md | 21 + .../skills/code-review-and-quality/SKILL.md | 397 +++++++++++++++ .../skills/security-and-hardening/SKILL.md | 457 ++++++++++++++++++ 5 files changed, 948 insertions(+), 53 deletions(-) create mode 100644 .opencode/commands/implement-plan.md create mode 100644 .opencode/commands/review.md create mode 100644 .opencode/skills/code-review-and-quality/SKILL.md create mode 100644 .opencode/skills/security-and-hardening/SKILL.md diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md index 395fe6416c..66b56d1041 100644 --- a/.opencode/agents/commiter.md +++ b/.opencode/agents/commiter.md @@ -79,67 +79,44 @@ permission: ## Role You are the Penpot commit assistant. You produce git commits that follow the -repository's commit conventions exactly: an emoji-prefixed imperative -subject, a body that explains the why, and the required trailers. You do -not implement features, review code, or push branches — you commit. +repository's commit conventions. You do not implement features, review code, or +push branches — you commit. ## Required Reading -Before drafting any commit, read `.serena/memories/workflow/creating-commits.md` -end-to-end. It is the canonical source for the emoji menu, subject/body -limits, and trailer format. The summary in this file does not replace it. +Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md` +end-to-end**. It is the authoritative source for the commit message format, the +emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it +exactly — do not improvise the format and do not restate its contents here. ## Pre-commit Workflow 1. Run `git status` to inspect the working tree. If there are unstaged or - untracked changes that are unrelated to the user's request, STOP and ask - the user how to handle them. Do not silently include unrelated work in - the commit. -2. Run `git diff --staged` (or `git diff` for unstaged changes) and review - the content. If you see secrets (API keys, tokens, passwords, private - keys, `.env` values), debug prints, or anything that does not match the - user's stated intent, STOP and tell the user before committing. -3. Pick the commit emoji from the menu in - `mem:workflow/creating-commits`. If none of the listed emojis fit, use - `:paperclip:` (other) and explain in the body why. -4. Draft the commit message (see format below), then run - `git commit -s -m "<subject>" -m "<body>"` (or pass the message via - `git commit -s -F -` if the body has unusual characters). - -## Commit Message Format - -``` -:emoji: Subject line (imperative, capitalized, no period, <=70 chars) - -Body explaining what changed and why. Wrap at 80 chars. Use manual -line breaks; do not rely on the terminal to wrap. - -Co-authored-by: <model-name> <model-name@penpot.app> -``` - -- Subject: imperative mood, capitalized, no trailing period, max 70 chars. -- Body: wraps at 80 chars. Explain the *why*, not just the *what* — what - was wrong before, what this change does about it, and any non-obvious - trade-offs. -- `Co-authored-by` trailer is mandatory. Replace `<model-name>` with your - own model identifier (e.g. `claude-sonnet-4-6`). -- `Signed-off-by` is added automatically by `git commit -s`, using the - local `git config user.name` / `user.email`. + untracked changes unrelated to the user's request, STOP and ask how to + handle them. Do not silently include unrelated work in the commit. +2. Run `git diff --staged` (or `git diff` for unstaged changes) and review the + content. If you see secrets (API keys, tokens, passwords, private keys, + `.env` values), debug prints, or anything that does not match the user's + stated intent, STOP and tell the user before committing. +3. Following the format in the doc, draft the message and run + `git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has + unusual characters). The `AI-assisted-by` trailer value is provided by the + calling agent — use it verbatim. ## Constraints - Do not push. Pushing is a separate workflow handled by the user (the - agent's permission set also denies `git push*`). -- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`, - or `rm*` — the permission set denies these outright. If staged work - needs to be discarded, ask the user to do it. -- Do not pass `--author`. Author identity comes from the local git - config. Never guess or hallucinate a name or email. -- Do not amend a commit you did not create in this session, unless the - user explicitly asks. `git commit --amend` rewrites history and is - irreversible once pushed. -- Do not bypass pre-commit hooks (`--no-verify`) unless the user - explicitly asks, and call out the deviation in your response. -- If the user asks for something that conflicts with these rules, follow - the user's request and explain the deviation in your response. Do not - silently override the format. + permission set also denies `git push*`). +- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`, or + `rm*` — the permission set denies these outright. If staged work needs to be + discarded, ask the user to do it. +- Do not pass `--author`. Author identity comes from the local git config. + Never guess or hallucinate a name or email. +- Do not amend a commit you did not create in this session, unless the user + explicitly asks. `git commit --amend` rewrites history and is irreversible + once pushed. +- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly + asks, and call out the deviation in your response. +- If the user asks for something that conflicts with these rules, follow the + user's request and explain the deviation in your response. Do not silently + override the format. diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md new file mode 100644 index 0000000000..f6cb42f6b8 --- /dev/null +++ b/.opencode/commands/implement-plan.md @@ -0,0 +1,43 @@ +--- +description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent +agent: build +--- + +# Implement Plan + +This command is run once a plan is ready (for example, from plan mode). Execute +the plan already prepared in the current session context — it does not take +extra arguments. Follow these steps in order. + +## 1. Create the issue + +Use the **`create-issue`** skill, following the *Creating Issues from Draft Body* +flow in `mem:workflow/creating-issues`. Derive the issue title and body from the +plan. Capture the new issue's number — call it **NNNN** (needed for the branch +name and the commit reference). + +## 2. Create the branch + +Create and switch to a branch named after the issue: + +``` +git checkout -b issue-NNNN +``` + +(Replace NNNN with the issue number from step 1.) + +## 3. Execute the plan + +Implement the prepared plan from the session context. Work methodically, keeping +changes focused on what the issue requires. Do not commit — the commit happens in +step 4. + +## 4. Commit with the commiter subagent + +After the implementation is complete, delegate the commit to the **`commiter`** +subagent. Give it a brief summary of what was implemented and why, the issue +reference (`issue-NNNN`), and the model name you are running as so it sets the +`AI-assisted-by` trailer correctly. The subagent owns the commit format and +conventions. + +Do not push. Pushing is handled separately by the user. diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md new file mode 100644 index 0000000000..7736620e89 --- /dev/null +++ b/.opencode/commands/review.md @@ -0,0 +1,21 @@ +--- +description: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes +agent: plan +subtask: true +--- + +You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance). + +The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent). + +Workflow: + +1. Determine the target to review: + - If the user provided a revision/range in $ARGUMENTS, use it. + - Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`). +2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched. +3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security. +4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem). +5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items. + +Do not modify any code and do not create a commit — this command only reviews. diff --git a/.opencode/skills/code-review-and-quality/SKILL.md b/.opencode/skills/code-review-and-quality/SKILL.md new file mode 100644 index 0000000000..21b0b71771 --- /dev/null +++ b/.opencode/skills/code-review-and-quality/SKILL.md @@ -0,0 +1,397 @@ +--- +name: code-review-and-quality +description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch. +--- + +# Code Review and Quality + +## Overview + +Multi-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance. + +**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it. + +## When to Use + +- Before merging any PR or change +- After completing a feature implementation +- When another agent or model produced code you need to evaluate +- When refactoring existing code +- After any bug fix (review both the fix and the regression test) + +## The Five-Axis Review + +Every review evaluates code across these dimensions: + +### 1. Correctness + +Does the code do what it claims to do? + +- Does it match the spec or task requirements? +- Are edge cases handled (null, empty, boundary values)? +- Are error paths handled (not just the happy path)? +- Does it pass all tests? Are the tests actually testing the right things? +- Are there off-by-one errors, race conditions, or state inconsistencies? + +### 2. Readability & Simplicity + +Can another engineer (or agent) understand this code without the author explaining it? + +- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context) +- Is the control flow straightforward (avoid nested ternaries, deep callbacks)? +- Is the code organized logically (related code grouped, clear module boundaries)? +- Are there any "clever" tricks that should be simplified? +- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure) +- **Are abstractions earning their complexity?** (Don't generalize until the third use case) +- Would comments help clarify non-obvious intent? (But don't comment obvious code.) +- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments? +- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path. +- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt. + +### 3. Architecture + +Does the change fit the system's design? + +- Does it follow existing patterns or introduce a new one? If new, is it justified? +- Does it maintain clean module boundaries? +- Is there code duplication that should be shared? +- Are dependencies flowing in the right direction (no circular dependencies)? +- Is the abstraction level appropriate (not over-engineered, not too coupled)? +- **Does this refactor reduce complexity or just relocate it?** Count the concepts a reader must hold to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it. +- **Is feature-specific logic leaking into a shared or general-purpose module?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift. +- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler. + +### 4. Security + +For detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities? + +- Is user input validated and sanitized? +- Are secrets kept out of code, logs, and version control? +- Is authentication/authorization checked where needed? +- Are SQL queries parameterized (no string concatenation)? +- Are outputs encoded to prevent XSS? +- Are dependencies from trusted sources with no known vulnerabilities? +- Is data from external sources (APIs, logs, user content, config files) treated as untrusted? +- Are external data flows validated at system boundaries before use in logic or rendering? + +### 5. Performance + +Does the change introduce performance problems? + +- Any N+1 query patterns? +- Any unbounded loops or unconstrained data fetching? +- Any synchronous operations that should be async? +- Any unnecessary re-renders in UI components? +- Any missing pagination on list endpoints? +- Any large objects created in hot paths? + +## Structural Remedies + +When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring: + +- **Replace a chain of conditionals** with a typed model or an explicit dispatcher. +- **Collapse duplicate branches** into a single clearer flow. +- **Separate orchestration from business logic** so each reads on its own. +- **Move feature-specific logic** out of a shared module into the package that owns the concept. +- **Reuse the canonical helper** instead of a bespoke near-duplicate. +- **Make a type boundary explicit** so downstream branching disappears. +- **Delete a pass-through wrapper** that adds indirection without clarifying the API. +- **Extract a helper, or split a large file** into focused modules. + +Prefer the remedy that removes moving pieces over one that spreads the same complexity around. + +## Change Sizing + +Small, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes: + +``` +~100 lines changed → Good. Reviewable in one sitting. +~300 lines changed → Acceptable if it's a single logical change. +~1000 lines changed → Too large. Split it. +``` + +**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add. + +**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature. + +**Splitting strategies when a change is too large:** + +| Strategy | How | When | +|----------|-----|------| +| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies | +| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns | +| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture | +| **Vertical** | Break into smaller full-stack slices of the feature | Feature work | + +**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line. + +**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion. + +## Change Descriptions + +Every change needs a description that stands alone in version control history. + +**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff. + +**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist. + +**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions." + +## Review Process + +### Step 1: Understand the Context + +Before looking at code, understand the intent: + +``` +- What is this change trying to accomplish? +- What spec or task does it implement? +- What is the expected behavior change? +``` + +### Step 2: Review the Tests First + +Tests reveal intent and coverage: + +``` +- Do tests exist for the change? +- Do they test behavior (not implementation details)? +- Are edge cases covered? +- Do tests have descriptive names? +- Would the tests catch a regression if the code changed? +``` + +### Step 3: Review the Implementation + +Walk through the code with the five axes in mind: + +``` +For each file changed: +1. Correctness: Does this code do what the test says it should? +2. Readability: Can I understand this without help? +3. Architecture: Does this fit the system? +4. Security: Any vulnerabilities? +5. Performance: Any bottlenecks? +``` + +### Step 4: Categorize Findings + +Label every comment with its severity so the author knows what's required vs optional: + +| Prefix | Meaning | Author Action | +|--------|---------|---------------| +| *(no prefix)* | Required change | Must address before merge | +| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality | +| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences | +| **Optional:** / **Consider:** | Suggestion | Worth considering but not required | +| **FYI** | Informational only | No action needed — context for future reference | + +This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions. + +**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review. + +### Step 5: Verify the Verification + +Check the author's verification story: + +``` +- What tests were run? +- Did the build pass? +- Was the change tested manually? +- Are there screenshots for UI changes? +- Is there a before/after comparison? +``` + +## Multi-Model Review Pattern + +Use different models for different review perspectives: + +``` +Model A writes the code + │ + ▼ +Model B reviews for correctness and architecture + │ + ▼ +Model A addresses the feedback + │ + ▼ +Human makes the final call +``` + +This catches issues that a single model might miss — different models have different blind spots. + +**Example prompt for a review agent:** +``` +Review this code change for correctness, security, and adherence to +our project conventions. The spec says [X]. The change should [Y]. +Flag any issues as Critical, Required, Optional, or Nit. +``` + +## Dead Code Hygiene + +After any refactoring or implementation change, check for orphaned code: + +1. Identify code that is now unreachable or unused +2. List it explicitly +3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?" + +Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask. + +``` +DEAD CODE IDENTIFIED: +- formatLegacyDate() in src/utils/date.ts — replaced by formatDate() +- OldTaskCard component in src/components/ — replaced by TaskCard +- LEGACY_API_URL constant in src/config.ts — no remaining references +→ Safe to remove these? +``` + +## Review Speed + +Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others. + +- **Respond within one business day** — this is the maximum, not the target +- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day +- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed +- **Large changes:** Ask the author to split them rather than reviewing one massive changeset + +## Handling Disagreements + +When resolving review disputes, apply this hierarchy: + +1. **Technical facts and data** override opinions and preferences +2. **Style guides** are the absolute authority on style matters +3. **Software design** must be evaluated on engineering principles, not personal preference +4. **Codebase consistency** is acceptable if it doesn't degrade overall health + +**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment. + +## Honesty in Review + +When reviewing code — whether written by you, another agent, or a human: + +- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one. +- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest. +- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow." +- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives. +- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself. + +## Dependency Discipline + +Part of code review is dependency review: + +**Before adding any dependency:** + +1. Does the existing stack solve this? (Often it does.) +2. How large is the dependency? (Check bundle impact.) +3. Is it actively maintained? (Check last commit, open issues.) +4. Does it have known vulnerabilities? (`npm audit`) +5. What's the license? (Must be compatible with the project.) + +**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability. + +**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline: + +1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks. +2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean. +3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first. +4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes. +5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships. + +For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict. + +## The Review Checklist + +```markdown +## Review: [PR/Change title] + +### Context +- [ ] I understand what this change does and why + +### Correctness +- [ ] Change matches spec/task requirements +- [ ] Edge cases handled +- [ ] Error paths handled +- [ ] Tests cover the change adequately + +### Readability +- [ ] Names are clear and consistent +- [ ] Logic is straightforward +- [ ] No unnecessary complexity + +### Architecture +- [ ] Follows existing patterns +- [ ] No unnecessary coupling or dependencies +- [ ] Appropriate abstraction level +- [ ] Refactors reduce complexity rather than relocate it +- [ ] No feature logic in shared modules; file stays within a healthy size + +### Security +- [ ] No secrets in code +- [ ] Input validated at boundaries +- [ ] No injection vulnerabilities +- [ ] Auth checks in place +- [ ] External data sources treated as untrusted + +### Performance +- [ ] No N+1 patterns +- [ ] No unbounded operations +- [ ] Pagination on list endpoints + +### Verification +- [ ] Tests pass +- [ ] Build succeeds +- [ ] Manual verification done (if applicable) + +### Verdict +- [ ] **Approve** — Ready to merge +- [ ] **Request changes** — Issues must be addressed +``` + +## See Also + +- For detailed security review guidance, see `security-and-hardening` + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. | +| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. | +| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. | +| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. | +| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. | +| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve — look for the version where branches disappear. | +| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. | +| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. | +| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. | + +## Red Flags + +- PRs merged without any review +- Review that only checks if tests pass (ignoring other axes) +- "LGTM" without evidence of actual review +- Security-sensitive changes without security-focused review +- Large PRs that are "too big to review properly" (split them) +- No regression tests with bug fix PRs +- Review comments without severity labels — makes it unclear what's required vs optional +- Accepting "I'll fix it later" — it never happens +- A refactor that moves code around without reducing the number of concepts a reader must hold +- A change that grows an already-large file instead of decomposing it +- New conditionals scattered into unrelated code paths (a missing abstraction) +- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module +- A bulk "bump dependencies" PR with no changelog review and no per-package isolation +- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff + +## Verification + +After review is complete: + +- [ ] All Critical issues are resolved +- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification +- [ ] Tests pass +- [ ] Build succeeds +- [ ] The verification story is documented (what changed, how it was verified) +- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed + +**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant. diff --git a/.opencode/skills/security-and-hardening/SKILL.md b/.opencode/skills/security-and-hardening/SKILL.md new file mode 100644 index 0000000000..a0a80bd617 --- /dev/null +++ b/.opencode/skills/security-and-hardening/SKILL.md @@ -0,0 +1,457 @@ +--- +name: security-and-hardening +description: Hardens code against vulnerabilities. Use when handling user input, authentication, data storage, or external integrations. Use when building any feature that accepts untrusted data, manages user sessions, or interacts with third-party services. +--- + +# Security and Hardening + +## Overview + +Security-first development practices for web applications. Treat every external input as hostile, every secret as sacred, and every authorization check as mandatory. Security isn't a phase — it's a constraint on every line of code that touches user data, authentication, or external systems. + +## When to Use + +- Building anything that accepts user input +- Implementing authentication or authorization +- Storing or transmitting sensitive data +- Integrating with external APIs or services +- Adding file uploads, webhooks, or callbacks +- Handling payment or PII data + +## Process: Threat Model First + +Controls bolted on without a threat model are guesses. Before hardening, spend five minutes thinking like an attacker: + +1. **Map the trust boundaries.** Where does untrusted data cross into your system? HTTP requests, form fields, file uploads, webhooks, third-party APIs, message queues, and **LLM output**. Every boundary is attack surface. +2. **Name the assets.** What's worth stealing or breaking? Credentials, PII, payment data, admin actions, money movement. +3. **Run STRIDE over each boundary** — a quick lens, not a ceremony: + +| Threat | Ask | Typical mitigation | +|---|---|---| +| **S**poofing | Can someone impersonate a user/service? | Authentication, signature verification | +| **T**ampering | Can data be altered in transit or at rest? | Integrity checks, parameterized queries, HTTPS | +| **R**epudiation | Can an action be denied later? | Audit logging of security events | +| **I**nformation disclosure | Can data leak? | Encryption, field allowlists, generic errors | +| **D**enial of service | Can it be overwhelmed? | Rate limiting, input size caps, timeouts | +| **E**levation of privilege | Can a user gain rights they shouldn't? | Authorization checks, least privilege | + +4. **Write abuse cases next to use cases.** For each feature, ask "how would I misuse this?" — then make that your first test. + +If you can't name the trust boundaries for a feature, you're not ready to secure it. This is OWASP **A04: Insecure Design** — most breaches begin in design, not code. + +## The Three-Tier Boundary System + +### Always Do (No Exceptions) + +- **Validate all external input** at the system boundary (API routes, form handlers) +- **Parameterize all database queries** — never concatenate user input into SQL +- **Encode output** to prevent XSS (use framework auto-escaping, don't bypass it) +- **Use HTTPS** for all external communication +- **Hash passwords** with bcrypt/scrypt/argon2 (never store plaintext) +- **Set security headers** (CSP, HSTS, X-Frame-Options, X-Content-Type-Options) +- **Use httpOnly, secure, sameSite cookies** for sessions +- **Run `npm audit`** (or equivalent) before every release + +### Ask First (Requires Human Approval) + +- Adding new authentication flows or changing auth logic +- Storing new categories of sensitive data (PII, payment info) +- Adding new external service integrations +- Changing CORS configuration +- Adding file upload handlers +- Modifying rate limiting or throttling +- Granting elevated permissions or roles + +### Never Do + +- **Never commit secrets** to version control (API keys, passwords, tokens) +- **Never log sensitive data** (passwords, tokens, full credit card numbers) +- **Never trust client-side validation** as a security boundary +- **Never disable security headers** for convenience +- **Never use `eval()` or `innerHTML`** with user-provided data +- **Never store sessions in client-accessible storage** (localStorage for auth tokens) +- **Never expose stack traces** or internal error details to users + +## OWASP Top 10 Prevention Patterns + +These are prevention patterns, not a ranking. For the 2021 ordering, see the quick-reference table in `references/security-checklist.md`. + +### Injection (SQL, NoSQL, OS Command) + +```typescript +// BAD: SQL injection via string concatenation +const query = `SELECT * FROM users WHERE id = '${userId}'`; + +// GOOD: Parameterized query +const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]); + +// GOOD: ORM with parameterized input +const user = await prisma.user.findUnique({ where: { id: userId } }); +``` + +### Broken Authentication + +```typescript +// Password hashing +import { hash, compare } from 'bcrypt'; + +const SALT_ROUNDS = 12; +const hashedPassword = await hash(plaintext, SALT_ROUNDS); +const isValid = await compare(plaintext, hashedPassword); + +// Session management +app.use(session({ + secret: process.env.SESSION_SECRET, // From environment, not code + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, // Not accessible via JavaScript + secure: true, // HTTPS only + sameSite: 'lax', // CSRF protection + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }, +})); +``` + +### Cross-Site Scripting (XSS) + +```typescript +// BAD: Rendering user input as HTML +element.innerHTML = userInput; + +// GOOD: Use framework auto-escaping (React does this by default) +return <div>{userInput}</div>; + +// If you MUST render HTML, sanitize first +import DOMPurify from 'dompurify'; +const clean = DOMPurify.sanitize(userInput); +``` + +### Broken Access Control + +```typescript +// Always check authorization, not just authentication +app.patch('/api/tasks/:id', authenticate, async (req, res) => { + const task = await taskService.findById(req.params.id); + + // Check that the authenticated user owns this resource + if (task.ownerId !== req.user.id) { + return res.status(403).json({ + error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' } + }); + } + + // Proceed with update + const updated = await taskService.update(req.params.id, req.body); + return res.json(updated); +}); +``` + +### Security Misconfiguration + +```typescript +// Security headers (use helmet for Express) +import helmet from 'helmet'; +app.use(helmet()); + +// Content Security Policy +app.use(helmet.contentSecurityPolicy({ + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], // Tighten if possible + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'"], + }, +})); + +// CORS — restrict to known origins +app.use(cors({ + origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000', + credentials: true, +})); +``` + +### Sensitive Data Exposure + +```typescript +// Never return sensitive fields in API responses +function sanitizeUser(user: UserRecord): PublicUser { + const { passwordHash, resetToken, ...publicFields } = user; + return publicFields; +} + +// Use environment variables for secrets +const API_KEY = process.env.STRIPE_API_KEY; +if (!API_KEY) throw new Error('STRIPE_API_KEY not configured'); +``` + +### Server-Side Request Forgery (SSRF) + +Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, `localhost`, private IPs). + +```typescript +// BAD: fetch whatever the user gives you +await fetch(req.body.webhookUrl); + +// GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects +import { lookup } from 'node:dns/promises'; +import ipaddr from 'ipaddr.js'; + +const ALLOWED_HOSTS = new Set(['hooks.example.com']); + +async function assertSafeUrl(raw: string): Promise<URL> { + const url = new URL(raw); + if (url.protocol !== 'https:') throw new Error('https only'); + if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed'); + // Resolve ALL records; a single private/reserved address fails the check. + const addrs = await lookup(url.hostname, { all: true }); + if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) { + throw new Error('private/reserved IP'); + } + return url; +} + +await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' }); +``` + +The `range() !== 'unicast'` check covers loopback, link-local `169.254.169.254` (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6. + +**Caveat — this still has a TOCTOU gap.** `fetch` resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a filtering agent in front (`request-filtering-agent` / `ssrf-req-filter`). + +## Input Validation Patterns + +### Schema Validation at Boundaries + +```typescript +import { z } from 'zod'; + +const CreateTaskSchema = z.object({ + title: z.string().min(1).max(200).trim(), + description: z.string().max(2000).optional(), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + dueDate: z.string().datetime().optional(), +}); + +// Validate at the route handler +app.post('/api/tasks', async (req, res) => { + const result = CreateTaskSchema.safeParse(req.body); + if (!result.success) { + return res.status(422).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + details: result.error.flatten(), + }, + }); + } + // result.data is now typed and validated + const task = await taskService.create(result.data); + return res.status(201).json(task); +}); +``` + +### File Upload Safety + +```typescript +// Restrict file types and sizes +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +const MAX_SIZE = 5 * 1024 * 1024; // 5MB + +function validateUpload(file: UploadedFile) { + if (!ALLOWED_TYPES.includes(file.mimetype)) { + throw new ValidationError('File type not allowed'); + } + if (file.size > MAX_SIZE) { + throw new ValidationError('File too large (max 5MB)'); + } + // Don't trust the file extension — check magic bytes if critical +} +``` + +## Triaging npm audit Results + +Not all audit findings require immediate action. Use this decision tree: + +``` +npm audit reports a vulnerability +├── Severity: critical or high +│ ├── Is the vulnerable code reachable in your app? +│ │ ├── YES --> Fix immediately (update, patch, or replace the dependency) +│ │ └── NO (dev-only dep, unused code path) --> Fix soon, but not a blocker +│ └── Is a fix available? +│ ├── YES --> Update to the patched version +│ └── NO --> Check for workarounds, consider replacing the dependency, or add to allowlist with a review date +├── Severity: moderate +│ ├── Reachable in production? --> Fix in the next release cycle +│ └── Dev-only? --> Fix when convenient, track in backlog +└── Severity: low + └── Track and fix during regular dependency updates +``` + +**Key questions:** +- Is the vulnerable function actually called in your code path? +- Is the dependency a runtime dependency or dev-only? +- Is the vulnerability exploitable given your deployment context (e.g., a server-side vulnerability in a client-only app)? + +When you defer a fix, document the reason and set a review date. + +### Supply-Chain Hygiene + +`npm audit` catches known CVEs; it won't catch a malicious or typosquatted package. Also: + +- **Commit the lockfile** and install with `npm ci` (not `npm install`) in CI — reproducible builds, no silent version drift. +- **Review new dependencies before adding them** — maintenance, download counts, and whether they truly earn their place. Every dependency is attack surface (OWASP **A06: Vulnerable Components**, **LLM03: Supply Chain**). +- **Be wary of `postinstall` scripts** in unfamiliar packages — they run arbitrary code at install time. +- **Watch for typosquats** — `cross-env` vs `crossenv`, `react-dom` vs `reactdom`. + +## Rate Limiting + +```typescript +import rateLimit from 'express-rate-limit'; + +// General API rate limit +app.use('/api/', rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 requests per window + standardHeaders: true, + legacyHeaders: false, +})); + +// Stricter limit for auth endpoints +app.use('/api/auth/', rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, // 10 attempts per 15 minutes +})); +``` + +## Secrets Management + +``` +.env files: + ├── .env.example → Committed (template with placeholder values) + ├── .env → NOT committed (contains real secrets) + └── .env.local → NOT committed (local overrides) + +.gitignore must include: + .env + .env.local + .env.*.local + *.pem + *.key +``` + +**Always check before committing:** +```bash +# Check for accidentally staged secrets +git diff --cached | grep -i "password\|secret\|api_key\|token" +``` + +**If a secret is ever committed, rotate it.** Deleting the line or rewriting history is not enough — assume it's compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history. + +## Securing AI / LLM Features + +If your app calls an LLM — chatbots, summarizers, agents, RAG — it inherits a new attack surface. Map it to the [OWASP Top 10 for LLM Applications (2025)](https://genai.owasp.org/llm-top-10/): + +- **Treat all model output as untrusted input (LLM05: Improper Output Handling).** Never pass LLM output straight into `eval`, SQL, a shell, `innerHTML`, or a file path. Validate and encode it exactly as you would raw user input. +- **Assume prompts can be hijacked (LLM01: Prompt Injection).** Untrusted text in the context window — a user message, a fetched web page, a PDF — can carry instructions. The system prompt is not a security boundary; enforce permissions in code, not in the prompt. +- **Keep secrets and other users' data out of prompts (LLM02 / LLM07).** Anything in the context can be echoed back. Don't put API keys, cross-tenant data, or the full system prompt where the model can repeat it. +- **Constrain tool and agent permissions (LLM06: Excessive Agency).** Scope tools to the minimum, require confirmation for destructive or irreversible actions, and validate every tool argument. +- **Bound consumption (LLM10: Unbounded Consumption).** Cap tokens, request rate, and loop/recursion depth so a crafted input can't run up cost or hang the system. +- **Isolate retrieval data (LLM08: Vector and Embedding Weaknesses).** In RAG, treat the vector store as a trust boundary: partition embeddings per tenant so one user can't retrieve another's data, and validate documents before indexing so poisoned content can't steer answers. + +```typescript +// BAD: trusting model output as a command or as markup +const sql = await llm.generate(`Write SQL for: ${userQuestion}`); +await db.query(sql); // arbitrary query execution +container.innerHTML = await llm.reply(userMessage); // stored XSS, via the model + +// GOOD: model output is data — parse defensively, then validate, then encode +let intent; +try { + intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage))); +} catch { + throw new ValidationError('unexpected model output'); // JSON.parse or schema failed +} +await runAllowlistedAction(intent.action, intent.params); +container.textContent = await llm.reply(userMessage); +``` + +## Security Review Checklist + +```markdown +### Authentication +- [ ] Passwords hashed with bcrypt/scrypt/argon2 (salt rounds ≥ 12) +- [ ] Session tokens are httpOnly, secure, sameSite +- [ ] Login has rate limiting +- [ ] Password reset tokens expire + +### Authorization +- [ ] Every endpoint checks user permissions +- [ ] Users can only access their own resources +- [ ] Admin actions require admin role verification + +### Input +- [ ] All user input validated at the boundary +- [ ] SQL queries are parameterized +- [ ] HTML output is encoded/escaped +- [ ] Server-side URL fetches are allowlisted (no SSRF to internal services) + +### Data +- [ ] No secrets in code or version control +- [ ] Sensitive fields excluded from API responses +- [ ] PII encrypted at rest (if applicable) + +### Infrastructure +- [ ] Security headers configured (CSP, HSTS, etc.) +- [ ] CORS restricted to known origins +- [ ] Dependencies audited for vulnerabilities +- [ ] Error messages don't expose internals + +### Supply Chain +- [ ] Lockfile committed; CI installs with `npm ci` +- [ ] New dependencies reviewed (maintenance, downloads, postinstall scripts) + +### AI / LLM (if used) +- [ ] Model output treated as untrusted (no eval/SQL/innerHTML/shell) +- [ ] Secrets and other users' data kept out of prompts +- [ ] Tool/agent permissions scoped; destructive actions require confirmation +``` +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "This is an internal tool, security doesn't matter" | Internal tools get compromised. Attackers target the weakest link. | +| "We'll add security later" | Security retrofitting is 10x harder than building it in. Add it now. | +| "No one would try to exploit this" | Automated scanners will find it. Security by obscurity is not security. | +| "The framework handles security" | Frameworks provide tools, not guarantees. You still need to use them correctly. | +| "It's just a prototype" | Prototypes become production. Security habits from day one. | +| "Threat modeling is overkill here" | Five minutes of "how would I attack this?" prevents the design flaws no control can patch later. | +| "It's just LLM output, it's only text" | That "text" can be a SQL statement, a script tag, or a shell command. Treat it like any untrusted input. | + +## Red Flags + +- User input passed directly to database queries, shell commands, or HTML rendering +- Secrets in source code or commit history +- API endpoints without authentication or authorization checks +- Missing CORS configuration or wildcard (`*`) origins +- No rate limiting on authentication endpoints +- Stack traces or internal errors exposed to users +- Dependencies with known critical vulnerabilities +- Server fetches user-supplied URLs without an allowlist (SSRF) +- LLM/model output passed into a query, the DOM, a shell, or `eval` +- Secrets, PII, or the full system prompt placed inside an LLM context window + +## Verification + +After implementing security-relevant code: + +- [ ] `npm audit` shows no critical or high vulnerabilities +- [ ] No secrets in source code or git history +- [ ] All user input validated at system boundaries +- [ ] Authentication and authorization checked on every protected endpoint +- [ ] Security headers present in response (check with browser DevTools) +- [ ] Error responses don't expose internal details +- [ ] Rate limiting active on auth endpoints +- [ ] Server-side URL fetches validated against an allowlist (no SSRF) +- [ ] LLM/model output validated and encoded before use (if AI features present) From 85dbf14344376946fcefc1d6665a760ba00ba2cc Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 13 Jul 2026 09:50:43 +0200 Subject: [PATCH 44/63] :paperclip: Add better planner skill and improve testing doc --- .gitignore | 3 + .opencode/skills/planner/SKILL.md | 254 ++++++++++++++---- .serena/memories/backend/core.md | 2 +- .serena/memories/common/core.md | 2 +- .serena/memories/common/testing-principles.md | 47 ---- .serena/memories/critical-info.md | 1 + .serena/memories/exporter/core.md | 1 + .serena/memories/frontend/core.md | 2 +- .serena/memories/library/core.md | 1 + .serena/memories/mcp/core.md | 1 + .serena/memories/plugins/core.md | 1 + .serena/memories/render-wasm/core.md | 1 + .serena/memories/testing.md | 149 ++++++++++ 13 files changed, 356 insertions(+), 109 deletions(-) delete mode 100644 .serena/memories/common/testing-principles.md create mode 100644 .serena/memories/testing.md diff --git a/.gitignore b/.gitignore index 7c3309ac20..dfdb8bae03 100644 --- a/.gitignore +++ b/.gitignore @@ -99,5 +99,8 @@ opencode.json /.playwright-mcp /.devenv/mcp/ /opencode.json +/.opencode/plans +/.opencode/reports +/.opencode/prompts /.codex/ /tools/__pycache__ diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md index 7886dda85f..d1802652e2 100644 --- a/.opencode/skills/planner/SKILL.md +++ b/.opencode/skills/planner/SKILL.md @@ -1,6 +1,6 @@ --- name: planner -description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to plans/YYYY-MM-DD-<title>.md only when the calling agent has write permission. +description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md. --- # Planner @@ -17,7 +17,7 @@ or modifies code. names, and test strategy. - The user asks "how would I implement X?" or "what's involved in fixing Y?". - The user is about to start non-trivial work and wants a bite-sized task - breakdown (DRY, YAGNI, TDD, frequent commits). + breakdown. Do **not** use this skill to actually implement anything — it is read-only. @@ -29,13 +29,8 @@ modify code. You help users understand the codebase, design solutions, and create detailed implementation plans that other agents or developers can execute. Document -everything they need to know: which files to touch for each task, code, tests, -docs they might need to check, and how to verify it. Give them the whole plan -as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. - -Assume the implementer is a skilled developer, but knows almost nothing about -our toolset or problem domain. Assume they don't know good test design very -well. +everything they need to know: which files to touch for each task, code patterns, +tests, and how to verify correctness. Apply DRY and KISS principles. Do **not** suggest commit messages or commit names anywhere in your plans or responses — committing is the developer's responsibility. @@ -44,92 +39,233 @@ responses — committing is the developer's responsibility. Before drafting any plan, work through the project's own guidance: -1. Read `AGENTS.md` (root) for the project-level rules. -2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to - identify which modules are affected. +1. Read `critical-info` (`.serena/memories/critical-info.md`) — the entry point + that describes the monorepo structure and module dependency graph. +2. From `critical-info`, identify which modules your task affects. 3. Read each affected module's core memory, e.g. `mem:frontend/core`, `mem:backend/core`, `mem:common/core`, `mem:exporter/core`, `mem:render-wasm/core`. Follow `mem:` references deeper as needed. -4. For frontend/backend work, check the relevant section's notes on lint, - format, and test commands so the plan can include them. +4. For each affected module, note its lint, format, and test commands so the + plan can include concrete verification steps. Skipping this step is the #1 cause of incorrect or incomplete plans. +## The Planning Process + +### Phase 1: Architecture Analysis + +1. Read the spec, requirements, or feature request. +2. Analyze the codebase architecture and identify affected modules. +3. Read project conventions (starting with `critical-info` and module core + memories) before drafting. +4. Map dependencies between components (see the dependency graph in + `critical-info`). +5. Identify risks, edge cases, performance implications, and breaking changes. + +### Phase 2: Task Breakdown + +Implementation order follows the monorepo's dependency graph: +`frontend -> common`, `backend -> common`, `exporter -> common`, +`frontend -> render-wasm`. Build shared foundations first, then layer +consumers on top. + +#### Slice Vertically + +Instead of building all of common, then all of backend, then all of frontend — +build one complete feature path at a time: + +``` +Task 1: common data types + schema ← foundation +Task 2: backend RPC handler + persistence +Task 3: frontend UI component + API integration +``` + +Each vertical slice delivers working, testable functionality. + +#### Write Tasks + +Each task follows this structure: + +```markdown +## Task [N]: [Short descriptive title] + +**Description:** One paragraph explaining what this task accomplishes. + +**Acceptance criteria:** +- [ ] [Specific, testable condition] +- [ ] [Specific, testable condition] + +**Verification:** +- [ ] Tests pass (module-specific test command) +- [ ] Lint/formatter passes (module-specific check command) + +**Dependencies:** [Task numbers this depends on, or "None"] + +**Files likely touched:** +- `path/to/file.clj` +- `path/to/file_test.clj` +``` + +Replace "module-specific test command" with the actual commands for the module +(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend, +or the commands noted in the module's core memory). + +#### Estimate Scope + +| Size | Files | Scope | +|------|-------|-------| +| **XS** | 1 | Single function, config change, or schema tweak | +| **S** | 1-2 | One handler or component method | +| **M** | 3-5 | One vertical feature slice | +| **L** | 5-8 | Multi-component feature | +| **XL** | 8+ | **Too large — break it down further** | + +If a task is L or larger, break it into smaller tasks. Agents perform best on +S and M tasks. + +**When to break a task down further:** +- It would take more than one focused session +- You cannot describe the acceptance criteria in 3 or fewer bullet points +- It touches two or more independent subsystems +- You find yourself writing "and" in the task title (a sign it is two tasks) + +#### Order and Checkpoints + +Arrange tasks so that: + +1. Dependencies are satisfied (build foundation first) +2. Each task leaves the system in a working state +3. Verification checkpoints occur after every 2-3 tasks +4. High-risk tasks are early (fail fast) + +Add explicit checkpoints with the relevant module commands: + +```markdown +## Checkpoint: After Tasks 1-3 +- [ ] All tests pass (module-specific command) +- [ ] Lint/format passes (module-specific command) +- [ ] Core flow works end-to-end +- [ ] Review with human before proceeding +``` + ## Requirements - Analyze the codebase architecture and identify affected modules. -- Read `AGENTS.md` and the memory system conventions before drafting. +- Read project conventions before drafting (start with `critical-info` and + affected module core memories). - Break down complex features or bugs into atomic, actionable steps. - Propose solutions with clear rationale, trade-offs, and sequencing. - Identify risks, edge cases, performance implications, and breaking changes. - Apply DRY and KISS principles to the proposed implementation. - Define a testing strategy aligned with each affected module's tooling. +- Every task must have acceptance criteria and verification steps. +- Checkpoints must exist between major phases. ## Constraints - You are **analysis-only** — never create, edit, or delete source code. -- The only file write you may attempt is the plan itself, and only when the - calling agent has write permission (see "Plan Output"). If the write is - denied, deliver the plan in the response and move on. +- The only file write you may attempt is the plan itself, saved to + `.opencode/plans/`. - You do **not** run builds, tests, linters, or any commands that modify state. - You do **not** create git commits or interact with version control. -- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, - `find`, `cat`, `bat`). +- You do **not** execute shell commands beyond read-only searches. - Your output is a structured plan or analysis, ready for handoff to an engineer agent or developer. -## Plan Output +## Output Format The plan is always delivered in the response so the user sees it regardless of which agent is running the skill. -Persistence is a **separate, best-effort step** that only runs when the -calling agent has `edit` write permission: +Additionally, save the plan to: -- **Has write permission** (e.g. `build`, `general`, `engineer`): in addition - to the in-response plan, save the plan to: +``` +.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md +``` - ``` - plans/YYYY-MM-DD-<plan-one-line-title>.md - ``` +Use today's date in the user's local timezone. The `<plan-one-line-title>` +slug is lowercase, hyphen-separated, and a short summary of the task +(e.g. `add-batch-get-profiles-for-file-comments`). Create the +`.opencode/plans/` directory if it does not exist. - Use today's date in the user's local timezone. The `<plan-one-line-title>` - slug is lowercase, hyphen-separated, and a short summary of the task - (e.g. `add-batch-get-profiles-for-file-comments`). Create the `plans/` - directory if it does not exist. +Always attempt the write. If the user explicitly provides a target file path, +use that path instead of the default. -- **No write permission** (e.g. the built-in `plan` agent, which denies - `edit`): do not attempt to write the file — the write tool will be - rejected. Just deliver the plan in the response. The user can copy it into - `plans/...` manually if they want it persisted. +### Plan Document Template -If the user explicitly provides a target file path, use that path instead of -the default `plans/YYYY-MM-DD-<slug>.md` (still subject to write permission). +```markdown +# Plan: [Feature/Project Name] -How to detect write permission: try the write. If it is denied, treat the -plan as response-only and proceed — do not retry, do not ask the user, and do -not mention the failed write in the response. +## Context +[One paragraph: what is the problem or feature request? Why is it needed?] -## Output Format +## Affected Modules +[Which modules of the monorepo are involved? Reference module paths and any +`mem:` memories that were consulted.] -Structure the plan as: +## Architecture Decisions +- [Key decision 1 and rationale] +- [Key decision 2 and rationale] -1. **Context** — What is the problem or feature request? Why is it needed? -2. **Affected modules** — Which parts of the codebase are involved? Reference - module paths and any `mem:` memories that were consulted. -3. **Approach** — Step-by-step implementation plan with file paths, function - names, and code shape where applicable. Group steps into atomic, ordered - tasks. -4. **Risks & considerations** — Edge cases, performance implications, - breaking changes, migration concerns, security implications. -5. **Testing strategy** — How to verify the implementation works correctly: - which test commands to run per module, what cases to cover, manual - verification steps, lint/format checks. +## Risks & Considerations +[Edge cases, performance implications, breaking changes, migration concerns, +security implications.] -Each step in **Approach** should be small enough to be reviewed and committed -independently. Cite exact file paths (`path/to/file.ext:line` when useful) so -the implementer can navigate directly. +## Approach +[Step-by-step implementation plan with file paths, function names, and code +shape where applicable. Group steps into atomic, ordered tasks.] + +## Task List + +### Phase 1: Foundation +- [ ] Task 1: ... +- [ ] Task 2: ... + +### Checkpoint: Phase 1 +- [ ] Tests pass, lint/formatter clean (module-specific commands) + +### Phase 2: Core Features +- [ ] Task 3: ... +- [ ] Task 4: ... + +### Checkpoint: Phase 2 +- [ ] End-to-end flow works + +### Phase 3: Polish +- [ ] Task 5: ... +- [ ] Task 6: ... + +### Checkpoint: Complete +- [ ] All acceptance criteria met +- [ ] Ready for review + +## Testing Strategy +[How to verify: which test commands to run per module, what cases to cover, +manual verification steps, lint/format checks. Consult each module's core +memory for the exact commands.] + +## Parallelization Opportunities +- **Safe to parallelize:** Independent feature slices across separate + modules, tests for already-implemented features +- **Must be sequential:** Shared common schema changes, database migrations +- **Needs coordination:** Features that share a contract (define the contract + first, then parallelize) + +## Open Questions +- [Question needing human input] +``` When the plan is purely analytical (e.g. a code review or feasibility study -with no implementation), skip the **Approach** section and lead with -**Findings** instead, keeping the rest of the structure. +with no implementation), skip the **Approach** and **Task List** sections and +lead with **Findings** instead, keeping the rest of the structure. + +## Verification Checklist + +Before starting implementation, confirm: + +- [ ] Every task has acceptance criteria +- [ ] Every task has a verification step +- [ ] Task dependencies are identified and ordered correctly +- [ ] No task touches more than ~5 files +- [ ] Checkpoints exist between major phases +- [ ] The human has reviewed and approved the plan diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index f416b78cf8..12a9bcda25 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -106,4 +106,4 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. -* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. +* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index 23fbe20198..6ae8f084e8 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -49,7 +49,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. - Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. -- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. +- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. ## Areas without focused memories diff --git a/.serena/memories/common/testing-principles.md b/.serena/memories/common/testing-principles.md deleted file mode 100644 index 860852393b..0000000000 --- a/.serena/memories/common/testing-principles.md +++ /dev/null @@ -1,47 +0,0 @@ -# Testing Principles (Cross-Cutting) - -Shared testing principles for all modules (backend, frontend, common, render-wasm, plugins). Module-specific commands and helpers are in each module's own testing memory. - -## Core Principles - -- **Test State, Not Interactions** — assert on outcomes, not method calls; survives refactoring -- **DAMP over DRY** — tests are specifications; duplication OK if each test is self-contained and readable -- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; mock only at boundaries (network, filesystem, email) -- **Arrange-Act-Assert** — every test: setup / action / verify -- **One Assertion Per Concept** — each test verifies one behavior; split compound assertions -- **Descriptive Test Names** — names read like specifications - -## Anti-Patterns - -- Testing implementation details → breaks on refactor -- Flaky tests (timing, order-dependent) → erode trust -- Mocking everything → tests pass, production breaks -- No test isolation → pass individually, fail together -- Testing framework code → waste of time -- Snapshot abuse → nobody reviews, break on any change - -## Verification Checklist - -After completing any implementation: -- Every new behavior has a corresponding test -- All tests pass for touched modules -- Bug fixes include a reproduction test that failed before the fix -- Test names describe the behavior being verified -- No tests were skipped or disabled - -## Red Flags - -- Writing code without corresponding tests -- Tests that pass on the first run (may not be testing what you think) -- Bug fixes without reproduction tests -- Tests that test framework behavior instead of app behavior -- Skipping tests to make the suite pass - -## Rationalizations - -- "I'll write tests after" → you won't; they'll test implementation not behavior -- "Too simple to test" → simple gets complicated; test documents expected behavior -- "Tests slow me down" → they speed up every future change -- "I tested manually" → manual testing doesn't persist -- "Code is self-explanatory" → tests ARE the specification -- "Just a prototype" → prototypes become production; test debt accumulates diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 1027829bcf..d291c81bf9 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -6,6 +6,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - A section's top-level memory is `<section>/core`. When a section is relevant, read the core memory before focused memories. - Edits/stale refs/duplication cleanup: `mem:memory-maintenance`. +- Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`. # Development workflow diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index da61317d63..3bcf784f49 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -7,6 +7,7 @@ - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. - From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. +- Cross-cutting testing principles and anti-patterns: `mem:testing`. ## HTTP and browser pool diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index b890de6770..5f06f8cfb0 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -52,7 +52,7 @@ Diagnostics and validation: - Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`. - Runtime crash recovery: `mem:frontend/handling-crashes`. - Tests and live verification: `mem:frontend/testing`. -- Cross-cutting testing principles and anti-patterns: `mem:common/testing-principles`. +- Cross-cutting testing principles and anti-patterns: `mem:testing`. - Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`. ## Areas without focused memories diff --git a/.serena/memories/library/core.md b/.serena/memories/library/core.md index dec9347c6f..7ff57c74b2 100644 --- a/.serena/memories/library/core.md +++ b/.serena/memories/library/core.md @@ -7,6 +7,7 @@ - Source: `library/src/`; tests: `library/test/`; experimentation/docs: `playground/`, `docs/`; config: `shadow-cljs.edn`, `deps.edn`, `package.json`. - From `library/`: build `pnpm run build`; bundle helper `pnpm run build:bundle` or `./scripts/build`; tests `pnpm run test`; watch `pnpm run watch` / `pnpm run watch:test`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. - When changing file-format construction or export behavior in `common/`, consider whether `@penpot/library` should be tested because it constructs Penpot files outside the app UI. +- Cross-cutting testing principles and anti-patterns: `mem:testing`. ## JS API and builder state diff --git a/.serena/memories/mcp/core.md b/.serena/memories/mcp/core.md index 772f7a3949..7aeb784470 100644 --- a/.serena/memories/mcp/core.md +++ b/.serena/memories/mcp/core.md @@ -85,6 +85,7 @@ From the `mcp/` directory, run * `pnpm run build` to test the build of all packages * `pnpm run fmt` to apply the auto-formatter +* Cross-cutting testing principles and anti-patterns: `mem:testing`. ## Devenv plugin/server wiring diff --git a/.serena/memories/plugins/core.md b/.serena/memories/plugins/core.md index ea51e5dad6..b042c34aa8 100644 --- a/.serena/memories/plugins/core.md +++ b/.serena/memories/plugins/core.md @@ -13,6 +13,7 @@ - From `plugins/`: install `pnpm -r install`; runtime dev server `pnpm run start` or `pnpm run start:app:runtime`; sample plugin `pnpm run start:plugin:<name>`; build runtime `pnpm run build:runtime`; build plugins `pnpm run build:plugins`; lint `pnpm run lint`; format `pnpm run format:check` / `pnpm run format`; tests `pnpm run test`; e2e `pnpm run test:e2e`. - If a change affects public Plugin API types or runtime, update `plugins/CHANGELOG.md`. Prefix type/signature entries with `**plugin-types:**`; runtime behavior entries with `**plugin-runtime:**`. +- Cross-cutting testing principles and anti-patterns: `mem:testing`. - JS Plugin API behavior inside Penpot app: `mem:frontend/plugin-api-to-cljs-binding`; TS declarations are not runtime code; many API objects are CLJS proxies in `frontend/src/app/plugins/*.cljs`. ## Sandbox and global cleanup diff --git a/.serena/memories/render-wasm/core.md b/.serena/memories/render-wasm/core.md index 30734d2b0d..17345b7a7d 100644 --- a/.serena/memories/render-wasm/core.md +++ b/.serena/memories/render-wasm/core.md @@ -26,6 +26,7 @@ From `render-wasm/`: - Build/copy frontend artifacts: `./build`. - Watch rebuild: `./watch`. - Rust tests: `./test` or `cargo test <name>`. +- Cross-cutting testing principles and anti-patterns: `mem:testing`. - Lint: `./lint`. - Format check: `cargo fmt --check`. diff --git a/.serena/memories/testing.md b/.serena/memories/testing.md new file mode 100644 index 0000000000..2f9f4c9131 --- /dev/null +++ b/.serena/memories/testing.md @@ -0,0 +1,149 @@ +# Testing + +## Overview + +Tests are proof that code works. Every behavior change needs a test. + +Testing in this monorepo varies by module. Each module has its own test +commands, helpers, runner registration requirements, and conventions. This +memory covers cross-cutting testing principles. For module-specific commands +and helpers, consult: + +- `mem:common/testing` — CLJC unit tests (JVM + JS), test helpers, fixture + builders, production-path change helpers +- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests, + live browser verification via nREPL +- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core` + +## When to Use + +- Implementing new logic or behavior +- Fixing any bug (reproduction test required) +- Modifying existing functionality +- Adding edge case handling + +**When NOT to use:** Pure configuration changes, documentation updates, or +static content changes with no behavioral impact. + +## TDD: Recommended Workflow + +Write a failing test before writing the code that makes it pass. For bug fixes, +reproduce the bug with a test before attempting a fix. + +When TDD isn't practical (exploratory work, tight coupling to unknown APIs), +still write tests before considering the work complete. + +``` + RED GREEN REFACTOR + Write a test Write minimal code Clean up the + that fails ──→ to make it pass ──→ implementation ──→ (repeat) + │ │ │ + ▼ ▼ ▼ + Test FAILS Test PASSES Tests still PASS +``` + +- **RED** — Write the test first. It must fail. A test that passes immediately + proves nothing. +- **GREEN** — Write the minimum code to make the test pass. Don't over-engineer. +- **REFACTOR** — With tests green, improve the code without changing behavior: + extract shared logic, improve naming, remove duplication. Run tests after + every step. + +## The Prove-It Pattern (Bug Fixes) + +When a bug is reported, **do not start by trying to fix it.** Start by writing +a test that reproduces it: + +1. Write a test that demonstrates the bug +2. Confirm the test FAILS (proving the bug exists) +3. Implement the fix +4. Confirm the test PASSES (proving the fix works) +5. Run the full test suite for the module (no regressions) + +## Core Principles + +- **Test State, Not Interactions** — assert on outcomes, not method calls; + survives refactoring +- **DAMP over DRY** — tests are specifications; duplication is OK if each test + is self-contained and readable. A test should tell a complete story without + requiring the reader to trace through shared helpers. +- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; + mock only at boundaries (network, RPC, filesystem, email) +- **Arrange-Act-Assert** — every test: setup / action / verify +- **One Assertion Per Concept** — each test verifies one behavior; split + compound assertions +- **Descriptive Test Names** — names read like specifications + +## Prefer Real Implementations Over Mocks + +Work down this list: + +1. **Real implementation** — Test the actual code with real collaborators. + Highest confidence. +2. **Fake** — A simplified but functional in-memory implementation (e.g. + atom/dict-backed store instead of a real database). +3. **Stub** — Returns canned data. Use when the collaborator's logic is + irrelevant. +4. **Mock** — Last resort, only at boundaries. Use only when verifying + interaction with an external system that cannot be faked. + +**Rule of thumb:** If you can write a fake or use the real implementation, do +that. If you find yourself asserting on call counts or invocation order, ask +whether a fake would be clearer. + +## Fixtures over Manual Setup + +Use fixture/`beforeEach` mechanisms for shared setup and teardown. Each test +should own its state so tests don't interfere with each other. Shorter-scope +fixtures (`:each` / per-test) are preferred; longer-scope fixtures (`:once` / +suite-level) are only for expensive, immutable shared setup. + +## Parametrized Tests + +Use your test framework's parametrize/table-driven mechanism to test multiple +scenarios with a single test body. Keeps tests concise and surfaces all cases +at a glance. + +## Test Pyramid + +``` + ╱╲ + ╱ ╲ E2E (few) + ╱ ╲ Full flows, real browser/server + ╱──────╲ + ╱ ╲ Integration (some) + ╱ ╲ Cross-module, test DB + ╱────────────╲ + ╱ ╲ Unit (most) + ╱ ╲ Pure logic, fast + ╱──────────────────╲ +``` + +Prefer unit tests for pure logic. Reach for integration/E2E tests when covering +RPC handlers, database queries, or full user flows. In the frontend, Playwright +E2E tests should not be added unless explicitly requested. + +## Anti-Patterns + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| Testing implementation details | Breaks on refactor | Test inputs/outputs | +| Flaky tests (timing, order-dependent) | Erodes trust | Deterministic assertions, isolate state | +| Mocking everything | Tests pass, production breaks | Prefer real implementations or fakes | +| No test isolation | Pass individually, fail together | Per-test state fixtures | +| Testing framework/platform code | Wastes time | Only test YOUR code | +| Snapshot abuse | Nobody reviews, break on any change | Focused assertions | +| Skipping tests to make suite pass | Hides real failures | Fix the test or fix the code | + +## Verification Checklist + +After completing any implementation: + +- [ ] Every new behavior has a corresponding test +- [ ] All tests pass for touched modules +- [ ] Bug fixes include a reproduction test that failed before the fix +- [ ] Test names describe the behavior being verified +- [ ] No tests were skipped or disabled +- [ ] Lint/formatter passes for touched modules +- [ ] New test files registered in the module's runner/entrypoint (see module + testing memory) From ab58d00d664355ab04fa9bb37174ec7dea8647cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Tue, 14 Jul 2026 07:43:52 +0200 Subject: [PATCH 45/63] :zap: Improve bool intersection perfomance (#10671) --- .../app/main/data/workspace/modifiers.cljs | 9 ++- .../src/app/main/data/workspace/shapes.cljs | 17 ++++- frontend/src/app/render_wasm/api.cljs | 25 ++++---- render-wasm/src/math/bools.rs | 62 ++++++++++++++++--- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index b5696414c8..41f14c0eea 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -838,8 +838,13 @@ (dwsh/update-shapes ids update-shape options) ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true))) + ;; needs to have the updated children info. + ;; `update-layout? false`: recalculating a bool path can never change + ;; `:hidden`, and the layout check would recompute the whole boolean + ;; path in WASM once per bool shape just to find that out. + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options + :with-objects? true + :update-layout? false))) (if undo-transation? (rx/of (dwu/commit-undo-transaction undo-id)) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 771f3147d9..ac3ccae82b 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -154,12 +154,14 @@ ([ids update-fn {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation?] + ignore-touched undo-group with-objects? changed-sub-attr translation? + update-layout?] :or {reg-objects? false save-undo? true stack-undo? false ignore-touched false - with-objects? false}}] + with-objects? false + update-layout? true}}] (assert (every? uuid? ids) "expect a coll of uuid for `ids`") (assert (fn? update-fn) "the `update-fn` should be a valid function") @@ -181,8 +183,17 @@ (filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?}))) (map :id)) + ;; `changed-attrs` runs `update-fn` in full for every shape, which + ;; can be expensive (e.g. `update-bool-shape` recalculates the whole + ;; boolean path in WASM). Skip the pass entirely when we can prove it + ;; cannot match: when the caller declares `attrs`, `changed-attrs` + ;; filters its result to that set, so if no layout attr is present + ;; the check is always empty. update-layout-ids - (when-not translation? + (when-not (or translation? + (not update-layout?) + (and (some? attrs) + (not (some update-layout-attr? attrs)))) (->> (into [] xf-update-layout ids) (not-empty))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 3a5357d5d1..e0273d0125 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2498,19 +2498,22 @@ ;; After the content is returned we discard that temporary context (h/call wasm/internal-module "_start_temp_objects") - (let [bool-type (get shape :bool-type) - ids (get shape :shapes) - all-children - (->> ids - (mapcat #(cfh/get-children-with-self objects %)))] + (try + (let [bool-type (get shape :bool-type) + ids (get shape :shapes) + all-children + (->> ids + (mapcat #(cfh/get-children-with-self objects %)))] - (h/call wasm/internal-module "_init_shapes_pool" (count all-children)) - (run! set-object all-children) + (h/call wasm/internal-module "_init_shapes_pool" (count all-children)) + (run! set-object all-children) - (let [content (-> (calculate-bool* bool-type ids) - (path.impl/path-data))] - (h/call wasm/internal-module "_end_temp_objects") - content))) + (-> (calculate-bool* bool-type ids) + (path.impl/path-data))) + (finally + ;; Always restore the main shapes pool: leaving the temp pool + ;; active would make the next `_start_temp_objects` panic. + (h/call wasm/internal-module "_end_temp_objects")))) (def POSITION-DATA-U8-SIZE 36) (def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) diff --git a/render-wasm/src/math/bools.rs b/render-wasm/src/math/bools.rs index 0f7e01612d..c1f5b82edc 100644 --- a/render-wasm/src/math/bools.rs +++ b/render-wasm/src/math/bools.rs @@ -82,14 +82,31 @@ pub fn split_intersections(segment: Bezier, intersections: &[f64]) -> Vec<Bezier } let mut result = Vec::new(); - let mut intersections = intersections.to_owned(); + // Clamp to the valid parametric range: `intersections()`/`project()` can + // return values a hair outside [0,1] due to float error, which would make + // `split` panic on its `(0.0..=1.).contains(&t)` assertion below. + let mut intersections: Vec<f64> = intersections.iter().map(|t| t.clamp(0.0, 1.0)).collect(); intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); let mut prev = 0.0; let mut cur_segment = segment; for t_i in &intersections { - let rti = (t_i - prev) / (1.0 - prev); + // Skip duplicated split points (the same crossing can be reported by + // two adjacent opposing segments that share an endpoint): re-splitting + // at (almost) the same t would emit a zero-length sliver segment whose + // midpoint containment test is unstable in union/difference/intersection. + if *t_i - prev < 1e-6 { + continue; + } + let denom = 1.0 - prev; + // Degenerate split (prev already at the segment end); nothing left to cut. + if denom <= f64::EPSILON { + continue; + } + // Re-normalize the global t into the remaining segment, clamped so float + // noise / out-of-order duplicates can never push it outside [0,1]. + let rti = ((t_i - prev) / denom).clamp(0.0, 1.0); let [s, rest] = cur_segment.split(TValue::Parametric(rti)); prev = *t_i; cur_segment = rest; @@ -110,21 +127,52 @@ pub fn split_segments(path_a: &Path, path_b: &Path) -> (Vec<Bezier>, Vec<Bezier> let mut intersects_b = Vec::<Vec<f64>>::with_capacity(path_b.len()); intersects_b.resize_with(path_b.len(), Default::default); + // Broad-phase: precompute a conservative (control-hull) AABB per segment, + // padded by the intersection tolerance. Two segments can only intersect if + // their boxes overlap, so we skip the expensive `intersections()` call for + // the (typically vast) majority of non-overlapping pairs. This turns the + // O(A*B) inner loop from A*B curve-subdivision solves into A*B cheap box + // tests plus only the handful of solves that can actually produce a hit. + let bbox = |b: &Bezier| { + let [min, max] = b.bounding_box_of_anchors_and_handles(); + [ + DVec2::new(min.x - INTERSECT_ERROR, min.y - INTERSECT_ERROR), + DVec2::new(max.x + INTERSECT_ERROR, max.y + INTERSECT_ERROR), + ] + }; + let boxes_a: Vec<[DVec2; 2]> = path_a.iter().map(bbox).collect(); + let boxes_b: Vec<[DVec2; 2]> = path_b.iter().map(bbox).collect(); + for i in 0..path_a.len() { + let [amin, amax] = boxes_a[i]; for j in 0..path_b.len() { + let [bmin, bmax] = boxes_b[j]; + // AABB overlap test; skip pairs that cannot intersect. + if amin.x > bmax.x || bmin.x > amax.x || amin.y > bmax.y || bmin.y > amax.y { + continue; + } let segment_a = path_a[i]; let segment_b = path_b[j]; - let intersections_a = segment_a.intersections( + let mut intersections_a = segment_a.intersections( &segment_b, Some(INTERSECT_ERROR), Some(INTERSECT_MIN_SEPARATION), ); + // Clamp at the source: float error can report a t just outside + // [0,1], and every `TValue::Parametric` consumer downstream + // (`evaluate`, `project`, `split`) asserts `(0.0..=1.).contains(&t)`. + for t in intersections_a.iter_mut() { + *t = t.clamp(0.0, 1.0); + } + intersects_b[j].extend(intersections_a.iter().map(|t_a| { - segment_b.project( - segment_a.evaluate(TValue::Parametric(*t_a)), - Some(PROJECT_OPTS), - ) + segment_b + .project( + segment_a.evaluate(TValue::Parametric(*t_a)), + Some(PROJECT_OPTS), + ) + .clamp(0.0, 1.0) })); intersects_a[i].extend(intersections_a); From c11d3aaaa74d42e7c9a4a4a87c35445f5e58982c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Tue, 14 Jul 2026 10:26:19 +0200 Subject: [PATCH 46/63] :bug: Add :stroke-image support to plugins API (#10683) --- frontend/src/app/plugins/format.cljs | 7 ++-- frontend/src/app/plugins/parser.cljs | 4 ++- frontend/src/app/plugins/strokes.cljs | 10 ++++-- .../src/generated/api-surface.json | 7 ++++ .../src/tests/media.test.ts | 33 +++++++++++++++++++ plugins/libs/plugin-types/index.d.ts | 4 +++ 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 1f0701dd99..8680a6d8a0 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -206,11 +206,13 @@ ;; strokeCapStart?: StrokeCap; ;; strokeCapEnd?: StrokeCap; ;; strokeColorGradient?: Gradient; +;; strokeImage?: ImageData; ;; } (defn format-stroke [{:keys [stroke-color stroke-color-ref-file stroke-color-ref-id stroke-opacity stroke-style stroke-width stroke-alignment - stroke-cap-start stroke-cap-end stroke-color-gradient] :as stroke}] + stroke-cap-start stroke-cap-end stroke-color-gradient + stroke-image] :as stroke}] (when (some? stroke) (obj/without-empty @@ -223,7 +225,8 @@ :strokeAlignment (format-key stroke-alignment) :strokeCapStart (format-key stroke-cap-start) :strokeCapEnd (format-key stroke-cap-end) - :strokeColorGradient (format-gradient stroke-color-gradient)}))) + :strokeColorGradient (format-gradient stroke-color-gradient) + :strokeImage (format-image stroke-image)}))) ;; export interface Blur { diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 1b9f844c48..cbadff48f4 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -214,6 +214,7 @@ ;; strokeCapStart?: StrokeCap; ;; strokeCapEnd?: StrokeCap; ;; strokeColorGradient?: Gradient; +;; strokeImage?: ImageData; ;; } (defn parse-stroke [^js stroke] @@ -228,7 +229,8 @@ :stroke-alignment (-> (obj/get stroke "strokeAlignment") parse-keyword) :stroke-cap-start (-> (obj/get stroke "strokeCapStart") parse-keyword) :stroke-cap-end (-> (obj/get stroke "strokeCapEnd") parse-keyword) - :stroke-color-gradient (-> (obj/get stroke "strokeColorGradient") parse-gradient)}))) + :stroke-color-gradient (-> (obj/get stroke "strokeColorGradient") parse-gradient) + :stroke-image (-> (obj/get stroke "strokeImage") parse-image-data)}))) (defn parse-strokes [^js strokes] diff --git a/frontend/src/app/plugins/strokes.cljs b/frontend/src/app/plugins/strokes.cljs index 59e305d4aa..1a717bb21c 100644 --- a/frontend/src/app/plugins/strokes.cljs +++ b/frontend/src/app/plugins/strokes.cljs @@ -18,7 +18,7 @@ :strokeColor {:get (fn [] (:stroke-color @state)) :set (fn [v] - (swap! state #(-> % (assoc :stroke-color v) (dissoc :stroke-color-gradient))) + (swap! state #(-> % (assoc :stroke-color v) (dissoc :stroke-color-gradient :stroke-image))) (on-change!))} :strokeColorRefFile @@ -62,7 +62,13 @@ (on-change!))] (gradients/gradient-proxy gradient-state gradient-change!)))) :set (fn [v] - (swap! state #(-> % (assoc :stroke-color-gradient (parser/parse-gradient v)) (dissoc :stroke-color))) + (swap! state #(-> % (assoc :stroke-color-gradient (parser/parse-gradient v)) (dissoc :stroke-color :stroke-image))) + (on-change!))} + + :strokeImage + {:get (fn [] (format/format-image (:stroke-image @state))) + :set (fn [v] + (swap! state #(-> % (assoc :stroke-image (parser/parse-image-data v)) (dissoc :stroke-color :stroke-color-gradient))) (on-change!))}))) (defn format-strokes diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index f61aac311a..0cbb2f8e1d 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -467,6 +467,7 @@ "strokeColorGradient", "strokeColorRefFile", "strokeColorRefId", + "strokeImage", "strokeOpacity", "strokeStyle", "strokeWidth" @@ -7791,6 +7792,12 @@ "kind": "getset", "type": "Gradient", "array": false + }, + "strokeImage": { + "decl": "Stroke", + "kind": "getset", + "type": "ImageData", + "array": false } }, "SvgRaw": { diff --git a/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts index c05ba336e2..3f4b4b01bb 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/media.test.ts @@ -61,6 +61,39 @@ describe.skipIfMocked('Media', () => { expect(fill.fillImage).toBeDefined(); }); + test('an uploaded image can be used as a stroke', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'plugin-stroke', + PNG_1X1, + 'image/png', + ); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.strokes = [{ strokeWidth: 2, strokeImage: image }]; + + const strokes = rect.strokes; + expect(strokes).toHaveLength(1); + expect(strokes[0].strokeImage).toBeDefined(); + }); + + test('Stroke.strokeImage can be set on a stroke and clears the solid color', async (ctx) => { + const image = await ctx.penpot.uploadMediaData( + 'plugin-stroke-set', + PNG_1X1, + 'image/png', + ); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.strokes = [{ strokeColor: '#ff0000', strokeWidth: 2 }]; + + // Set strokeImage directly on the stroke (covers Stroke.strokeImage (set)). + const stroke = rect.strokes[0]; + stroke.strokeImage = image; + expect(stroke.strokeImage).toBeDefined(); + // An image stroke replaces the solid color. + expect(stroke.strokeColor).toBeFalsy(); + }); + test('uploadMediaUrl resolves to image data', async (ctx) => { // Needs the backend to fetch an external URL, which may be unavailable in // the headless runner; treat a rejection as an environment limitation. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 1dbe005a3e..cb53532dd6 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4180,6 +4180,10 @@ export interface Stroke { * The optional gradient stroke defined by a Gradient object. */ strokeColorGradient?: Gradient; + /** + * The optional image stroke defined by an ImageData object. + */ + strokeImage?: ImageData; } /** From 95cfbf5f7c4f21766dbfb87a5ff21b02fd7c83e8 Mon Sep 17 00:00:00 2001 From: Pablo Alba <pablo.alba@kaleidos.net> Date: Tue, 14 Jul 2026 12:42:19 +0200 Subject: [PATCH 47/63] :sparkles: Improve workspace debug sidebar workflow (#10678) --- frontend/src/app/main/data/workspace.cljs | 1 + .../app/main/data/workspace/clipboard.cljs | 9 ++++ .../src/app/main/data/workspace/layout.cljs | 2 +- .../app/main/ui/workspace/context_menu.cljs | 6 +++ .../src/app/main/ui/workspace/sidebar.cljs | 26 ++++------- .../app/main/ui/workspace/sidebar/debug.cljs | 17 ++++++- .../workspace/sidebar/debug_shape_info.cljs | 5 -- .../main/ui/workspace/sidebar/layer_name.cljs | 6 ++- .../main/ui/workspace/sidebar/options.cljs | 46 +++++++++++++++---- frontend/src/app/util/debug.cljs | 21 +++++++-- frontend/translations/en.po | 3 ++ frontend/translations/es.po | 5 +- 12 files changed, 109 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 67621f3754..fb6245c31c 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1568,6 +1568,7 @@ (dm/export dwcp/paste-shapes) (dm/export dwcp/paste-data-valid?) (dm/export dwcp/copy-link-to-clipboard) +(dm/export dwcp/copy-id-to-clipboard) (dm/export dwcp/copy-as-image) ;; Drawing diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index c87a5707f9..25c51f2257 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1138,6 +1138,15 @@ (watch [_ _ _] (clipboard/to-clipboard (rt/get-current-href))))) +(defn copy-id-to-clipboard + [id] + (ptk/reify ::copy-id-to-clipboard + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/from (clipboard/to-clipboard id)) + (rx/map (fn [_] + (ntf/info "The id has been copied to the clipboard"))))))) + (defn copy-as-image [] (ptk/reify ::copy-as-image diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index 2f04bcfa53..fa8208c0ff 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -53,7 +53,7 @@ :add #{:tokens}}}) (def valid-options-mode - #{:design :prototype :inspect}) + #{:design :prototype :inspect :debug}) (def default-layout #{:sitemap diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 6931253cd4..6d1384790c 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -37,6 +37,7 @@ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.assets.common :as cmm] [app.util.clipboard :as clipboard] + [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.i18n :refer [tr] :as i18n] [app.util.shape-icon :as usi] @@ -152,6 +153,7 @@ do-copy #(st/emit! (dw/copy-selected)) do-copy-link #(st/emit! (dw/copy-link-to-clipboard)) + do-copy-id #(st/emit! (dw/copy-id-to-clipboard (-> shapes first :id str))) do-cut #(st/emit! (dw/copy-selected) (dw/delete-selected)) @@ -200,6 +202,10 @@ (reset! enabled-paste-props* false))))))] [:* + (when (and (not multiple?) + (dbg/enabled? :show-ids)) + [:> menu-entry* {:title (tr "workspace.shape.menu.copy-id") + :on-click do-copy-id}]) [:> menu-entry* {:title (tr "workspace.shape.menu.copy") :shortcut (sc/get-tooltip :copy) :on-click do-copy}] diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index 6e17e7f0c7..c918288270 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -33,7 +33,6 @@ [app.main.ui.workspace.right-header :refer [right-header*]] [app.main.ui.workspace.sidebar.assets :refer [assets-toolbox*]] [app.main.ui.workspace.sidebar.debug :refer [debug-panel*]] - [app.main.ui.workspace.sidebar.debug-shape-info :refer [debug-shape-info*]] [app.main.ui.workspace.sidebar.history :refer [history-toolbox*]] [app.main.ui.workspace.sidebar.layers :refer [layers-toolbox*]] [app.main.ui.workspace.sidebar.options :refer [options-toolbox*]] @@ -41,7 +40,6 @@ [app.main.ui.workspace.sidebar.sitemap :refer [sitemap*]] [app.main.ui.workspace.sidebar.versions :refer [versions-toolbox*]] [app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab*]] - [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) @@ -285,18 +283,16 @@ (let [is-comments? (= drawing-tool :comments) is-history? (contains? layout :document-history) is-inspect? (= section :inspect) - - dbg-shape-panel? (dbg/enabled? :shape-panel) + is-debug? (= section :debug) current-section* (mf/use-state :info) current-section (deref current-section*) can-be-expanded? - (or dbg-shape-panel? - (and (not is-comments?) - (not is-history?) - is-inspect? - (= current-section :code))) + (and (not is-comments?) + (not is-history?) + is-inspect? + (= current-section :code)) {on-pointer-down :on-pointer-down on-lost-pointer-capture :on-lost-pointer-capture @@ -325,15 +321,16 @@ [:aside {:class (stl/css-case :right-settings-bar true :not-expand (not can-be-expanded?) - :expanded (> width right-sidebar-default-width)) + :expanded (or is-debug? (> width right-sidebar-default-width))) :id "right-sidebar-aside" :data-testid "right-sidebar" :data-size (str width) :on-context-menu dom/prevent-default-context-menu - :style {:--right-sidebar-width (if can-be-expanded? - (dm/str width "px") - (dm/str right-sidebar-default-width "px"))}} + :style {:--right-sidebar-width (cond + is-debug? (dm/str right-sidebar-default-max-width "px") + can-be-expanded? (dm/str width "px") + :else (dm/str right-sidebar-default-width "px"))}} (when can-be-expanded? [:div {:class (stl/css :resize-area) @@ -348,9 +345,6 @@ [:div {:class (stl/css :settings-bar-inside)} (cond - dbg-shape-panel? - [:> debug-shape-info*] - is-comments? [:> comments-sidebar* {}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs index 5fbe361490..9e46f48c41 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs @@ -18,15 +18,28 @@ [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) +(def ^:private no-reload-options + "Debug options that don't require a page reload to take effect. + These options are handled reactively via okulary subscriptions." + #{:shape-panel + :show-ids + :show-touched}) + (mf/defc debug-panel* [{:keys [class]}] - (let [on-toggle-enabled + (let [;; dbg/state is an okulary atom; deref'ing it makes this component + ;; re-render whenever any debug option is toggled, so checkboxes + ;; reflect the current state without a page reload. + _dbg (mf/deref dbg/state) + + on-toggle-enabled (mf/use-fn (fn [event option] (dom/prevent-default event) (dom/stop-propagation event) (dbg/toggle! option) - (js* "app.main.reinit(true)"))) + (when-not (contains? no-reload-options option) + (js* "app.main.reinit(true)")))) handle-close (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs index f4b30eea5f..f7c1e84ca9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs @@ -13,7 +13,6 @@ [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.ds.product.panel-title :refer [panel-title*]] [debug :as dbg] [rumext.v2 :as mf])) @@ -125,10 +124,6 @@ (map (d/getf objects)))] [:div {:class (stl/css :shape-info)} - [:> panel-title* {:class (stl/css :shape-info-title) - :text "Debug" - :on-close #(dbg/disable! :shape-panel)}] - (if (empty? selected) [:div {:class (stl/css :attrs-container)} "No shapes selected"] (for [[idx current] (d/enumerate selected)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 91e8a1c605..3e4b1ee570 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -26,7 +26,11 @@ type-comp type-frame component-id is-hidden is-blocked variant-id variant-name variant-properties variant-error on-tab-press ref]}] - (let [edition* (mf/use-state false) + (let [;; Subscribe to dbg/state so the component re-renders when + ;; :show-ids or :show-touched are toggled without a page reload. + _dbg (mf/deref dbg/state) + + edition* (mf/use-state false) edition? (deref edition*) local-ref (mf/use-ref) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 5b4619710e..f07dce3891 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -21,6 +21,7 @@ [app.main.ui.context :as ctx] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.inspect.right-sidebar :as hrs] + [app.main.ui.workspace.sidebar.debug-shape-info :refer [debug-shape-info*]] [app.main.ui.workspace.sidebar.options.drawing :as drawing] [app.main.ui.workspace.sidebar.options.menus.align :refer [align-options*]] [app.main.ui.workspace.sidebar.options.menus.bool :refer [bool-options*]] @@ -38,6 +39,7 @@ [app.main.ui.workspace.sidebar.options.shapes.rect :as rect] [app.main.ui.workspace.sidebar.options.shapes.svg-raw :as svg-raw] [app.main.ui.workspace.sidebar.options.shapes.text :as text] + [app.util.debug :as dbg] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] [rumext.v2 :as mf])) @@ -199,13 +201,17 @@ [:> hrs/right-sidebar* props])) -(def ^:private options-tabs - [{:label (tr "workspace.options.design") - :id "design"} - {:label (tr "workspace.options.prototype") - :id "prototype"} - {:label (tr "workspace.options.inspect") - :id "inspect"}]) +(defn- generate-options-tabs + [] + (cond-> [{:label (tr "workspace.options.design") + :id "design"} + {:label (tr "workspace.options.prototype") + :id "prototype"} + {:label (tr "workspace.options.inspect") + :id "inspect"}] + (dbg/enabled? :shape-panel) + (conj {:label "Debug" + :id "debug"}))) (defn- on-option-tab-change [mode] @@ -225,9 +231,28 @@ options-mode (mf/deref refs/options-mode-global) + ;; dbg/state is an okulary atom; deref'ing it makes this component + ;; re-render when debug options change (e.g. :shape-panel toggle) + ;; so the tabs list is regenerated reactively without a page reload. + dbg-state + (mf/deref dbg/state) + shapes (mf/with-memo [selected objects] - (sequence (keep (d/getf objects)) selected))] + (sequence (keep (d/getf objects)) selected)) + + options-tabs + (generate-options-tabs)] + + (mf/use-effect + (mf/deps dbg-state) + (fn [] + (if (contains? dbg-state :shape-panel) + ;; shape-panel was just enabled: select the debug tab + (on-option-tab-change "debug") + ;; shape-panel was just disabled: if debug tab is active, go back to design + (when (= options-mode :debug) + (on-option-tab-change "design"))))) [:div {:class (stl/css :tool-window)} (if (and (:can-edit permissions) (not render-context-lost?)) @@ -255,7 +280,10 @@ :objects objects :page-id page-id :file-id file-id - :shapes shapes}])] + :shapes shapes}] + + :debug + [:> debug-shape-info*])] [:div {:class (stl/css :element-options :inspect-options :read-only)} [:> inspect-tab* {:page-id page-id diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 6eea87a758..0f8d4471f2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -6,9 +6,11 @@ (ns app.util.debug (:require - [app.main.store :as st])) + [app.main.store :as st] + [app.util.storage :as storage] + [okulary.core :as l])) -(defonce state (atom #{#_:events})) +(def ^:private storage-key :app.util.debug/enabled-options) (def options #{;; Displays the bounding box for the shapes @@ -105,6 +107,17 @@ ;; Event times :events-times}) +(defn- load-state + [] + (let [stored (get storage/user storage-key #{})] + (into #{} (filter options) stored))) + +(defonce state (l/atom (load-state))) + +(defn- persist-state! + [state] + (swap! storage/user assoc storage-key state)) + (defn handle-change [] (set! st/*debug-events* (contains? @state :events)) @@ -112,7 +125,9 @@ (when *assert* (handle-change) - (add-watch state :watcher handle-change)) + (add-watch state :watcher handle-change) + (add-watch state :persistence (fn [_ _ _ new-state] + (persist-state! new-state)))) (defn enable! [option] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e05426f58e..1e1eb52478 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8779,6 +8779,9 @@ msgstr "Copy as CSS (nested layers)" msgid "workspace.shape.menu.copy-link" msgstr "Copy link" +msgid "workspace.shape.menu.copy-id" +msgstr "Copy id" + #: src/app/main/ui/workspace/context_menu.cljs:219 msgid "workspace.shape.menu.copy-paste-as" msgstr "Copy/Paste as ..." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index cceda3bf52..78c4c51465 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4629,7 +4629,7 @@ msgid "onboarding.choice.team-up.invite-members" msgstr "Invitar integrantes" msgid "onboarding.choice.team-up.invite-members-desc" -msgstr "" +msgstr "" "Personas de desarrollo, diseño, gestión... todo el mundo es bienvenido. " "Podrás invitar a más gente posteriormente." @@ -8551,6 +8551,9 @@ msgstr "Copiar como CSS (capas anidadas)" msgid "workspace.shape.menu.copy-link" msgstr "Copiar enlace" +msgid "workspace.shape.menu.copy-id" +msgstr "Copiar id" + #: src/app/main/ui/workspace/context_menu.cljs:219 msgid "workspace.shape.menu.copy-paste-as" msgstr "Copiar/Pegar como ..." From c119622ad9fd50d8161cbf7320c4b3fbc0392974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= <marina.lopez.yap@gmail.com> Date: Tue, 14 Jul 2026 12:13:06 +0200 Subject: [PATCH 48/63] :bug: Changed color and text from nitrate banners --- frontend/src/app/main/ui/dashboard/subscription.cljs | 2 +- frontend/src/app/main/ui/dashboard/subscription.scss | 2 +- frontend/translations/en.po | 6 +++--- frontend/translations/es.po | 7 +++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index bd33bfa998..7021cb9884 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -200,7 +200,7 @@ [:div {:class (stl/css :nitrate-content)} [:span {:class (stl/css :nitrate-title)} (tr "subscription.dashboard.banner.unlock-features")]] [:div {:class (stl/css :nitrate-content)} - [:span {:class (stl/css :nitrate-info)} (tr "subscription.dashboard.banner.unlock-features-description")] + [:span {:class (stl/css :nitrate-info)} (tr "subscription.dashboard.banner.unlock-features-description-text")] [:> button* {:variant "primary" :type "button" :class (stl/css :nitrate-bottom-button) diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 653650dd40..16fa856de9 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -225,7 +225,7 @@ border-radius: var(--sp-s); flex-direction: column; margin: var(--sp-m) var(--sp-m) 0; - background: var(--color-background-quaternary); + background: var(--color-accent-select); border: $b-1 solid var(--color-accent-primary-muted); padding: var(--sp-l); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 1e1eb52478..b5dabef677 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5771,10 +5771,10 @@ msgid "subscription.dashboard.banner.unlock-features" msgstr "Unlock Enterprise features" #: src/app/main/ui/dashboard/subscription.cljs:203 -msgid "subscription.dashboard.banner.unlock-features-description" +msgid "subscription.dashboard.banner.unlock-features-description-text" msgstr "" -"Set fine-grained permissions and keep your design operations secure, " -"without compromising the open-source freedom your team already loves." +"Set fine-grained permissions and stay in control, " +"without compromising the open-source freedom your team loves." #: src/app/main/ui/dashboard/subscription.cljs:208 msgid "subscription.dashboard.banner.upgrade-nitrate" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 78c4c51465..6384ee4a9f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5628,11 +5628,10 @@ msgid "subscription.dashboard.banner.unlock-features" msgstr "Desbloquea las funciones Enterprise" #: src/app/main/ui/dashboard/subscription.cljs:203 -msgid "subscription.dashboard.banner.unlock-features-description" +msgid "subscription.dashboard.banner.unlock-features-description-text" msgstr "" -"Establece permisos detallados y garantiza la seguridad de tus operaciones " -"de diseño, sin renunciar a la libertad del código abierto que tanto valora " -"tu equipo." +"Establece permisos detallados y mantén el control, " +"sin renunciar a la libertad del código abierto que tanto valora tu equipo." #: src/app/main/ui/dashboard/subscription.cljs:208 msgid "subscription.dashboard.banner.upgrade-nitrate" From 167aa7410f95bce91b9a80059624a3e3d9307f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Tue, 14 Jul 2026 14:15:29 +0200 Subject: [PATCH 49/63] :bug: Fix modals for nitrate subscription when unlimited (#10690) --- frontend/src/app/main/ui/dashboard/sidebar.cljs | 16 ++++++++++++++-- .../src/app/main/ui/dashboard/subscription.cljs | 8 ++++---- .../src/app/main/ui/nitrate/nitrate_form.cljs | 3 ++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index bea1060183..269e2a3793 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -315,8 +315,14 @@ (mf/use-fn (mf/deps profile) (fn [] - (if (dnt/is-valid-license? profile) + (cond + (= (get-subscription-type (-> profile :props :subscription)) "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true})) + + (dnt/is-valid-license? profile) (dnt/go-to-nitrate-ac-create-org) + + :else (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) on-go-to-cc-click @@ -755,8 +761,14 @@ (mf/use-fn (mf/deps profile) (fn [] - (if (dnt/is-valid-license? profile) + (cond + (= (get-subscription-type (-> profile :props :subscription)) "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true})) + + (dnt/is-valid-license? profile) (dnt/go-to-nitrate-ac-create-org) + + :else (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] (if show-dropdown? [:div {:class (stl/css :sidebar-org-switch)} diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 7021cb9884..b7a4e9a3e6 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -162,11 +162,11 @@ handle-click (mf/use-fn - (mf/deps nitrate-license subscription-type) + (mf/deps subscription-type) (fn [] - (if (= subscription-type "unlimited") - (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true})) - (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) + (st/emit! (dnt/show-nitrate-popup :nitrate-form + (when (= subscription-type "unlimited") + {:show-contact-sales-option true}))))) handle-go-to-cc (mf/use-fn dnt/go-to-nitrate-ac-create-org) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 138b485151..ef973f111f 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -24,7 +24,8 @@ ::mf/wrap-props true} [connectivity] - (let [online? (:licenses connectivity) + (let [show-contact-sales-option (:show-contact-sales-option connectivity) + online? (and (:licenses connectivity) (not show-contact-sales-option)) profile (mf/deref refs/profile) on-click (mf/use-fn From 6285b1de6085cd9a3f667237680d42d2eaeeefe6 Mon Sep 17 00:00:00 2001 From: Elena Torro <elenatorro@gmail.com> Date: Wed, 15 Jul 2026 07:15:42 +0200 Subject: [PATCH 50/63] :bug: Fix masked group position in flex layout on child visibility change --- render-wasm/src/shapes/modifiers.rs | 48 ++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 109fc605b2..da6289b289 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -108,6 +108,15 @@ fn calculate_group_bounds( shape_bounds.with_points(result) } +fn calculate_masked_group_bounds( + shape: &Shape, + shapes: ShapesPoolRef, + bounds: &HashMap<Uuid, Bounds>, +) -> Option<Bounds> { + let mask = shape.mask_id().and_then(|id| shapes.get(id))?; + Some(bounds.find(mask)) +} + fn calculate_bool_bounds( shape: &Shape, shapes: ShapesPoolRef, @@ -349,10 +358,10 @@ fn propagate_reflow( layout_reflows.insert(*id); } Type::Group(Group { masked: true }) => { - let children_ids = shape.children_ids(true); - if let Some(child) = children_ids.first().and_then(|id| shapes.get(id)) { - let child_bounds = bounds.find(child); - bounds.insert(shape.id, child_bounds); + // Masked group bounds are the mask shape bounds (first child in stored + // order, children_ids returns children in reverse order) + if let Some(shape_bounds) = calculate_masked_group_bounds(shape, shapes, bounds) { + bounds.insert(shape.id, shape_bounds); } reflown.insert(*id); } @@ -588,4 +597,35 @@ mod tests { assert_eq!(bounds.width(), 3.0); assert_eq!(bounds.height(), 3.0); } + + #[test] + fn test_masked_group_bounds_are_mask_bounds() { + let parent_id = Uuid::new_v4(); + let shapes = { + let mut shapes = ShapesPool::new(); + shapes.initialize(10); + + let mask_id = Uuid::new_v4(); + let mask = shapes.add_shape(mask_id); + mask.set_selrect(1.0, 1.0, 5.0, 5.0); + + let content_id = Uuid::new_v4(); + let content = shapes.add_shape(content_id); + content.set_selrect(0.0, 0.0, 10.0, 10.0); + + let parent = shapes.add_shape(parent_id); + parent.set_shape_type(Type::Group(Group { masked: true })); + parent.add_child(mask_id); + parent.add_child(content_id); + parent.set_selrect(1.0, 1.0, 5.0, 5.0); + shapes + }; + + let parent = shapes.get(&parent_id).unwrap(); + + let bounds = calculate_masked_group_bounds(parent, &shapes, &HashMap::new()).unwrap(); + + assert_eq!(bounds.width(), 4.0); + assert_eq!(bounds.height(), 4.0); + } } From 792d88dc4f0f6d70ce013bb1a8e616eab2d8c57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Wed, 15 Jul 2026 12:30:20 +0200 Subject: [PATCH 51/63] :bug: Fix invalid org invitation show toast (#10693) * :bug: Fix invalid org invitation show toast * :paperclip: Code review --- backend/src/app/rpc/commands/verify_token.clj | 58 +++++++++++-------- .../src/app/main/ui/auth/verify_token.cljs | 11 ++++ frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index e2433fec95..13f36da82a 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -184,13 +184,7 @@ {:columns [:id :email :default-team-id]}) registration-disabled? (not (contains? cf/flags :registration)) - org-invitation? (and (contains? cf/flags :nitrate) organization-id) - ;; Membership only makes sense for a logged-in profile; querying it for - ;; an anonymous recipient would call nitrate with a nil profile-id and - ;; mask the clean :invalid-token response with a generic error. - membership (when (and profile org-invitation?) - (nitrate/call cfg :get-org-membership {:profile-id profile-id - :organization-id organization-id}))] + org-invitation? (and (contains? cf/flags :nitrate) organization-id)] (if profile (do @@ -201,23 +195,32 @@ :reason :email-mismatch :hint "logged-in user does not matches the invitation")) - (when (:is-member membership) - (ex/raise :type :validation - :code :already-an-org-member - :team-id (:default-team-id membership) - :hint "the user is already a member of the organization")) - - (when (and org-invitation? (not (:organization-id membership))) - (ex/raise :type :validation - :code :org-not-found - :team-id (:default-team-id profile) - :hint "the organization doesn't exist")) - (when (nil? invitation) (ex/raise :type :validation - :code :invalid-token - :hint "no invitation associated with the token")) + :code (if organization-id :canceled-invitation :invalid-token) + :hint (if organization-id + "the invitation has been canceled" + "no invitation associated with the token"))) + ;; Membership only makes sense for a logged-in profile with an + ;; existing invitation; querying it when the invitation is absent + ;; would call nitrate needlessly and could mask the clean + ;; :canceled-invitation/:invalid-token response with a generic error. + (let [membership (when org-invitation? + (nitrate/call cfg :get-org-membership {:profile-id profile-id + :organization-id organization-id}))] + + (when (:is-member membership) + (ex/raise :type :validation + :code :already-an-org-member + :team-id (:default-team-id membership) + :hint "the user is already a member of the organization")) + + (when (and org-invitation? (not (:organization-id membership))) + (ex/raise :type :validation + :code :org-not-found + :team-id (:default-team-id profile) + :hint "the organization doesn't exist"))) ;; if we have logged-in user and it matches the invitation we proceed ;; with accepting the invitation and joining the current profile to the @@ -251,12 +254,17 @@ (assoc :org-team-id accepted-team-id))))) (do - ;; If the user is not logged-in and the token is invalid we throw the error - ;; Taiga issue #14182 + ;; If the user is not logged-in and the invitation has been canceled + ;; we return a specific error code so the frontend can redirect to + ;; login with an appropriate message instead of showing the error page. + ;; This only applies to org invitations; team invitations keep the + ;; existing :invalid-token behavior. (when (nil? invitation) (ex/raise :type :validation - :code :invalid-token - :hint "no invitation associated with the token")) + :code (if organization-id :canceled-invitation :invalid-token) + :hint (if organization-id + "the invitation has been canceled" + "no invitation associated with the token"))) ;; If we have not logged-in user, and invitation comes with member-id we ;; redirect user to login, if no member-id is present and in the invitation diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 337e89c89f..d67be7e5eb 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -96,6 +96,17 @@ (rt/nav :dashboard-recent {:team-id team-id}) (ntf/error (tr "errors.org-not-found"))) + (= :canceled-invitation code) + (let [profile (:profile @st/state) + authenticated? (da/is-authenticated? profile)] + (if authenticated? + (st/emit! + (dcm/go-to-dashboard-recent :team-id :default) + (ntf/warn (tr "notifications.invitation-canceled"))) + (st/emit! + (rt/nav :auth-login) + (ntf/warn (tr "notifications.invitation-canceled"))))) + (or (= :validation type) (= :invalid-token code) (= :token-expired reason)) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b5dabef677..ea0d60d7ec 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10106,3 +10106,6 @@ msgstr "Autosaved versions will be kept for %s days." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" + +msgid "notifications.invitation-canceled" +msgstr "This invitation is no longer available." \ No newline at end of file diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6384ee4a9f..d117f9ca38 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9757,3 +9757,6 @@ msgstr "Los autoguardados duran %s días." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" + +msgid "notifications.invitation-canceled" +msgstr "Esta invitación ya no está disponible." \ No newline at end of file From bdc078d5ea0cd9450993267b22d3866cf7667978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Wed, 15 Jul 2026 14:47:44 +0200 Subject: [PATCH 52/63] :bug: Fix mismatched subscription in social login (#10703) --- backend/src/app/rpc/commands/verify_token.clj | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 13f36da82a..ee15a63385 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -85,9 +85,18 @@ ::audit/props (audit/profile->props profile) ::audit/profile-id (:id profile)})))) +(defn- with-nitrate-licence + [profile cfg] + (if (contains? cf/flags :nitrate) + (nitrate/add-nitrate-licence-to-profile cfg profile) + profile)) + (defmethod process-token :auth [{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}] - (let [profile (profile/get-profile conn profile-id)] + (let [profile (-> (profile/get-profile conn profile-id) + (profile/strip-private-attrs) + (update :props profile/filter-props) + (with-nitrate-licence cfg))] (assoc claims :profile profile))) ;; --- Team Invitation From e48f37498429da15fb72df6a9152f12643544d18 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 15 Jul 2026 17:32:16 +0200 Subject: [PATCH 53/63] :arrow_up: Update opencode on devenv dockerfile --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1fd9a7b382..b965526cbe 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.17.16 +ENV OPENCODE_VERSION=1.18.1 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From e498dd238293bcb1218e603809d82502dacd2d95 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 15 Jul 2026 21:01:33 +0200 Subject: [PATCH 54/63] :paperclip: Update commiter opencode agent --- .opencode/agents/commiter.md | 95 ++++++------------------------------ 1 file changed, 14 insertions(+), 81 deletions(-) diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md index 66b56d1041..a5e128e4d1 100644 --- a/.opencode/agents/commiter.md +++ b/.opencode/agents/commiter.md @@ -6,7 +6,6 @@ permission: read: allow glob: allow grep: allow - list: allow edit: deny webfetch: deny websearch: deny @@ -14,66 +13,9 @@ permission: skill: deny lsp: deny todowrite: deny - question: allow + question: deny external_directory: deny - bash: - # Broad read-side: any non-write git query - "git status*": allow - "git log*": allow - "git diff*": allow - "git show*": allow - "git rev-parse*": allow - "git branch*": allow - "git remote -v*": allow - "git config --get*": allow - - # Commit flow: staged, explicit paths only. `git commit*` (no space) - # also covers `git commit --amend`. `git add -*` overrides the allow - # below to block flag-driven bulk adds (`-A`, `--all`, `-u`, ...). - "git add *": allow - "git commit*": allow - "git add -*": deny - - # Read-only filesystem helpers used in the commit flow - "cat *": allow - "head *": allow - "tail *": allow - "wc *": allow - "date *": allow - - # Dangerous: deny outright - "rm *": deny - "rmdir *": deny - "mv *": deny - "cp *": deny - "dd *": deny - "chmod *": deny - "chown *": deny - "sudo *": deny - "git push*": deny - "git clean*": deny - "git reset*": deny - "git checkout*": deny - "git restore*": deny - "git config --global*": deny - "curl *": deny - "wget *": deny - "ssh *": deny - "scp *": deny - "eval *": deny - - # Risky-but-sometimes-needed: ask the user - "git stash*": ask - "git rebase*": ask - "git merge*": ask - "git tag*": ask - "git fetch*": ask - "git pull*": ask - # Note: `git config <anything-other-than-(--get|--global)>` falls - # through to the `*` catch-all below and is asked. - - # Safety net - "*": ask + bash: allow --- ## Role @@ -91,13 +33,12 @@ exactly — do not improvise the format and do not restate its contents here. ## Pre-commit Workflow -1. Run `git status` to inspect the working tree. If there are unstaged or - untracked changes unrelated to the user's request, STOP and ask how to - handle them. Do not silently include unrelated work in the commit. -2. Run `git diff --staged` (or `git diff` for unstaged changes) and review the - content. If you see secrets (API keys, tokens, passwords, private keys, - `.env` values), debug prints, or anything that does not match the user's - stated intent, STOP and tell the user before committing. +1. **Stage the files** specified by the calling agent. Do not ask for + confirmation — the calling agent knows exactly which files to commit. +2. Run `git diff --staged` to review the content. If you see secrets (API + keys, tokens, passwords, private keys, `.env` values), debug prints, or + anything that does not match the stated intent, STOP and tell the user + before committing. 3. Following the format in the doc, draft the message and run `git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has unusual characters). The `AI-assisted-by` trailer value is provided by the @@ -105,18 +46,10 @@ exactly — do not improvise the format and do not restate its contents here. ## Constraints -- Do not push. Pushing is a separate workflow handled by the user (the - permission set also denies `git push*`). -- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`, or - `rm*` — the permission set denies these outright. If staged work needs to be - discarded, ask the user to do it. +- Do not push. Pushing is a separate workflow handled by the user. +- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations. - Do not pass `--author`. Author identity comes from the local git config. - Never guess or hallucinate a name or email. -- Do not amend a commit you did not create in this session, unless the user - explicitly asks. `git commit --amend` rewrites history and is irreversible - once pushed. -- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly - asks, and call out the deviation in your response. -- If the user asks for something that conflicts with these rules, follow the - user's request and explain the deviation in your response. Do not silently - override the format. +- Do not amend a commit you did not create in this session, unless the user explicitly asks. +- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks. +- Do not add untracked files that were not created in this session. +- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response. From b3105e6b8226a1313d7f99d25b465f7447e99465 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Wed, 15 Jul 2026 21:01:55 +0200 Subject: [PATCH 55/63] :rewind: Backport github workflows from develop --- .github/workflows/auto-label.yml | 76 ++++++++++++++++++++++++++++++ .github/workflows/build-bundle.yml | 4 ++ .github/workflows/build-docker.yml | 4 ++ .github/workflows/tests-mcp.yml | 2 +- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-label.yml diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 0000000000..27ae0f37a4 --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,76 @@ +name: Auto Label and Add to Project + +on: + issues: + types: [opened] + pull_request: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: triage-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.TRIAGE_APP_ID }} + private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} + owner: penpot + + - name: Process Issue or PR + uses: actions/github-script@v7 + with: + github-token: ${{ steps.triage-app-token.outputs.token }} + script: | + // === 1. CONFIGURATION === + const PROJECT_NUMBER = 8; // <--- Replace with your project board number + const IS_ORG = true; // <--- Set to false if this is a personal project, true if an organization + const OWNER = context.repo.owner; + const REPO = context.repo.repo; + + const issueNumber = context.issue.number; + const isPR = !!context.payload.pull_request; + const contentId = isPR ? context.payload.pull_request.node_id : context.payload.issue.node_id; + + // Define your labels here + const labelToApply = 'needs triage'; + + // === 2. APPLY THE LABEL === + console.log(`Applying label "${labelToApply}" to ${isPR ? 'PR' : 'Issue'} #${issueNumber}...`); + await github.rest.issues.addLabels({ + issue_number: issueNumber, + owner: OWNER, + repo: REPO, + labels: [labelToApply] + }); + + // === 3. ADD TO PROJECT BOARD === + console.log(`Fetching Project #${PROJECT_NUMBER} ID...`); + const projectQuery = ` + query($owner: String!, $number: Int!) { + ${IS_ORG ? 'organization' : 'user'}(login: $owner) { + projectV2(number: $number) { + id + } + } + } + `; + + const projectRes = await github.graphql(projectQuery, { owner: OWNER, number: PROJECT_NUMBER }); + const projectId = IS_ORG ? projectRes.organization.projectV2.id : projectRes.user.projectV2.id; + + console.log(`Adding item to project board...`); + const addToProjectMutation = ` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { + id + } + } + } + `; + + await github.graphql(addToProjectMutation, { projectId, contentId }); + console.log("Automation successfully completed!"); + diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 8f8b40007a..e31382ccd4 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -37,6 +37,10 @@ on: required: false default: 'yes' +concurrency: + group: ${{ github.workflow }}-${{ inputs.gh_ref }} + cancel-in-progress: true + jobs: build-bundle: name: Build and Upload Penpot Bundle diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 18ac6aec9f..0972f1e25d 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -16,6 +16,10 @@ on: required: true default: 'develop' +concurrency: + group: ${{ github.workflow }}-${{ inputs.gh_ref }} + cancel-in-progress: true + jobs: build-and-push: name: Build and Push Penpot Docker Images diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9e622ca83a..6f8eadcadd 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -49,4 +49,4 @@ jobs: - name: Tests working-directory: ./mcp run: | - pnpm -r run test; + pnpm run test; From 17c344b8f5fe785b1be7ae7a6e34945e17a118d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= <david.barragan@kaleidos.net> Date: Thu, 16 Jul 2026 10:15:22 +0200 Subject: [PATCH 56/63] :bug: Fix auto-label action failing on PRs from forked repos --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 27ae0f37a4..491f422d1f 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -3,7 +3,7 @@ name: Auto Label and Add to Project on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] jobs: From 258dd6ad6cc634375768f88c6f9fafe63cfd2f36 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 16 Jul 2026 12:19:50 +0200 Subject: [PATCH 57/63] :bug: Handle text node targets in closest-text-editor-content (#10641) Event targets can be DOM text nodes (nodeType 3) which lack the .closest() method. Add a get-element helper that normalizes text nodes to their parent Element before calling .closest(), matching the existing pattern in dom/get-parent-with-data. Fixes #10640 AI-assisted-by: mimo-v2.5-pro --- frontend/src/app/util/text/ui.cljs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index 8138126221..a6242cb7e3 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -10,13 +10,23 @@ [app.main.store :as st] [app.util.dom :as dom])) +(defn- get-element + "Given a DOM node, returns the nearest Element, walking up from text nodes." + [target] + (if (and (some? target) + (not= (.-nodeType target) js/Node.ELEMENT_NODE)) + (.-parentElement target) + target)) + (defn v1-closest-text-editor-content [target] - (.closest ^js target ".public-DraftEditor-content")) + (when-let [el (get-element target)] + (.closest ^js el ".public-DraftEditor-content"))) (defn v2-closest-text-editor-content [target] - (.closest ^js target "[data-itype=\"editor\"]")) + (when-let [el (get-element target)] + (.closest ^js el "[data-itype=\"editor\"]"))) (defn closest-text-editor-content [target] From 7e361920348dba1fa8a0ad7bc218b0d5b1ab5eb1 Mon Sep 17 00:00:00 2001 From: Kobi Hikri <kobi.hikri@gmail.com> Date: Thu, 16 Jul 2026 15:53:46 +0300 Subject: [PATCH 58/63] :wrench: Pin mattermost-notify action to its v2.1.0 commit SHA The notify steps referenced mattermost/action-mattermost-notify@master, a mutable branch that runs in CI with access to the MATTERMOST_WEBHOOK_URL secret. Pinning to the immutable commit of the latest release (v2.1.0, ae31bb6) keeps the exact reviewed code from executing, per GitHub third-party action hardening guidance, while staying easy to bump. --- .github/workflows/build-bundle.yml | 2 +- .github/workflows/build-docker-devenv.yml | 2 +- .github/workflows/build-docker.yml | 2 +- .github/workflows/build-tag.yml | 2 +- .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-styles-doc.yml | 2 +- .github/workflows/release.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e31382ccd4..441587eea3 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -85,7 +85,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index f94064833e..5faafba350 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -40,7 +40,7 @@ jobs: cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Notify Mattermost - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0972f1e25d..e2497f3093 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -175,7 +175,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 58fa0413c0..c47d19570c 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Notify Mattermost - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 51be85e45e..7208646c1a 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -131,7 +131,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index 137ba6f7fa..cbc8e109cd 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -114,7 +114,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 47f0d1cc24..29d2ac4fea 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -129,7 +129,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76e3d91964..47305864f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd From 779983d38e8ed65a2650f135320411756ebbb174 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 16 Jul 2026 14:57:05 +0000 Subject: [PATCH 59/63] :paperclip: Standardize test scripts and add execution discipline docs - Remove conditional build from test scripts (frontend, common) - Remove test:jvm from common package.json (JVM tests via clojure directly) - Remove test from backend package.json (JVM tests via clojure directly) - Unify common/scripts/test-quiet.js with frontend's BUILD_STEPS pattern - Add execution discipline section to mem:testing (no piping, tee to file) - Add READ mem:testing FIRST directives to module testing docs AI-assisted-by: deepseek-v4-flash --- .serena/memories/backend/core.md | 3 ++- .serena/memories/common/testing.md | 10 ++++++---- .serena/memories/frontend/testing.md | 9 ++++++--- .serena/memories/testing.md | 14 ++++++++++++++ backend/package.json | 3 +-- common/package.json | 5 ++--- common/scripts/test-quiet.js | 28 ++++++++++++++++------------ frontend/package.json | 2 +- 8 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 12a9bcda25..15f0dd109d 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -101,9 +101,10 @@ misleading linter/compiler output. See `mem:tools/paren-repair`. ## Testing -IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. +IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline. * **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. * **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. + diff --git a/.serena/memories/common/testing.md b/.serena/memories/common/testing.md index bfd126d26f..4eda8e871f 100644 --- a/.serena/memories/common/testing.md +++ b/.serena/memories/common/testing.md @@ -4,20 +4,22 @@ ## Unit tests +READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS and JVM test runs. + Common tests live under `common/test/common_tests/` and use `clojure.test`. They are CLJC and run on both JVM and JS. From `common/`: - Full JVM test run: `clojure -M:dev:test` -- Full JS test run: `pnpm run test:quiet` +- Full JS test run (always builds, suppressed output): `pnpm run test:quiet` +- Full JS test run (always builds, build output visible): `pnpm run test` - Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test` - Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch` - Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test` - Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute` - Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`) -- Build JS test target only: `pnpm run build:test` -- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn` -- Watch tests: `pnpm run watch:test` +- Build JS test target only (no run): `pnpm run build:test` +- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`. New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags diff --git a/.serena/memories/frontend/testing.md b/.serena/memories/frontend/testing.md index a4e3b8f6df..c390be9755 100644 --- a/.serena/memories/frontend/testing.md +++ b/.serena/memories/frontend/testing.md @@ -4,15 +4,18 @@ Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC ## Unit tests +READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs. + Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access. From `frontend/`: -- Full unit test run: `pnpm run test:quiet`. +- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`. +- Full unit test run (always builds, build output visible): `pnpm run test`. - Focus a frontend CLJS test namespace: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens`. - Focus one frontend CLJS test var: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`. - Quiet `app.*` logging during a run: append `--log-level warn` (or `trace|debug|info|warn|error`). -- Build test target only: `pnpm run build:test`. -- After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`. +- Build test target only (no run): `pnpm run build:test`. +- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`. - Watch tests: `pnpm run watch:test`. New frontend test namespaces must be required/listed in `frontend_tests/runner.cljs`; new vars in existing namespaces need no runner change. diff --git a/.serena/memories/testing.md b/.serena/memories/testing.md index 2f9f4c9131..ce4990ba12 100644 --- a/.serena/memories/testing.md +++ b/.serena/memories/testing.md @@ -135,6 +135,20 @@ E2E tests should not be added unless explicitly requested. | Snapshot abuse | Nobody reviews, break on any change | Focused assertions | | Skipping tests to make suite pass | Hides real failures | Fix the test or fix the code | +## Execution discipline + +When running CLJS/JS tests (frontend, common): + +- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output. +- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead. +- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running. +- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs). +- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`. + +When running JVM tests (backend, common): +- Use `clojure -M:dev:test` directly (no pnpm wrapper). +- The same no-piping rule applies: use `--focus` to narrow scope. + ## Verification Checklist After completing any implementation: diff --git a/backend/package.json b/backend/package.json index 8c99fa923c..96bd4cbada 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,6 @@ "scripts": { "lint": "clj-kondo --parallel --lint ../common/src src/", "check-fmt": "cljfmt check --parallel=true src/ test/", - "fmt": "cljfmt fix --parallel=true src/ test/", - "test": "clojure -M:dev:test" + "fmt": "cljfmt fix --parallel=true src/ test/" } } diff --git a/common/package.json b/common/package.json index 7813de8e87..19257d3ee6 100644 --- a/common/package.json +++ b/common/package.json @@ -29,8 +29,7 @@ "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", - "test:js": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js", - "test:quiet": "node ./scripts/test-quiet.js", - "test:jvm": "clojure -M:dev:test" + "test": "pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/common/scripts/test-quiet.js b/common/scripts/test-quiet.js index 6b614c74c4..b1be0dd682 100644 --- a/common/scripts/test-quiet.js +++ b/common/scripts/test-quiet.js @@ -1,18 +1,23 @@ import { spawnSync } from "node:child_process"; +const BUILD_STEPS = [ + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + const progress = (msg) => process.stderr.write(`${msg}\n`); -progress("Building test bundle..."); -const build = spawnSync("pnpm", ["run", "build:test"], { - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: 64 * 1024 * 1024, -}); - -if (build.status !== 0) { - progress("Building test bundle failed"); - if (build.stdout?.length) process.stdout.write(build.stdout); - if (build.stderr?.length) process.stderr.write(build.stderr); - process.exit(build.status ?? 1); +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } } progress("Running tests..."); @@ -21,5 +26,4 @@ const result = spawnSync( ["target/tests/test.js", ...process.argv.slice(2)], { stdio: "inherit" }, ); - process.exit(result.status ?? 1); diff --git a/frontend/package.json b/frontend/package.json index 4e0b3f365d..32da31fbf9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,7 @@ "lint:js": "exit 0", "lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'", "build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test", - "test": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js", + "test": "pnpm run build:test && node target/tests/test.js", "test:quiet": "node ./scripts/test-quiet.js", "test:storybook": "vitest run --project=storybook", "watch:test": "mkdir -p target/tests && concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests --exec 'node target/tests/test.js'\"", From d9511db585d593c60ff3ce817d173adf10436887 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 17:00:20 +0200 Subject: [PATCH 60/63] :wrench: Auto-confirm pnpm modules purge in MCP bootstrap #10680 (#10681) pnpm occasionally detects an incompatible node_modules directory (e.g. after a store location or pnpm major version change) and interactively asks whether to remove and recreate it, blocking the MCP bootstrap in the devenv tmux pane. Set confirmModulesPurge: false in mcp/pnpm-workspace.yaml so the purge is auto-confirmed; this file is included in the npm pack tarball (unlike .npmrc) and applies to all install invocations from a single place. AI-assisted-by: claude-fable-5 --- mcp/pnpm-workspace.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 60c228aba4..14eaeefa61 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -1,3 +1,8 @@ +# auto-confirm node_modules purge when pnpm detects an incompatible modules +# directory (e.g. after a store location or pnpm major version change), +# preventing the interactive prompt from blocking bootstrap +confirmModulesPurge: false + allowBuilds: esbuild: true sharp: false From 33d478b53257e6601a064e16d07f7ae96299e7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= <marina.lopez.yap@gmail.com> Date: Thu, 16 Jul 2026 15:32:02 +0200 Subject: [PATCH 61/63] :recycle: Rename org to organization --- backend/src/app/rpc/management/nitrate.clj | 96 +++++++++---------- .../rpc_management_nitrate_test.clj | 88 ++++++++--------- frontend/src/app/main/data/nitrate.cljs | 6 +- .../src/app/main/ui/dashboard/sidebar.cljs | 4 +- .../app/main/ui/dashboard/subscription.cljs | 2 +- .../nitrate_activation_success_modal.cljs | 2 +- 6 files changed, 99 insertions(+), 99 deletions(-) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index fc96d2e448..f6f5073d8a 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -112,24 +112,24 @@ (->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name]))))) -;; ---- API: upload-org-logo +;; ---- API: upload-organization-logo -(def ^:private schema:upload-org-logo +(def ^:private schema:upload-organization-logo [:map [:content media/schema:upload] [:organization-id ::sm/uuid] [:previous-id {:optional true} ::sm/uuid]]) -(def ^:private schema:upload-org-logo-result +(def ^:private schema:upload-organization-logo-result [:map [:id ::sm/uuid]]) -(sv/defmethod ::upload-org-logo +(sv/defmethod ::upload-organization-logo "Store an organization logo in penpot storage and return its ID. Accepts an optional previous-id to mark the old logo for garbage collection when replacing an existing one." {::doc/added "2.17" - ::sm/params schema:upload-org-logo - ::sm/result schema:upload-org-logo-result + ::sm/params schema:upload-organization-logo + ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] (when previous-id @@ -441,9 +441,9 @@ RETURNING id, deleted_at;") (profile-to-map profile))) -;; ---- API: get-org-member-team-counts +;; ---- API: get-organization-member-team-counts -(def ^:private sql:get-org-member-team-counts +(def ^:private sql:get-organization-member-team-counts "SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count FROM team_profile_rel AS tpr JOIN team AS t ON t.id = tpr.team_id @@ -452,19 +452,19 @@ RETURNING id, deleted_at;") AND t.is_default IS FALSE GROUP BY tpr.profile_id;") -(def ^:private schema:get-org-member-team-counts-params +(def ^:private schema:get-organization-member-team-counts-params [:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) -(def ^:private schema:get-org-member-team-counts-result +(def ^:private schema:get-organization-member-team-counts-result [:vector [:map [:profile-id ::sm/uuid] [:team-count ::sm/int]]]) -(sv/defmethod ::get-org-member-team-counts +(sv/defmethod ::get-organization-member-team-counts "Get the number of non-default teams each profile belongs to within a set of teams." {::doc/added "2.15" - ::sm/params schema:get-org-member-team-counts-params - ::sm/result schema:get-org-member-team-counts-result + ::sm/params schema:get-organization-member-team-counts-params + ::sm/result schema:get-organization-member-team-counts-result ::rpc/auth false} [cfg {:keys [team-ids]}] (let [team-ids (cond @@ -480,12 +480,12 @@ RETURNING id, deleted_at;") [] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:get-org-member-team-counts ids-array]))))))) + (db/exec! conn [sql:get-organization-member-team-counts ids-array]))))))) -;; API: invite-to-org +;; API: invite-to-organization -(sv/defmethod ::invite-to-org +(sv/defmethod ::invite-to-organization "Invite to organization" {::doc/added "2.15" ::sm/params [:map @@ -497,13 +497,13 @@ RETURNING id, deleted_at;") nil) -;; API: get-org-invitations +;; API: get-organization-invitations -(def ^:private schema:get-org-invitations-params +(def ^:private schema:get-organization-invitations-params [:map [:organization-id ::sm/uuid]]) -(def ^:private schema:get-org-invitations-result +(def ^:private schema:get-organization-invitations-result [:vector [:map [:id ::sm/uuid] @@ -514,11 +514,11 @@ RETURNING id, deleted_at;") [:profile-id {:optional true} [:maybe ::sm/uuid]] [:photo-url {:optional true} ::sm/uri]]]) -(sv/defmethod ::get-org-invitations +(sv/defmethod ::get-organization-invitations "Get valid invitations for an organization, returning at most one invitation per email." {::doc/added "2.16" - ::sm/params schema:get-org-invitations-params - ::sm/result schema:get-org-invitations-result + ::sm/params schema:get-organization-invitations-params + ::sm/result schema:get-organization-invitations-result ::nitrate/sso false} [cfg {:keys [organization-id]}] (let [team-ids (noh/get-org-team-ids cfg organization-id)] @@ -530,59 +530,59 @@ RETURNING id, deleted_at;") (assoc :photo-url (files/resolve-public-uri photo-id)))))))))) -;; API: delete-org-invitations +;; API: delete-organization-invitations -(def ^:private sql:delete-org-invitations +(def ^:private sql:delete-organization-invitations "DELETE FROM team_invitation AS ti WHERE ti.email_to = ? AND (ti.org_id = ? OR ti.team_id = ANY(?));") -(def ^:private schema:delete-org-invitations-params +(def ^:private schema:delete-organization-invitations-params [:map [:organization-id ::sm/uuid] [:email ::sm/email]]) -(sv/defmethod ::delete-org-invitations +(sv/defmethod ::delete-organization-invitations "Delete all invitations for one email in an organization scope (org + org teams)." {::doc/added "2.16" - ::sm/params schema:delete-org-invitations-params + ::sm/params schema:delete-organization-invitations-params ::nitrate/sso false} [cfg {:keys [organization-id email]}] (let [clean-email (profile/clean-email email) team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) + (db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array])))) nil)) -;; API: delete-all-org-invitations +;; API: delete-all-organization-invitations -(def ^:private sql:delete-all-org-invitations +(def ^:private sql:delete-all-organization-invitations "DELETE FROM team_invitation AS ti WHERE ti.org_id = ? OR ti.team_id = ANY(?);") -(def ^:private schema:delete-all-org-invitations-params +(def ^:private schema:delete-all-organization-invitations-params [:map [:organization-id ::sm/uuid]]) -(sv/defmethod ::delete-all-org-invitations +(sv/defmethod ::delete-all-organization-invitations "Delete every pending invitation associated with an organization (org-level + team-level). Called from Nitrate when an organization is about to be deleted, so users that click their invitation token hit the existing invalid-token landing page." {::doc/added "2.18" - ::sm/params schema:delete-all-org-invitations-params + ::sm/params schema:delete-all-organization-invitations-params ::rpc/auth false} [cfg {:keys [organization-id]}] (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) + (db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array])))) nil)) -;; API: remove-from-org +;; API: remove-from-organization (def ^:private sql:get-reassign-to "SELECT tpr.profile_id @@ -607,7 +607,7 @@ RETURNING id, deleted_at;") (assoc team-to-transfer :reassign-to reassign-to))) -(sv/defmethod ::remove-from-org +(sv/defmethod ::remove-from-organization "Remove an user from an organization" {::doc/added "2.17" ::sm/params [:map @@ -635,16 +635,16 @@ RETURNING id, deleted_at;") (notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org") nil)) -;; API: get-remove-from-org-summary +;; API: get-remove-from-organization-summary -(def ^:private schema:get-remove-from-org-summary-result +(def ^:private schema:get-remove-from-organization-summary-result [:map [:teams-to-delete ::sm/int] [:teams-to-transfer ::sm/int] [:teams-to-exit ::sm/int] [:teams-to-detach ::sm/int]]) -(sv/defmethod ::get-remove-from-org-summary +(sv/defmethod ::get-remove-from-organization-summary "Get a summary of the teams that would be deleted, transferred, or exited if the user were removed from the organization" {::doc/added "2.17" @@ -652,7 +652,7 @@ RETURNING id, deleted_at;") [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] [:default-team-id ::sm/uuid]] - ::sm/result schema:get-remove-from-org-summary-result + ::sm/result schema:get-remove-from-organization-summary-result ::db/transaction true ::nitrate/sso false} [cfg {:keys [profile-id organization-id default-team-id]}] @@ -701,8 +701,8 @@ RETURNING id, deleted_at;") :organizations organizations})))) nil) -;; API: exists-org-team-invitations-for-non-members / -;; delete-org-team-invitations-for-non-members +;; API: exists-organization-team-invitations-for-non-members / +;; delete-organization-team-invitations-for-non-members (def ^:private sql:get-profile-emails-by-ids "SELECT email @@ -729,7 +729,7 @@ RETURNING id, deleted_at;") [:team-ids [:vector ::sm/uuid]] [:member-ids [:vector ::sm/uuid]]]) -(def ^:private schema:exists-org-team-invitations-for-non-members-result +(def ^:private schema:exists-organization-team-invitations-for-non-members-result [:map [:exists ::sm/boolean]]) (defn- org-team-invitations-for-non-members-arrays @@ -751,17 +751,17 @@ RETURNING id, deleted_at;") emails-array]) :non-member))) -(sv/defmethod ::exists-org-team-invitations-for-non-members +(sv/defmethod ::exists-organization-team-invitations-for-non-members "Return if there are any team invitations for emails that are not organization members." {::doc/added "2.18" ::sm/params schema:org-team-invitations-for-non-members-params - ::sm/result schema:exists-org-team-invitations-for-non-members-result + ::sm/result schema:exists-organization-team-invitations-for-non-members-result ::nitrate/sso false} [cfg params] (db/run! cfg (fn [{:keys [::db/conn]}] {:exists (boolean (non-member-org-team-invitations-exist? conn params))}))) -(sv/defmethod ::delete-org-team-invitations-for-non-members +(sv/defmethod ::delete-organization-team-invitations-for-non-members "Delete team invitations for emails that are not organization members." {::doc/added "2.18" ::sm/params schema:org-team-invitations-for-non-members-params @@ -914,8 +914,8 @@ RETURNING id, deleted_at;") [cfg params] {:valid (oidc/is-organization-sso-config-valid? cfg params)}) -;; ---- API: notify-org-sso-change -(sv/defmethod ::notify-org-sso-change +;; ---- API: notify-organization-sso-change +(sv/defmethod ::notify-organization-sso-change "Nitrate notifies that an organization sso values have changed" {::doc/added "2.19" ::sm/params [:map diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 2691514952..bff2a59478 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -369,7 +369,7 @@ (t/is (= :not-found (th/ex-type (:error ko-out)))) (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) -(t/deftest get-org-invitations-returns-valid-deduped-by-email +(t/deftest get-organization-invitations-returns-valid-deduped-by-email (let [profile (th/create-profile* 1 {:is-active true}) team-1 (th/create-team* 1 {:profile-id (:id profile)}) team-2 (th/create-team* 2 {:profile-id (:id profile)}) @@ -377,7 +377,7 @@ org-summary {:id org-id :teams [{:id (:id team-1)} {:id (:id team-2)}]} - params {::th/type :get-org-invitations + params {::th/type :get-organization-invitations ::rpc/profile-id (:id profile) :organization-id org-id}] @@ -439,12 +439,12 @@ (t/is (nil? (:role dedup))) (t/is (nil? (:valid-until dedup)))))) -(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams +(t/deftest get-organization-invitations-includes-org-level-invitations-when-no-teams (let [profile (th/create-profile* 1 {:is-active true}) org-id (uuid/random) org-summary {:id org-id :teams []} - params {::th/type :get-org-invitations + params {::th/type :get-organization-invitations ::rpc/profile-id (:id profile) :organization-id org-id}] @@ -468,7 +468,7 @@ (t/is (= "org-only@example.com" (-> result first :email))) (t/is (some? (-> result first :sent-at)))))) -(t/deftest get-org-invitations-returns-existing-profile-data +(t/deftest get-organization-invitations-returns-existing-profile-data (let [profile (th/create-profile* 1 {:is-active true}) invited (th/create-profile* 2 {:is-active true :fullname "Invited User"}) @@ -479,7 +479,7 @@ org-id (uuid/random) org-summary {:id org-id :teams []} - params {::th/type :get-org-invitations + params {::th/type :get-organization-invitations ::rpc/profile-id (:id profile) :organization-id org-id}] @@ -504,7 +504,7 @@ (t/is (str/ends-with? (:photo-url invitation) (str "/assets/by-id/" photo-id)))))) -(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email +(t/deftest delete-organization-invitations-removes-org-and-org-team-invitations-for-email (let [profile (th/create-profile* 1 {:is-active true}) team-1 (th/create-team* 1 {:profile-id (:id profile)}) team-2 (th/create-team* 2 {:profile-id (:id profile)}) @@ -514,7 +514,7 @@ :teams [{:id (:id team-1)} {:id (:id team-2)}]} target-email "target@example.com" - params {::th/type :delete-org-invitations + params {::th/type :delete-organization-invitations ::rpc/profile-id (:id profile) :organization-id org-id :email "TARGET@example.com"}] @@ -572,7 +572,7 @@ (t/is (= (:id outside-team) (:team-id (first remaining-target)))) (t/is (= 1 (count remaining-other)))))) -(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations +(t/deftest delete-all-organization-invitations-removes-org-and-org-team-invitations (let [profile (th/create-profile* 1 {:is-active true}) team-1 (th/create-team* 1 {:profile-id (:id profile)}) team-2 (th/create-team* 2 {:profile-id (:id profile)}) @@ -581,7 +581,7 @@ org-summary {:id org-id :teams [{:id (:id team-1)} {:id (:id team-2)}]} - params {::th/type :delete-all-org-invitations + params {::th/type :delete-all-organization-invitations :organization-id org-id}] ;; Should be deleted: org-level invitation. @@ -660,10 +660,10 @@ (t/is (present? "dan@example.com")) (t/is (present? "erin@example.com"))))) -(t/deftest delete-all-org-invitations-handles-org-with-no-teams +(t/deftest delete-all-organization-invitations-handles-org-with-no-teams (let [profile (th/create-profile* 1 {:is-active true}) org-id (uuid/random) - params {::th/type :delete-all-org-invitations + params {::th/type :delete-all-organization-invitations :organization-id org-id}] ;; Org-level invitation should still be deleted. @@ -686,14 +686,14 @@ (t/is (nil? (:result out))) (t/is (empty? remaining))))) -(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete +(t/deftest exists-organization-team-invitations-for-non-members-reports-invitations-to-delete (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) profile (th/create-profile* 4 {:is-active true}) team-1 (th/create-team* 1 {:profile-id (:id profile)}) team-2 (th/create-team* 2 {:profile-id (:id profile)}) outside-team (th/create-team* 3 {:profile-id (:id profile)}) org-id (uuid/random) - base-params {::th/type :exists-org-team-invitations-for-non-members + base-params {::th/type :exists-organization-team-invitations-for-non-members ::rpc/profile-id (:id profile) :organization-id org-id :team-ids [(:id team-1) (:id team-2)] @@ -744,14 +744,14 @@ :valid-until (ct/in-future "24h")}) (t/is (true? (exist!))))) -(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations +(t/deftest delete-organization-team-invitations-for-non-members-removes-non-member-invitations (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) profile (th/create-profile* 4 {:is-active true}) team-1 (th/create-team* 1 {:profile-id (:id profile)}) team-2 (th/create-team* 2 {:profile-id (:id profile)}) outside-team (th/create-team* 3 {:profile-id (:id profile)}) org-id (uuid/random) - params {::th/type :delete-org-team-invitations-for-non-members + params {::th/type :delete-organization-team-invitations-for-non-members ::rpc/profile-id (:id profile) :organization-id org-id :team-ids [(:id team-1) (:id team-2)] @@ -830,7 +830,7 @@ (t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Tests: remove-from-org +;; Tests: remove-from-organization ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- make-org-summary @@ -853,7 +853,7 @@ :remove-profile-from-org nil nil))) -(t/deftest remove-from-org-happy-path-no-extra-teams +(t/deftest remove-from-organization-happy-path-no-extra-teams ;; User is only in its default team (which has files); it should be ;; kept, renamed and unset as default. A notification must be sent. (let [org-owner (th/create-profile* 1 {:is-active true}) @@ -875,7 +875,7 @@ mbus/pub! (fn [_bus & {:keys [topic message]}] (swap! calls conj {:topic topic :message message}))] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -898,7 +898,7 @@ (t/is (= "Acme Org" (:organization-name msg))) (t/is (= "dashboard.user-no-longer-belong-org" (:notification msg)))))) -(t/deftest remove-from-org-deletes-empty-default-team +(t/deftest remove-from-organization-deletes-empty-default-team ;; When the default team has no files it should be soft-deleted. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -913,7 +913,7 @@ out (with-redefs [nitrate/call (nitrate-call-mock org-summary) mbus/pub! (fn [& _] nil)] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -923,7 +923,7 @@ (let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})] (t/is (some? (:deleted-at team)))))) -(t/deftest remove-from-org-deletes-sole-owner-team +(t/deftest remove-from-organization-deletes-sole-owner-team ;; When the user is the sole member of an org team it should be deleted. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -939,7 +939,7 @@ out (with-redefs [nitrate/call (nitrate-call-mock org-summary) mbus/pub! (fn [& _] nil)] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -949,7 +949,7 @@ (let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})] (t/is (some? (:deleted-at team)))))) -(t/deftest remove-from-org-transfers-ownership-of-multi-member-team +(t/deftest remove-from-organization-transfers-ownership-of-multi-member-team ;; When the user owns a team that has another non-owner member, ownership ;; is transferred to that member by the endpoint automatically. (let [org-owner (th/create-profile* 1 {:is-active true}) @@ -970,7 +970,7 @@ out (with-redefs [nitrate/call (nitrate-call-mock org-summary) mbus/pub! (fn [& _] nil)] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -984,7 +984,7 @@ (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})] (t/is (true? (:is-owner rel)))))) -(t/deftest remove-from-org-exits-non-owned-team +(t/deftest remove-from-organization-exits-non-owned-team ;; When the user is a non-owner member of an org team, they simply leave. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1003,7 +1003,7 @@ out (with-redefs [nitrate/call (nitrate-call-mock org-summary) mbus/pub! (fn [& _] nil)] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1017,7 +1017,7 @@ (let [team (th/db-get :team {:id (:id extra-team)})] (t/is (some? team))))) -(t/deftest remove-from-org-error-nobody-to-reassign +(t/deftest remove-from-organization-error-nobody-to-reassign ;; When the user owns a multi-member team but every other member is ;; also an owner, the auto-selection query finds nobody and raises. (let [other-owner (th/create-profile* 1 {:is-active true}) @@ -1041,7 +1041,7 @@ out (with-redefs [nitrate/call (nitrate-call-mock org-summary) mbus/pub! (fn [& _] nil)] (management-command-with-nitrate! - {::th/type :remove-from-org + {::th/type :remove-from-organization ::rpc/profile-id (:id other-owner) :profile-id (:id user) :organization-id organization-id @@ -1051,10 +1051,10 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))) -;; Tests: get-remove-from-org-summary +;; Tests: get-remove-from-organization-summary ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest get-remove-from-org-summary-no-extra-teams +(t/deftest get-remove-from-organization-summary-no-extra-teams ;; User only has a default team — nothing to delete/transfer/exit. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1068,7 +1068,7 @@ :org-teams []) out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary + {::th/type :get-remove-from-organization-summary ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1080,7 +1080,7 @@ :teams-to-detach 0} (:result out))))) -(t/deftest get-remove-from-org-summary-with-teams-to-delete +(t/deftest get-remove-from-organization-summary-with-teams-to-delete ;; User owns a sole-member extra org team → 1 to delete. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1095,7 +1095,7 @@ :org-teams [(:id extra-team)]) out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary + {::th/type :get-remove-from-organization-summary ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1107,7 +1107,7 @@ :teams-to-detach 0} (:result out))))) -(t/deftest get-remove-from-org-summary-with-teams-to-transfer +(t/deftest get-remove-from-organization-summary-with-teams-to-transfer ;; User owns a multi-member extra org team → 1 to transfer. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1126,7 +1126,7 @@ :org-teams [(:id extra-team)]) out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary + {::th/type :get-remove-from-organization-summary ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1138,7 +1138,7 @@ :teams-to-detach 0} (:result out))))) -(t/deftest get-remove-from-org-summary-with-teams-to-exit +(t/deftest get-remove-from-organization-summary-with-teams-to-exit ;; User is a non-owner member of an org team → 1 to exit. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1156,7 +1156,7 @@ :org-teams [(:id extra-team)]) out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary + {::th/type :get-remove-from-organization-summary ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1168,7 +1168,7 @@ :teams-to-detach 0} (:result out))))) -(t/deftest get-remove-from-org-summary-does-not-mutate +(t/deftest get-remove-from-organization-summary-does-not-mutate ;; Calling the summary endpoint must not modify any teams. (let [org-owner (th/create-profile* 1 {:is-active true}) user (th/create-profile* 2 {:is-active true}) @@ -1183,7 +1183,7 @@ :org-teams [(:id extra-team)]) _ (with-redefs [nitrate/call (nitrate-call-mock org-summary)] (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary + {::th/type :get-remove-from-organization-summary ::rpc/profile-id (:id org-owner) :profile-id (:id user) :organization-id organization-id @@ -1201,7 +1201,7 @@ (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] (t/is (some? rel2))))) -(t/deftest notify-org-sso-change-sends-setup-sso-email-once-per-recipient +(t/deftest notify-organization-sso-change-sends-setup-sso-email-once-per-recipient (let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"}) member (th/create-profile* 2 {:is-active true :fullname "Member" @@ -1216,7 +1216,7 @@ :name org-name :teams [{:id (:id team)}]} sent (atom []) - params {::th/type :notify-org-sso-change + params {::th/type :notify-organization-sso-change :organization-id org-id :updated-props false :became-active true}] @@ -1269,9 +1269,9 @@ (t/is (= org-name (:organization-name email-params))) (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) -(t/deftest notify-org-sso-change-skips-email-when-not-active +(t/deftest notify-organization-sso-change-skips-email-when-not-active (let [sent (atom []) - params {::th/type :notify-org-sso-change + params {::th/type :notify-organization-sso-change :organization-id (uuid/random) :updated-props false :became-active false}] diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 5abc969155..279c622934 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -59,7 +59,7 @@ (st/emit! (rt/nav-raw :href "/admin-console/"))) ([{:keys [organization-id organization-slug]}] (if (and organization-id organization-slug) - (let [href (dm/str "/admin-console/org/" + (let [href (dm/str "/admin-console/organization/" (u/percent-encode organization-slug) "/" (u/percent-encode (str organization-id)) @@ -67,9 +67,9 @@ (st/emit! (rt/nav-raw :href href))) (st/emit! (rt/nav-raw :href "/admin-console/"))))) -(defn go-to-nitrate-ac-create-org +(defn go-to-nitrate-ac-create-organization [] - (st/emit! (rt/nav-raw :href "/admin-console/?action=create-org"))) + (st/emit! (rt/nav-raw :href "/admin-console/?action=create-organization"))) (defn can-send-invitations? [{:keys [organization profile-id team-permissions]}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 269e2a3793..d64063b522 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -320,7 +320,7 @@ (st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true})) (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-ac-create-org) + (dnt/go-to-nitrate-ac-create-organization) :else (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) @@ -766,7 +766,7 @@ (st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true})) (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-ac-create-org) + (dnt/go-to-nitrate-ac-create-organization) :else (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index b7a4e9a3e6..e09bea4f71 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -169,7 +169,7 @@ {:show-contact-sales-option true}))))) handle-go-to-cc - (mf/use-fn dnt/go-to-nitrate-ac-create-org) + (mf/use-fn dnt/go-to-nitrate-ac-create-organization) handle-open-renew-modal (mf/use-fn #(st/emit! (modal/show :nitrate-code-activation {:renew? true})))] diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs index c0d5e0e77c..d2e1a7bd24 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs @@ -36,7 +36,7 @@ (mf/use-fn (fn [] (modal/hide!) - (dnt/go-to-nitrate-ac-create-org)))] + (dnt/go-to-nitrate-ac-create-organization)))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} From cddbd8e8975c6e7e3546f511e66451cd0c3bf1eb Mon Sep 17 00:00:00 2001 From: Juan de la Cruz <delacruzgarciajuan@gmail.com> Date: Mon, 20 Jul 2026 10:06:02 +0200 Subject: [PATCH 62/63] :sparkles: Add font family preview in typography selector (#10411) Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> --- frontend/package.json | 1 + frontend/scripts/_helpers.js | 43 +- frontend/scripts/build-app-assets.js | 1 + frontend/scripts/build-fonts-preview.js | 384 ++++++++++++++++++ frontend/scripts/watch.js | 1 + frontend/src/app/config.cljs | 5 + frontend/src/app/main/fonts.cljs | 108 ++++- frontend/src/app/main/ui/workspace.cljs | 9 + .../sidebar/options/menus/typography.cljs | 83 +++- .../sidebar/options/menus/typography.scss | 29 ++ frontend/src/app/util/dom.cljs | 7 + frontend/src/app/util/globals.js | 7 + 12 files changed, 670 insertions(+), 8 deletions(-) create mode 100644 frontend/scripts/build-fonts-preview.js diff --git a/frontend/package.json b/frontend/package.json index 32da31fbf9..f743baa28a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build:app:assets": "node ./scripts/build-app-assets.js", + "build:fonts-preview": "node ./scripts/build-fonts-preview.js", "build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js index c6396f99bc..ea5a4dff0b 100644 --- a/frontend/scripts/_helpers.js +++ b/frontend/scripts/_helpers.js @@ -15,6 +15,8 @@ import pLimit from "p-limit"; import ppt from "pretty-time"; import wpool from "workerpool"; +import { buildFontsPreviewSprite } from "./build-fonts-preview.js"; + function getCoreCount() { return os.cpus().length; } @@ -48,8 +50,9 @@ async function findFiles(basePath, predicate, options = {}) { return files; } -function syncDirs(originPath, destPath) { - const command = `rsync -ar --delete ${originPath} ${destPath}`; +function syncDirs(originPath, destPath, excludes = []) { + const excludeArgs = excludes.map((p) => `--exclude=${p}`).join(" "); + const command = `rsync -ar --delete ${excludeArgs} ${originPath} ${destPath}`; return new Promise((resolve, reject) => { proc.exec(command, (cause, stdout) => { @@ -540,6 +543,36 @@ export async function compileSvgSprites() { } } +export async function compileFontsPreviewSprite() { + const start = process.hrtime(); + log.info("init: compile fonts preview sprite"); + let error = false; + let result; + + try { + result = await buildFontsPreviewSprite(); + } catch (cause) { + error = cause; + } + + const end = process.hrtime(start); + + if (error) { + log.error("error: compile fonts preview sprite", `(${ppt(end)})`); + console.error(error); + } else if (result.skipped) { + log.info( + "done: compile fonts preview sprite (up-to-date, skipped)", + `(${ppt(end)})`, + ); + } else { + log.info( + `done: compile fonts preview sprite (${result.ok} ok, ${result.failed} fallback)`, + `(${ppt(end)})`, + ); + } +} + export async function compileTemplates() { const start = process.hrtime(); let error = false; @@ -584,7 +617,11 @@ export async function copyAssets() { log.info("init: copy assets"); await syncDirs("resources/images/", "resources/public/images/"); - await syncDirs("resources/fonts/", "resources/public/fonts/"); + // The font preview sprite is generated into public/fonts/ (not committed), so + // exclude it from --delete to keep it across builds (see compileFontsPreviewSprite). + await syncDirs("resources/fonts/", "resources/public/fonts/", [ + "fonts-preview-sprite.svg", + ]); const end = process.hrtime(start); log.info("done: copy assets", `(${ppt(end)})`); diff --git a/frontend/scripts/build-app-assets.js b/frontend/scripts/build-app-assets.js index e0fe385dce..f1f5a970c8 100644 --- a/frontend/scripts/build-app-assets.js +++ b/frontend/scripts/build-app-assets.js @@ -3,6 +3,7 @@ import * as h from "./_helpers.js"; await h.ensureDirectories(); await h.compileStyles(); await h.copyAssets(); +await h.compileFontsPreviewSprite(); await h.copyWasmPlayground(); await h.compileSvgSprites(); await h.compileTranslations(); diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js new file mode 100644 index 0000000000..b1aa7c839c --- /dev/null +++ b/frontend/scripts/build-fonts-preview.js @@ -0,0 +1,384 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC Sucursal en España SL + +// Builds one SVG sprite previewing every catalog (built-in + Google) font name, +// outlined in its own typeface, so the picker loads it once instead of one +// request per font. Custom uploads aren't baked in and use the runtime fallback. +// +// Part of the asset build (compileFontsPreviewSprite in _helpers.js): +// regenerates only when missing or older than the gfonts catalog. Run directly +// to force a rebuild. See the technical-guide doc for the full design. + +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import ph from "node:path"; +import url from "node:url"; + +import opentype from "opentype.js"; + +// Coordinates are emitted in CSS px (1 user unit == 1px), so the UI sizes rows +// without per-font metrics. Each name is laid out on its baseline then fitted +// into a fixed BOX_HEIGHT box (see buildSymbol). See the technical-guide doc. +const FONT_SIZE = 18; // visual cap/x-height target, matches the label typography +const BOX_HEIGHT = 28; // display box height in px; keep in sync with the scss +const VPAD = 1; // px of breathing room top/bottom before a name is scaled to fit +// Decimals of precision. 1 (≈0.1px grid) keeps glyphs smooth; 0 makes them +// wobbly. Sprite is ~2.3MB gzip at 1 with the relative encoding (serializePath). +const PATH_PRECISION = 1; + +const CACHE_DIR = ph.join(os.tmpdir(), "penpot-fonts-preview-cache"); +const CONCURRENCY = 24; +// Written straight to the served dir (like the SVG sprite), and excluded from +// copyAssets' `rsync --delete` so it survives builds — see the doc / _helpers.js. +export const OUTPUT = "resources/public/fonts/fonts-preview-sprite.svg"; + +// Color fonts (COLR/SVG glyph tables) make opentype.js take a browser-only +// DOMParser path that rejects a promise and crashes Node. We only need vector +// outlines, so swallow those rejections and the COLR warning during generation +// (restored afterwards so other build steps are unaffected). +function installFontParsingGuards() { + const onRejection = () => {}; + process.on("unhandledRejection", onRejection); + + const origWarn = console.warn; + console.warn = (msg, ...rest) => { + if (typeof msg === "string" && msg.includes("COLR")) return; + origWarn(msg, ...rest); + }; + + return () => { + process.off("unhandledRejection", onRejection); + console.warn = origWarn; + }; +} + +// Mirror of cuerdas.core/slug as used by the `parse-gfont` macro in +// src/app/main/fonts.clj so that ids match `(str "gfont-" (str/slug family))`. +function slug(value) { + return value + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +async function findGfontsJson() { + const dir = "resources/fonts"; + const entries = await fs.readdir(dir); + const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort(); + if (matches.length === 0) { + throw new Error(`no gfonts.*.json found in ${dir}`); + } + return ph.join(dir, matches[matches.length - 1]); +} + +async function readCatalog() { + const builtin = [ + { + id: "sourcesanspro", + name: "Source Sans Pro", + source: { + type: "file", + path: "resources/fonts/sourcesanspro-regular.ttf", + }, + }, + ]; + + const gfontsPath = await findGfontsJson(); + const raw = JSON.parse(await fs.readFile(gfontsPath, "utf-8")); + const google = (raw.items || []).map((item) => { + const family = item.family; + // Prefer the "menu" subset: a tiny TTF Google ships containing exactly the + // glyphs needed to render the family name in a font picker. + const url = + item.menu || + (item.files && (item.files.regular || Object.values(item.files)[0])); + return { + id: `gfont-${slug(family)}`, + name: family, + source: { type: "url", url }, + }; + }); + + return [...builtin, ...google]; +} + +async function fetchWithCache(url) { + const key = createHash("sha1").update(url).digest("hex") + ".ttf"; + const cached = ph.join(CACHE_DIR, key); + try { + return await fs.readFile(cached); + } catch { + // not cached yet + } + + let lastErr; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + await fs.writeFile(cached, buf); + return buf; + } catch (err) { + lastErr = err; + } + } + throw lastErr; +} + +async function loadFontBuffer(source) { + if (source.type === "file") return fs.readFile(source.path); + if (!source.url) throw new Error("missing font url"); + return fetchWithCache(source.url); +} + +// Lay the name out glyph-by-glyph with sanitized advances, NOT via +// `font.getPath`: some menu subsets have a broken kern/NaN advance that emits +// `NaN` into the path, which makes browsers stop parsing mid-path (only the +// first glyph renders). Baseline at y=0; buildSymbol re-centers afterwards. +// `hasTofu` flags glyphs the subset lacks (.notdef → "tofu" boxes), dropping the +// font to the runtime fallback. +function renderName(font, text) { + const scale = FONT_SIZE / font.unitsPerEm; + const path = new opentype.Path(); + let x = 0; + let hasTofu = false; + for (const ch of text) { + const glyph = font.charToGlyph(ch); + if (glyph.index === 0 && ch.trim() !== "") hasTofu = true; + path.extend(glyph.getPath(x, 0, FONT_SIZE)); + const advance = glyph.advanceWidth; + x += (Number.isFinite(advance) ? advance : font.unitsPerEm * 0.5) * scale; + } + return { path, hasTofu }; +} + +// Round to PATH_PRECISION decimals (normalizing -0). Done BEFORE deltas are +// taken (serializePath) so the relative encoding never drifts. +function roundGrid(value) { + return Number(value.toFixed(PATH_PRECISION)) + 0; +} + +// Format an (already-rounded) number compactly: drop the integer "0" from +// "0.x"/"-0.x" to save bytes (".x"/"-.x" are valid SVG numbers). +function fmt(n) { + let s = n.toString(); + if (s.startsWith("0.")) s = s.slice(1); + else if (s.startsWith("-0.")) s = "-" + s.slice(2); + return s; +} + +// Serialize paths ourselves (opentype's `toPathData` separates numbers with +// spaces only — ambiguous enough that browsers bail mid-path). Format: comma +// WITHIN a pair, space BETWEEN pairs. Commands are RELATIVE (lowercase m/l/q/c) +// for much smaller, better-compressing output (~6.2MB → ~2.3MB gzip), with no +// geometry change. Curve control points are relative to the pen before the +// command, and `z` returns the pen to the subpath start — both tracked below. +// Coordinates are baked through the affine fit (scale `s`, translate tx/ty). +function serializePath(commands, s, tx, ty) { + const ax = (v) => roundGrid(v * s + tx); + const ay = (v) => roundGrid(v * s + ty); + let out = ""; + // px/py: current pen (rounded, absolute). sx/sy: start of the current subpath, + // which the pen snaps back to on `z`. + let px = 0, + py = 0, + sx = 0, + sy = 0; + // Re-round the delta: subtracting two grid values reintroduces float noise + // (13.5 - 10.9 === 2.6000000000000014). Drift-free, since the pen tracks the + // exact rounded absolute (x/y), not the emitted delta. + const d = (to, from) => fmt(roundGrid(to - from)); + for (const c of commands) { + switch (c.type) { + case "M": { + const x = ax(c.x), + y = ay(c.y); + out += `m${d(x, px)},${d(y, py)}`; + px = sx = x; + py = sy = y; + break; + } + case "L": { + const x = ax(c.x), + y = ay(c.y); + out += `l${d(x, px)},${d(y, py)}`; + px = x; + py = y; + break; + } + case "Q": { + const x1 = ax(c.x1), + y1 = ay(c.y1), + x = ax(c.x), + y = ay(c.y); + out += `q${d(x1, px)},${d(y1, py)} ${d(x, px)},${d(y, py)}`; + px = x; + py = y; + break; + } + case "C": { + const x1 = ax(c.x1), + y1 = ay(c.y1), + x2 = ax(c.x2), + y2 = ay(c.y2), + x = ax(c.x), + y = ay(c.y); + out += + `c${d(x1, px)},${d(y1, py)} ` + + `${d(x2, px)},${d(y2, py)} ${d(x, px)},${d(y, py)}`; + px = x; + py = y; + break; + } + case "Z": + out += "z"; + px = sx; + py = sy; + break; + } + } + return out; +} + +function buildSymbol({ id, name }, buffer) { + const ab = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + const font = opentype.parse(ab); + // Drop the SVG color-glyph table to force outline rendering (see above). + font.tables.svg = undefined; + + const { path, hasTofu } = renderName(font, name); + // Drop fonts whose name needs glyphs the subset lacks — they would render as + // .notdef boxes; the runtime fallback loads the real font instead. + if (hasTofu) throw new Error("missing glyphs (tofu)"); + + const bb = path.getBoundingBox(); + if (!Number.isFinite(bb.x1) || bb.x2 <= bb.x1) throw new Error("empty path"); + + // Fit into BOX_HEIGHT, centered by bounding box so tall ascenders/descenders + // aren't clipped; names taller than the box are scaled down. Left-aligned at 0. + const usable = BOX_HEIGHT - 2 * VPAD; + const height = bb.y2 - bb.y1; + const s = height > usable ? usable / height : 1; + const tx = -bb.x1 * s; + const ty = BOX_HEIGHT / 2 - ((bb.y1 + bb.y2) / 2) * s; + + const d = serializePath(path.commands, s, tx, ty); + if (!d || d.length === 0) throw new Error("empty path"); + // A stray NaN (non-finite outline point) would truncate the glyph in the + // browser; drop the font so it cleanly falls back to the runtime loader. + if (d.includes("NaN")) throw new Error("non-finite path"); + // No `fill`: the UI's <use> provides `currentColor` so it follows the theme. + return `<g id="font-preview-${id}"><path d="${d}"/></g>`; +} + +async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let cursor = 0; + async function worker() { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + } + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + return results; +} + +// True when the sprite exists and is at least as new as the gfonts catalog, so +// the build can skip the expensive, network-bound regeneration. +export async function isSpriteUpToDate(outputPath = OUTPUT) { + try { + const gfontsPath = await findGfontsJson(); + const [outStat, gfontsStat] = await Promise.all([ + fs.stat(outputPath), + fs.stat(gfontsPath), + ]); + return outStat.mtimeMs >= gfontsStat.mtimeMs; + } catch { + // output missing (or no catalog) → not up to date + return false; + } +} + +// Regenerate the sprite into `outputPath`. Skips (returns `{ skipped: true }`) +// when the output is already up to date, unless `force` is set. On a real +// rebuild it returns `{ skipped:false, ok, failed, bytes }`. +export async function buildFontsPreviewSprite({ + outputPath = OUTPUT, + force = false, +} = {}) { + if (!force && (await isSpriteUpToDate(outputPath))) { + return { skipped: true }; + } + + const restoreGuards = installFontParsingGuards(); + try { + await fs.mkdir(CACHE_DIR, { recursive: true }); + + const catalog = await readCatalog(); + console.log(`building font preview sprite for ${catalog.length} fonts…`); + + let ok = 0; + const failed = []; + const symbols = await mapLimit(catalog, CONCURRENCY, async (font) => { + try { + const buffer = await loadFontBuffer(font.source); + const symbol = buildSymbol(font, buffer); + ok++; + if (ok % 200 === 0) console.log(` …${ok}/${catalog.length}`); + return symbol; + } catch (err) { + failed.push({ id: font.id, reason: err.message }); + return null; + } + }); + + const body = symbols.filter(Boolean).join(""); + const sprite = + `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` + + body + + `</svg>\n`; + + await fs.mkdir(ph.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, sprite); + + if (failed.length) { + console.log( + `font preview sprite: ${failed.length} fonts will use the runtime fallback:`, + ); + for (const f of failed.slice(0, 40)) + console.log(` - ${f.id}: ${f.reason}`); + if (failed.length > 40) console.log(` …and ${failed.length - 40} more`); + } + + return { skipped: false, ok, failed: failed.length, bytes: sprite.length }; + } finally { + restoreGuards(); + } +} + +// When invoked directly (`node scripts/build-fonts-preview.js`), force a full +// rebuild regardless of the up-to-date check. +const isDirectRun = + process.argv[1] && + import.meta.url === url.pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + const res = await buildFontsPreviewSprite({ force: true }); + const mb = (res.bytes / 1024 / 1024).toFixed(1); + console.log( + `done: ${res.ok} ok, ${res.failed} failed → ${OUTPUT} (${mb} MB, served gzipped)`, + ); +} diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js index 38007d4454..700874be74 100644 --- a/frontend/scripts/watch.js +++ b/frontend/scripts/watch.js @@ -63,6 +63,7 @@ async function compileSass(path) { await h.ensureDirectories(); await compileSassAll(); await h.copyAssets(); +await h.compileFontsPreviewSprite(); await h.copyWasmPlayground(); await h.compileTranslations(); await h.compileSvgSprites(); diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 359a984097..dc2c5a237a 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -196,6 +196,11 @@ (get :path) (str "?version=" version-tag))) +(def fonts-preview-sprite-uri + (-> public-uri + (u/join "fonts/fonts-preview-sprite.svg") + (str))) + (defn external-feature-flag [flag value] (let [f (obj/get global "externalFeatureFlag")] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 02181c3db8..761e1dbc6f 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -108,6 +108,108 @@ ;; only know if the font is needed or not (defonce ^:dynamic loaded-hints (l/atom #{})) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREVIEW SPRITE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; A prebuilt SVG sprite (generated by scripts/build-fonts-preview.js) holds every +;; built-in + Google font name outlined in its own typeface, so the picker can +;; preview the whole catalog with no per-font requests. Fonts not in it (custom +;; uploads, ones that fail to bake) use the runtime fallback. +;; +;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched +;; markup is cached here as a string (`:svg`) and the nodes are materialized only +;; while the picker is open (attach/detach below). `:ids` are the font ids it +;; covers, so the UI can pick sprite vs fallback. +(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil})) + +;; Id prefix shared with the generator and the UI's `<use href>`; referenced here +;; rather than re-declared so the contract stays in one place. +(def preview-sprite-prefix "font-preview-") + +(defn- collect-preview-ids + "Set of font ids present in the sprite, read from the injected `container` + element's `<g id=\"font-preview-…\">` groups (prefix stripped)." + [^js container] + (let [nodes (dom/query-all container (dm/str "g[id^=\"" preview-sprite-prefix "\"]")) + plen (count preview-sprite-prefix)] + (persistent! + (reduce (fn [acc node] + (conj! acc (subs (dom/get-attribute node "id") plen))) + (transient #{}) + (array-seq nodes))))) + +(defn- reset-preview-sprite-error! + [] + ;; :error → the UI shows plain names (no previews, no per-font load storm); a + ;; later `prefetch-preview-sprite!` call can retry. + (reset! preview-sprite {:status :error :ids #{} :svg nil})) + +(defn- parse-sprite-svg + "Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection + surface). Returns the root `<svg>` element, or nil if it isn't valid SVG." + [text] + (let [doc (.parseFromString (js/DOMParser.) text "image/svg+xml") + root (.-documentElement doc)] + ;; A malformed document yields a <parsererror> root instead of <svg>. + (when (and (= "svg" (.-tagName ^js root)) + (nil? (dom/query doc "parsererror"))) + root))) + +(defn prefetch-preview-sprite! + "Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see + `attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet + (`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or + `:ready`." + [] + (when (and (globals/browser?) + (contains? #{:idle :error} (:status @preview-sprite))) + (swap! preview-sprite assoc :status :loading) + (->> (http/send! {:method :get + :uri cf/fonts-preview-sprite-uri + :response-type :text + :mode :cors}) + (rx/subs! + (fn [response] + ;; http/send! doesn't reject on non-2xx; guard so an error body isn't + ;; cached as the sprite. + (if (http/success? response) + (swap! preview-sprite assoc :status :ready :svg (:body response)) + (do + (log/wrn :hint "cannot load font preview sprite" :status (:status response)) + (reset-preview-sprite-error!)))) + (fn [cause] + (log/wrn :hint "cannot load font preview sprite" :cause cause) + (reset-preview-sprite-error!)))))) + +(defn attach-preview-sprite! + "Materialize the cached sprite into the DOM (hidden) so rows can reference its + glyph groups via `<use>`, and record the covered font ids. Returns the injected + node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the + markup is invalid. Parsing happens here, not on prefetch, so the cost is paid + only while the picker is open." + [] + (let [{:keys [status svg]} @preview-sprite] + (when (and (globals/browser?) (= :ready status) (some? svg)) + (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] + ;; The node already carries display:none + aria-hidden from the generator. + (do + (dom/set-attribute! node "id" "font-preview-sprite") + (when-let [body-el (unchecked-get globals/document "body")] + (dom/append-child! body-el node)) + (swap! preview-sprite assoc :ids (collect-preview-ids node)) + node) + (do + (log/wrn :hint "cannot parse font preview sprite") + (reset-preview-sprite-error!) + nil))))) + +(defn detach-preview-sprite! + "Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The + cached markup and `:ids` stay, so reopening re-attaches without a refetch." + [node] + (dom/remove! node)) + (defn- add-font-css! "Creates a style element and attaches it to the dom." [id css] @@ -158,7 +260,7 @@ (defmethod load-font :google [{:keys [id ::on-loaded] :as font}] - (when (exists? js/window) + (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "google") (let [url (generate-gfonts-url font)] (->> (fetch-gfont-css url) @@ -200,7 +302,7 @@ (defmethod load-font :custom [{:keys [id ::on-loaded] :as font}] - (when (exists? js/window) + (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "custom") (let [css (generate-custom-font-css font)] (add-font-css! id css) @@ -215,7 +317,7 @@ ([font-id] (ensure-loaded! font-id nil)) ([font-id variant-id] (log/dbg :action "try-ensure-loaded!" :font-id font-id :variant-id variant-id) - (if-not (exists? js/window) + (if-not (globals/browser?) ;; If we are in the worker environment, we just mark it as loaded ;; without really loading it. (do diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index bb69485774..e70cbe685c 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -8,12 +8,14 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] + [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] [app.main.data.persistence :as dps] [app.main.data.plugins :as dpl] [app.main.data.workspace :as dw] [app.main.features :as features] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.router :as-alias rt] [app.main.store :as st] @@ -230,6 +232,13 @@ (st/emit! (dps/initialize-persistence) (dpl/update-plugins-permissions-peek))) + ;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount + ;; (kept in memory, not the DOM) so the typography selector renders previews on + ;; open with no network wait. Remove the flag check to drop the feature. + (mf/with-effect [] + (when (contains? cf/flags :font-preview) + (fonts/prefetch-preview-sprite!))) + ;; Setting the layout preset by its name (mf/with-effect [layout-name] (st/emit! (dw/initialize-workspace-layout layout-name))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index dc242cb19c..0f6cc60077 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -12,6 +12,7 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.types.text :as txt] + [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.common :as dcm] [app.main.data.fonts :as fts] @@ -39,6 +40,7 @@ [app.util.timers :as tm] [cuerdas.core :as str] [goog.events :as events] + [promesa.core :as p] [rumext.v2 :as mf])) (defn- attr->string [value] @@ -63,11 +65,73 @@ (or next (peek fonts))) current)) +(defn- use-font-lazy-load + "Lazily loads `font-id` for the fallback preview when `fallback?`, on idle so + fast scrolling over recycled virtualized rows doesn't storm requests (cancelled + if the row is reused first). Returns whether the font face is loaded yet." + [font-id fallback?] + (let [loaded? (mf/use-state #(and fallback? (contains? @fonts/loaded font-id)))] + (mf/use-effect + (mf/deps font-id fallback?) + (fn [] + (let [already? (and fallback? (contains? @fonts/loaded font-id))] + (reset! loaded? already?) + (if (and fallback? (not already?)) + (let [cancelled? (volatile! false) + task (tm/schedule-on-idle + (fn [] + (-> (fonts/ensure-loaded! font-id) + (p/then (fn [_] + (when-not @cancelled? + (reset! loaded? true)))) + (p/catch (fn [_] nil)))))] + (fn [] + (vreset! cancelled? true) + (tm/dispose! task))) + (constantly nil))))) + @loaded?)) + +;; --- FEATURE: font preview (flag :font-preview) ------------------------------ +;; font-item-preview* and use-font-lazy-load are the whole feature. They are only +;; rendered/called behind the `:font-preview` flag check in font-item* below, so +;; their hooks never run when the flag is off. To remove the flag, inline +;; font-item-preview* into font-item* and drop the plain-name branch. + +(mf/defc font-item-preview* + "Row content with previews: a vector preview from the shared sprite for catalog + fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't + cover." + {::mf/wrap [mf/memo]} + [{:keys [font]}] + (let [font-id (:id font) + sprite (mf/deref fonts/preview-sprite) + in-sprite? (contains? (:ids sprite) font-id) + + ;; Fallback is ONLY for custom fonts: ones the (ready) sprite doesn't + ;; cover. If the sprite isn't ready (loading/error) we show the plain name + ;; rather than runtime-loading the whole catalog. + fallback? (and (= :ready (:status sprite)) + (not in-sprite?)) + loaded? (use-font-lazy-load font-id fallback?)] + (if in-sprite? + ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. + [:svg {:class (stl/css :font-item-preview) + :role "img" + :aria-label (:name font)} + [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] + [:span {:class (stl/css :font-item-label) + :style (when loaded? + #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} + (:name font)]))) + (mf/defc font-item* {::mf/wrap [mf/memo]} [{:keys [font is-current on-click style]}] (let [item-ref (mf/use-ref) - on-click (mf/use-fn (mf/deps font) #(on-click font))] + on-click (mf/use-fn (mf/deps font) #(on-click font)) + ;; FLAG :font-preview — gates the feature markup AND its row styling + ;; (.font-item-preview-on in the scss). Remove this and its two uses below. + preview? (contains? cf/flags :font-preview)] (mf/use-effect (mf/deps is-current) @@ -82,8 +146,11 @@ :ref item-ref :on-click on-click} [:div {:class (stl/css-case :font-item true + :font-item-preview-on preview? :selected is-current)} - [:span {:class (stl/css :font-item-label)} (:name font)] + (if preview? + [:> font-item-preview* {:font font}] + [:span {:class (stl/css :font-item-label)} (:name font)]) (when is-current [:> icon* {:icon-id i/tick :size "s"}])]])) @@ -115,6 +182,8 @@ fonts (mf/with-memo [state fonts] (filter-fonts state fonts)) + sprite-status (:status (mf/deref fonts/preview-sprite)) + recent-fonts (mf/deref refs/recent-fonts) recent-fonts (mf/with-memo [state recent-fonts] (filter-fonts state recent-fonts)) @@ -165,6 +234,16 @@ (let [key (events/listen js/document "keydown" on-key-down)] #(events/unlistenByKey key))) + ;; FLAG :font-preview — materialize the preview sprite into the DOM only while + ;; the picker is open (markup is prefetched on workspace load), removing it on + ;; close so its ~2000 nodes aren't kept around idle. Remove the flag clause to + ;; drop the feature. + (mf/with-effect [sprite-status] + (when (and (contains? cf/flags :font-preview) + (= :ready sprite-status)) + (let [node (fonts/attach-preview-sprite!)] + #(fonts/detach-preview-sprite! node)))) + (mf/with-effect [@selected] (when-let [inst (mf/ref-val flist)] (when-let [index (:index @selected)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index b12d3cd835..4281c18e61 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -11,6 +11,10 @@ @use "ds/mixins.scss" as *; @use "ds/_utils.scss" as *; +// Must match BOX_HEIGHT in scripts/build-fonts-preview.js (the sprite bakes each +// font name into that box at 1 unit == 1px). +$font-preview-box-height: 28px; + .typography-entry { --actions-visibility: hidden; --typography-entry-background-color: var(--color-background-tertiary); @@ -392,6 +396,31 @@ min-inline-size: 0; } +// --- FLAG :font-preview row styling. Only applied when font-item* adds the +// .font-item-preview-on modifier; remove this whole block with the flag so rows +// render exactly as before. +.font-item-preview-on { + // Center & clip so a previewed font's own metrics never grow/overflow the row. + align-items: center; + overflow: hidden; + + // Match the label size to the vector previews. + .font-item-label { + @include t.use-typography("body-medium"); + + line-height: 1.2; + } +} + +// `currentColor` makes the glyph fill follow the row text color (theme + selected). +.font-item-preview { + flex-grow: 1; + min-inline-size: 0; + inline-size: 100%; + block-size: $font-preview-box-height; + fill: currentcolor; +} + .font-selector-dropdown-full-size { block-size: var(--sidebar-element-options-height); display: grid; diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index faf5501f37..ca2809cfba 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -361,6 +361,13 @@ (.appendChild ^js el child)) el) +(defn import-node + "Import `node` (e.g. parsed in another document) into the current document so + it can be inserted. Deep clone unless `deep?` is false." + ([^js node] (import-node node true)) + ([^js node deep?] + (.importNode globals/document node deep?))) + (defn insert-after! [^js el ^js ref child] (when (and (some? el) (some? ref)) diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index cd9d990023..2539c7a852 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -22,6 +22,13 @@ goog.scope(function () { self.global = globalThis; + // Whether we are running in a real browser (has a window), as opposed to a + // worker or the test environment, where the objects below are mocked. + // Exposed to ClojureScript as `globals/browser?`. + self.browser_QMARK_ = function () { + return typeof goog.global.window !== "undefined"; + }; + function createMockedEventEmitter(k) { /* Allow mocked objects to be event emitters, so other modules * may subscribe to them. From 826072c65d9124d929e153f2b18b0648315860ec Mon Sep 17 00:00:00 2001 From: Shlok Goyal <shlokgoyal1279@gmail.com> Date: Mon, 20 Jul 2026 13:37:26 +0530 Subject: [PATCH 63/63] :sparkles: Remove misleading MCP config from success modal (#10415) Signed-off-by: Shlok1729 <shlokgoyal1279@gmail.com> Signed-off-by: Shlok Goyal <shlokgoyal1279@gmail.com> Co-authored-by: Andrey Antukh <niwi@niwi.nz> --- .../app/main/ui/settings/integrations.cljs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index 403893ea68..3151b73c31 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -116,25 +116,6 @@ (tr "integrations.mcp-key.will-not-expire") (tr "integrations.token.will-not-expire")))]] - (when is-mcp - [:div {:class (stl/css :modal-content)} - [:> text* {:as "div" - :typography t/body-small - :class (stl/css :color-primary)} - (tr "integrations.info.mcp-client-config")] - [:textarea {:class (stl/css :textarea) - :wrap "off" - :rows 7 - :read-only true - :value (dm/str - "{\n" - " \"mcpServers\": {\n" - " \"penpot\": {\n" - " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" - " }\n" - " }" - "\n}")}]]) - [:div {:class (stl/css :modal-footer)} [:> button* {:variant "secondary" :on-click modal/hide!}