diff --git a/.agents/skills/create-commit/SKILL.md b/.agents/skills/create-commit/SKILL.md index f0a612c17e..bf0667ca65 100644 --- a/.agents/skills/create-commit/SKILL.md +++ b/.agents/skills/create-commit/SKILL.md @@ -62,5 +62,6 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. - Do not pass `--author`. Author identity comes from the local git config. - Do not amend a commit you did not create in this session, unless explicitly asked. - Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked. +- Never add a `Co-authored-by:` trailer. It is forbidden and fails `./scripts/check-commit`. - Do not add untracked files that were not created in this session. - Do not skip the `scripts/check-commit` verification step (Iron Rule 4). diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index 29d9c79d60..0de7fe8975 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -47,4 +47,45 @@ jobs: # pattern: '^.+(Resolves|Fixes): \#[0-9]+$' # error: 'You need at least one "Resolves|Fixes: #" line.' + check-commit-guidelines: + if: ${{ !github.event.pull_request.draft }} + name: Check Commit Guidelines + runs-on: ubuntu-latest + steps: + # On PR events check out the base ref, so the script below is + # always the trusted version: PR commits are only read, never + # executed. On push events this resolves to the pushed sha. + - name: Checkout trusted code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.base.sha || github.sha }} + + - name: Fetch PR commits + if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} + run: git fetch --no-tags origin "pull/${{ github.event.pull_request.number }}/head:pr-head" + + - name: Run scripts/check-commit on every new commit + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "push" ]; then + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + RANGE="${{ github.sha }}" + else + RANGE="${{ github.event.before }}..${{ github.sha }}" + fi + else + RANGE="${{ github.event.pull_request.base.sha }}..pr-head" + fi + COMMITS="$(git rev-list --reverse "$RANGE")" + if [ -z "$COMMITS" ]; then + echo "No new commits to check." + exit 0 + fi + FAILED=0 + for COMMIT in $COMMITS; do + ./scripts/check-commit -c "$COMMIT" || FAILED=1 + done + exit "$FAILED" + diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d632c9a471..a60234819f 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -37,8 +37,10 @@ that breaks them is wrong, even if the code is right. - For another commit: `./scripts/check-commit -c ` - **NEVER** hand-wave the body as "one long line". If a line exceeds 76, break it at a space. +- **NEVER add a `Co-authored-by:` trailer.** It is forbidden; `scripts/check-commit` fails the commit when any line matches it (case-insensitive). +- CI runs `scripts/check-commit` on every new commit (`mem:workflow/creating-commits` is enforced by `.github/workflows/commit-checker.yml`), so a forbidden trailer fails the build. - Exceptions inside the body (do not wrap these): `Signed-off-by:`, - `Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL. + `AI-assisted-by:` trailers, and lines carrying a URL. **AI-assisted-by trailer rules:** - Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a11d90851..2d665b0715 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ If AI-assisted content is used, it must be carefully reviewed and verified by a Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues). Before filing, search existing issues to avoid duplicates. +> **Note:** Every new issue starts under the `needs triage` label until the +> product team reviews it. Issues under `needs triage` or `internal` are not +> open to contributions — do not start work on them. Wait for a maintainer to +> confirm it in the issue itself. + Include the following when possible: 1. Steps to reproduce the error. @@ -164,10 +169,15 @@ To save time on both sides, please avoid submitting PRs that: - Submit AI-generated code without human review. - Skip local syntax and formatting checks before submitting. - Skip the [discussion step](#workflow) for non-bug-fix changes. +- Open a PR for an issue under `needs triage` or `internal`. We may close + these PRs without review, without an explanation and without a reply. ### Good first issues We use the `good first issue` label to mark issues appropriate for newcomers. +An issue is open to contributions only after a maintainer says so in the issue +itself. Issues under `needs triage` or `internal` stay closed, however simple +they look. ## Commit Guidelines @@ -213,8 +223,12 @@ Commit messages must follow this format: - Keep the subject to **70 characters** or fewer - **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted) - Separate the subject from the body with a **blank line** +- Never add a `Co-authored-by` trailer — it is forbidden and fails + `./scripts/check-commit` You can check a commit against these rules with `./scripts/check-commit`. +CI runs the same script on every new commit in +`.github/workflows/commit-checker.yml`, so violations fail the build. ### Examples diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 101438f4d8..d8ea87ac5d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -112,18 +112,18 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=2.0.26 +ENV OPENCODE_VERSION=2.0.29 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-${OPENCODE_VERSION}.tgz"; \ - ESUM='1d7117dbb31968456f89537b562fb5e20e48d9e9ef0325fc8845abdda6ef4842'; \ + ESUM='0af39e75dbdfacc20f8f531ef35fb4dcc0b44c08084203518614272f232283ae'; \ ;; \ amd64|x86_64) \ BINARY_URL="https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-${OPENCODE_VERSION}.tgz"; \ - ESUM='0a116e033a02804741d4644337d012bdf5a24aac33a34c0c96534d2d53961119'; \ + ESUM='14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; exit 1; \ diff --git a/docs/img/libraries/libraries-import-tokens-button.webp b/docs/img/libraries/libraries-import-tokens-button.webp deleted file mode 100644 index 6cfe1b2156..0000000000 Binary files a/docs/img/libraries/libraries-import-tokens-button.webp and /dev/null differ diff --git a/docs/img/libraries/libraries-import-tokens-confirm.webp b/docs/img/libraries/libraries-import-tokens-confirm.webp deleted file mode 100644 index cc923ac079..0000000000 Binary files a/docs/img/libraries/libraries-import-tokens-confirm.webp and /dev/null differ diff --git a/docs/img/libraries/libraries-tokens-source.webp b/docs/img/libraries/libraries-tokens-source.webp new file mode 100644 index 0000000000..3a7b15f9cd Binary files /dev/null and b/docs/img/libraries/libraries-tokens-source.webp differ diff --git a/docs/img/shortcuts/shortcuts-settings.webp b/docs/img/shortcuts/shortcuts-settings.webp new file mode 100644 index 0000000000..e552a0f87a Binary files /dev/null and b/docs/img/shortcuts/shortcuts-settings.webp differ diff --git a/docs/img/styling/stroke-per-side.webp b/docs/img/styling/stroke-per-side.webp new file mode 100644 index 0000000000..676be32b83 Binary files /dev/null and b/docs/img/styling/stroke-per-side.webp differ diff --git a/docs/img/teams/team-selector-organizations.webp b/docs/img/teams/team-selector-organizations.webp new file mode 100644 index 0000000000..8996358acc Binary files /dev/null and b/docs/img/teams/team-selector-organizations.webp differ diff --git a/docs/img/teams/team-selector.webp b/docs/img/teams/team-selector.webp deleted file mode 100644 index e7af5e1523..0000000000 Binary files a/docs/img/teams/team-selector.webp and /dev/null differ diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 64c39376d3..ea8b721bb4 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -14,18 +14,24 @@ member is allowed to do depends on their permissions.

At Penpot you can create as many teams as you need and be invited to teams owned by others. Learn how to manage them.

Select team

-

At the top left of the dashboard you can find the team selector.

+

At the top left of the dashboard you can find the team selector. It shows the current team and, if the team belongs to an organization, the organization name.

+

If you belong to an organization, the menu has two columns:

+ +

Clicking an organization only shows its teams. You stay where you are until you pick a team. If you do not belong to any organization, the menu shows a single list of teams.

"Personal Projects" is the name of your personal space at Penpot. It is like any other team but in which no members can be invited so that you will always have your own private dashboard. Create or join other teams to collaborate with other Penpot users.

-
Teams selector
+
Team selector with the Organizations and Teams columns

Create teams

-

To create a new team go to the bottom of the team selector and press "+ Create new team". Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

+

To create a new team go to the bottom of the team selector and press "Create new team". If the menu shows organizations, the team is created in the organization you are looking at, or outside any organization if Other teams is selected. Then you will be asked to enter a team name and that's it. Once a new team is created you are able to invite new team members.

Teams selector

Delete and leave teams

All members can leave the team anytime from the same menu.

-

Only the team owner can delete a team. The option can be found at the team menu (the three dots at the right side). When deleting a team all projects and files belonging to it will be permanently deleted.

+

Only the team owner can delete a team. The option can be found at the team menu (the three dots next to the team selector), together with members, invitations, settings and leaving the team. When deleting a team all projects and files belonging to it will be permanently deleted.

Teams selector

Team settings

diff --git a/docs/user-guide/account-teams/your-account.njk b/docs/user-guide/account-teams/your-account.njk index db8c5161e4..3267ac5886 100644 --- a/docs/user-guide/account-teams/your-account.njk +++ b/docs/user-guide/account-teams/your-account.njk @@ -18,11 +18,18 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi

Password #

-

If you want to change your password to a new one, this can be done in the Password section.

+

If you want to change your password to a new one, this can be done in the Password section. Penpot sends you an email each time your password is changed, set or reset.

Penpot's password
+

Sessions and sign-in protection + # +

+

Your session ends after a period without activity, and also after a maximum time since you logged in, even if you keep working. When that happens, Penpot asks you to log in again.

+

After several failed login attempts in a short time, your account can be locked for some minutes. Penpot shows how long to wait. Resetting your password unlocks it.

+

On self-hosted installations, the administrator sets these limits. See Session expiration and Account lockout in the Technical Guide.

+

Notifications #

diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 53f58cdb99..1ceb140067 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -19,6 +19,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

Penpot Design Tokens adhere to the Design Tokens Format Module and its definitions, a draft by the W3C DTCG. Penpot ensures compatibility across various disciplines, tools, and technologies by following the most standardized approach available for design tokens.

Tokens can be exported from Penpot or integrated into other tools directly, without conversion. Additionally, the knowledge gained from using Design Tokens in Penpot remains valuable, regardless of whether you continue using Penpot or a different tool or technology.

+

Tokens live in a file. To share them between files, keep them in a shared library and set that library as the tokens source of the other files. See Use tokens from a shared library.

+

Creating a token

You can create reusable and semantic tokens to be referenced in your designs at the Tokens panel. In this panel, you’ll find all the available types of tokens in Penpot arranged alphabetically, with existing tokens being shown at the top of the list.

@@ -194,6 +196,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
  • Clicking on a color token will apply it to the element as a fill by default.
  • Right-clicking on a color token, allows you to select whether it should be applied as a fill or stroke color.
  • +

    You can also use a color token for the canvas background. With nothing selected, click the swatch in the Canvas background section of the Design tab, switch the color picker to Color tokens and pick a token. The background then follows the token, for example when you switch between a light and a dark theme. Use Detach token to keep the color as a plain value.

    Tokens color
    @@ -343,6 +346,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

    Stroke Width

    The stroke width token, also known as the border width token, defines the thickness of a stroke around a design element. It can be applied to boards, groups, rectangles and text elements.

    +

    On rectangles and boards you can apply it to a single side. Right-click the token and choose All, Top, Right, Bottom or Left. Dimension tokens offer the same options under Stroke Width. You can also pick a token in each side field of the stroke per side controls.

    +

    Tokens apply only to the first stroke of a layer. Applying a token to one side requires WebGL rendering.

    Number token

    @@ -677,7 +682,7 @@ ExtraBold Italic

    Importing and Exporting Tokens

    You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the Tools option at the bottom of the Tokens tab.

    -

    You can also import tokens from a connected shared library using the Libraries modal. That import uses the library's full token definition (sets and themes included) and replaces the tokens already in your file.

    +

    To use the tokens of a shared library without copying them, set the library as the file's tokens source. See Use tokens from a shared library. Import is not available while a library is the tokens source.

    The Import functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.

    These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.

    diff --git a/docs/user-guide/design-systems/libraries.njk b/docs/user-guide/design-systems/libraries.njk index 9c60b52918..d4b66e692e 100644 --- a/docs/user-guide/design-systems/libraries.njk +++ b/docs/user-guide/design-systems/libraries.njk @@ -71,22 +71,30 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana Open libraries
    -

    Import tokens from a connected library

    -

    If a shared library you have connected includes design tokens, you can copy them into the current file from the Libraries modal.

    -

    The Import tokens button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.

    +

    Use tokens from a shared library

    +

    A file can use the design tokens of a connected shared library instead of its own. This keeps one source of truth: tokens are defined once in the library, and every file that uses it gets the same tokens, sets and themes.

    +

    A file uses one tokens source at a time: its own tokens or the tokens of one connected library.

      -
    1. Open the Libraries panel from the assets tab.
    2. -
    3. Under Libraries in this file, find the connected library that includes tokens and click Import tokens (beside the disconnect control).
    4. -
    5. Review the confirmation dialog. Importing brings in the library's full token data, including sets and themes when the library defines them.
    6. -
    7. Click Import tokens to apply, or Cancel to go back without changes.
    8. +
    9. Connect the library to the file (see Connect shared libraries).
    10. +
    11. Open the Libraries panel from the assets tab. The This file tab lists the file library and the connected libraries, with how many tokens, sets and themes each one has.
    12. +
    13. Click Set as tokens source next to the library. The current source is labelled Tokens source.
    -

    Importing tokens from a library replaces all tokens, sets and themes in the current file. Export your tokens first (from the Tokens tab → Tools → Export) if you need to keep a backup.

    -

    This is separate from connecting the library for components, colors and typographies. You can use shared assets without importing tokens, and import tokens only when you want the file's token setup to match the library.

    - Import tokens button in the Libraries modal -
    The Import tokens button on a connected library that includes tokens.
    -
    -
    - Import tokens from library confirmation dialog -
    Confirm before importing. All current tokens, sets and themes in this file will be replaced.
    + This file tab of the Libraries modal with a connected library set as tokens source
    +

    The button only appears for libraries that have tokens of their own. A library that already uses tokens from another library cannot be a tokens source.

    +

    You can also change the source from the top of the Tokens tab, which shows Source: followed by This file or the library name. In the Assets tab, a tokens icon marks the library that is the current source.

    + +
    What you can do with library tokens
    +
      +
    • Apply them to layers, as with local tokens.
    • +
    • Activate themes and sets. The active themes and sets are saved in each file, so two files can use the same library with different themes. When you set a library as source, the file starts with the themes and sets active in the library.
    • +
    • Edit them in the library. From a file that uses library tokens you cannot create, edit, rename or delete tokens, sets or themes. Use the open-in-new-tab button next to the source name, or Edit themes in the theme menu, to open the library on its Tokens tab.
    • +
    + +
    How changes reach your file
    +

    Token changes in the library reach the files that use it right away. You do not need to accept them in Updates, which only lists components, colors and typographies. When sets or themes are added, removed or renamed in the library, Penpot shows a notice in the file.

    +

    If a token applied in your file is renamed or deleted in the library, the layer keeps its value but the token appears as missing. Apply the new token again.

    + +
    Switching back to the file's own tokens
    +

    Click Set as tokens source on the File library row. The file's own tokens were kept while the library was the source, so they come back. Tokens applied from the library appear as missing unless the file has tokens with the same names. Disconnecting a library that is the tokens source also switches the file back to its own tokens.

    diff --git a/docs/user-guide/designing/color-stroke.njk b/docs/user-guide/designing/color-stroke.njk index bd1cab3000..de69f59b8f 100644 --- a/docs/user-guide/designing/color-stroke.njk +++ b/docs/user-guide/designing/color-stroke.njk @@ -122,6 +122,21 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien Multiple strokes
    +

    Stroke per side

    +

    Rectangles and boards can have a different stroke width on each side. Use it for dividers, tab underlines or accent borders without adding extra layers.

    +
      +
    1. Select a rectangle or a board with a stroke.
    2. +
    3. Click the Stroke per side button at the end of the stroke row.
    4. +
    5. Set the width of each side: Top, Right, Bottom and Left. Use 0 to hide a side.
    6. +
    +
    + Stroke section with Stroke per side enabled and a different width on each side +
    +

    When the sides have different widths, the main width field shows Mixed. Typing a value in it sets all four sides to that value. Color, position and style are shared by all sides.

    +

    Each stroke of a layer has its own per-side widths. You can also edit them on a multiple selection of rectangles and boards.

    +

    You can apply stroke width tokens to one side or to all of them. In the Inspect tab, per-side widths appear as CSS logical properties (border-block-start-width, border-inline-end-width, border-block-end-width and border-inline-start-width).

    +

    Stroke per side requires WebGL rendering. With the classic renderer the button is disabled and only the top width is drawn.

    +

    Stroke to path

    A path is the underlying geometry: a sequence of points and segments (straight lines or Bezier curves). A stroke is the visible line drawn along that path, with properties such as width, color, opacity, dashes, caps, and joins.

    Stroke to path turns the visible outline of a stroke into a separate, editable path. You can then reshape or style that outline independently of the original path. The new shape is usually closed. That matters for SVG, boolean operations, and editing the outline as geometry.

    diff --git a/docs/user-guide/designing/layers.njk b/docs/user-guide/designing/layers.njk index b46bc11f90..9aca80ced0 100644 --- a/docs/user-guide/designing/layers.njk +++ b/docs/user-guide/designing/layers.njk @@ -22,7 +22,7 @@ are shown by default at the Curves (freehand)

    The curve tool allows a path to be created directly in a freehand mode. Select the curve tool from the free-draw flyout in the toolbar or press Shift/⇧ + C.

    -

    The path created will contain a lot of points, but it is edited the same way as any other curve.

    +

    When you release the mouse, Penpot turns your stroke into a few smooth curves. Sharp turns stay as corners, so a zigzag keeps its points. The result is a regular path that you can edit like any other.

    Paths (bezier)

    A path is composed of two or more nodes and the line segments between them, which may also be curved. To draw a new path, select the path tool from the free-draw flyout in the toolbar or press P. Then you have two ways to create the path:

    @@ -178,16 +178,37 @@ Select the curve tool from the free-draw flyout in the toolbar or press Shi
  • Close it clicking over the starting node.
  • Leave it open pressing Esc or Enter to stop editing. Then press Esc to exit the edit mode.
  • -

    Tip: If you hold Shift/⇧ while adding nodes the angle between the current and the next will change in 45 degree increments.

    +

    Tip: If you hold Shift/⇧ while adding nodes, the angle between the current node and the next one changes in 15 degree increments.

    -

    Edit nodes

    +

    Edit nodes

    To edit a node double click on a path or select and press Enter. You can choose to edit individual nodes or create new ones. Press Esc to exit node edition.

    +

    The path toolbar has two modes:

    +
      +
    • Move nodes (M): select and move nodes, segments and handles. Editing an existing path starts in this mode.
    • +
    • Draw nodes (P): add new nodes and segments to the path.
    • +
    +

    Snap nodes (Ctrl/⌘ + ') is on by default, so new and moved nodes snap to the existing ones.

    + +

    Continue or close a path

    +

    To keep drawing from an existing node, select it in Move mode and switch to Draw mode, or click it in Draw mode. If the node is the end of an open path, the new segments extend it. Click the start node to close the shape. Hold Shift/⇧ to draw in 15 degree steps.

    + +

    Reshape with the mouse

    +

    These gestures work on nodes and segments while you edit a path:

    +
      +
    • Ctrl/⌘ + drag a segment: bends it while both end nodes stay in place. Ctrl/⌘ + click turns a segment from straight to curved and back.
    • +
    • Ctrl/⌘ + drag a node: pulls out a pair of handles. Ctrl/⌘ + click turns a node from corner to curve and back.
    • +
    • Alt/⌥ + click a segment (Move mode): adds a node at that point. You can also click near the middle of a segment to add a node there.
    • +
    • Alt/⌥ + click a node: removes the node and keeps the path smooth around it.
    • +
    • Ctrl/⌘ + Alt/⌥ + click a node: removes the node and its segments, which leaves a gap.
    • +
    +

    Delete or Backspace removes the selected nodes and keeps the path joined. With selected handles, it removes them and the curve at that node becomes straight. Shift/⇧ + Delete removes the selected nodes together with their segments.

    +

    See all the path editor shortcuts.

    +

    Change the image frame

    +

    Hold Ctrl/⌘ while you drag a resize handle to change the layer bounds without scaling the image. Shrink the layer to crop the image, or make it bigger to add empty space around it. The image is not distorted.

    +

    This works with image layers and with any layer that has an image fill. A normal resize afterwards scales the frame and the image together.

    +

    Layer actions

    Create

    @@ -477,7 +502,7 @@ You can choose to edit individual nodes or create new ones. Press Esc
  • Layer blur blurs the layer content itself.
  • Background blur blurs whatever sits behind the layer. Use it for frosted-glass or depth effects. Background blur requires the WebGL renderer to be enabled.
  • -

    Background blur is not included in exported files yet (PNG, SVG, PDF, and others). The exporter still uses the legacy SVG renderer, so the effect only shows in the workspace and View mode. Export support is a current priority and will land in an upcoming release.

    +

    Background blur is included in exports made with WebGL rendering enabled. In PDF files it is embedded as an image.

    Colorful shapes behind a frosted panel with background blur applied
    Background blur affects the layers behind the shape, not the shape itself.
    diff --git a/docs/user-guide/designing/text-typo.njk b/docs/user-guide/designing/text-typo.njk index 3317821ff2..c01b634653 100644 --- a/docs/user-guide/designing/text-typo.njk +++ b/docs/user-guide/designing/text-typo.njk @@ -24,6 +24,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe

    Edit and style text content

    Press Enter with a text layer selected to start editing the text content. You can style parts of the text content as rich text.

    +

    With WebGL rendering enabled, right-click while editing to open a menu with Cut, Copy, Paste and Select all. Your text selection is kept.

    editing text
    diff --git a/docs/user-guide/designing/workspace-basics.njk b/docs/user-guide/designing/workspace-basics.njk index c0547d4cbc..bcd23eb546 100644 --- a/docs/user-guide/designing/workspace-basics.njk +++ b/docs/user-guide/designing/workspace-basics.njk @@ -185,6 +185,9 @@ press Shift/⇧ + left click over the right arrow of a group or a boa Marking comments as read
    +

    Filter comments

    +

    The comments menu lets you show all comments, only your comments or only your mentions. Hide resolved comments is a separate option. Penpot remembers it in your browser for all files, in the workspace and in View mode.

    +

    Edit and remove comments

    At the top right of the comment popup you can find options to edit or delete comments.

    diff --git a/docs/user-guide/export-import/exporting-layers.njk b/docs/user-guide/export-import/exporting-layers.njk index c8ad0988e9..6f1f5869c5 100644 --- a/docs/user-guide/export-import/exporting-layers.njk +++ b/docs/user-guide/export-import/exporting-layers.njk @@ -27,7 +27,7 @@ desc: Learn how to export layers in Penpot, the free, open-source design collabo

    Export options

    The options of an export:

      -
    • Size - Options for the most common sizing scales.
    • +
    • Size - The scale of the exported image: 0.5x, 0.75x, 1x, 1.5x, 2x, 3x, 4x or 6x. Use 1x, 2x and 3x to get the usual iOS asset sizes. Available for PNG, JPEG and WEBP.
    • Suffix - Especially useful if you are exporting at different scales.
    • File format - PNG, JPEG, WEBP, SVG, PDF.
    diff --git a/docs/user-guide/first-steps/cloud-selfhost.njk b/docs/user-guide/first-steps/cloud-selfhost.njk index bf3c128132..b5a22ee1a1 100644 --- a/docs/user-guide/first-steps/cloud-selfhost.njk +++ b/docs/user-guide/first-steps/cloud-selfhost.njk @@ -15,3 +15,7 @@ desc: Start instantly with Penpot, the open-source design and prototyping platfo

    On your own server

    Currently, private Penpot instances only require basic Docker knowledge.

    You can run your own Penpot server following the instructions at the Technical Guide

    + +

    Check for updates

    +

    To see if your installation is up to date, open your profile menu in the dashboard and go to About Penpot → Check for updates. If a newer version exists, Penpot shows your installed version, the latest one, and the highlights of each release you are missing, with links to the full changelog and the release notes.

    +

    The check runs from your browser and needs access to GitHub. It is hidden on installations configured as air-gapped.

    diff --git a/docs/user-guide/first-steps/shortcuts.njk b/docs/user-guide/first-steps/shortcuts.njk index b06057a9b9..e4708c9800 100644 --- a/docs/user-guide/first-steps/shortcuts.njk +++ b/docs/user-guide/first-steps/shortcuts.njk @@ -10,7 +10,43 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Here you can find a list of all keyboard shortcuts that you can use in Penpot. Bear in mind that most of them are at the workspace, where they are more needed.

    -

    You can also check the most updated list of shortcuts at the GitHub file.

    +

    The tables below list the default shortcuts. You can change most of them to fit your keyboard layout or your habits. See Customize shortcuts.

    + +

    Customize shortcuts

    +

    Go to Your account → Shortcuts to see every shortcut and change the ones you want. From the workspace, open the shortcuts panel (Help & info → Shortcuts in the main menu, or ?) and click Customize in Settings.

    +

    Your shortcuts are saved in your account, so they follow you to any computer and browser. After a change, reload any other open Penpot tabs to apply it.

    + +

    Find a shortcut

    +

    Shortcuts are grouped into Workspace, Dashboard and Viewer. Use the tabs to see All of them, only your Custom ones, or the ones you Disabled.

    +

    The search field matches both the action name and the keys. Type "group" to find the grouping actions, or "shift" to see every shortcut that uses Shift.

    + +

    Change a shortcut

    +
      +
    1. Click the shortcut you want to change.
    2. +
    3. Press the new keys. The last combination you press is the one recorded.
    4. +
    5. Click the check mark to save, or the cross to cancel. Clicking outside the editor also cancels. Esc does not cancel: it is recorded as a key.
    6. +
    +
    + Shortcuts settings with a shortcut being edited and a conflict warning +
    +

    If the keys are already used by another action in the same group, Penpot shows which one. When you save, the shortcut moves to the action you are editing, and the other action appears under Disabled.

    +

    Each action takes one combination. If an action had two defaults (for example, two ways to redo), saving a custom one replaces both.

    +

    From the same editor you can also disable a shortcut so that no keys trigger it, or reset it to its default value. Custom shortcuts are marked with a dot, and the main menu highlights the custom keys.

    +

    Paste cannot be customized.

    + +

    Keyboard layouts

    +

    Punctuation keys (such as -, [, / or ') are recorded by their physical position on the keyboard, so they work the same on AZERTY, QWERTZ and other layouts. Letters and numbers are recorded by the character they type. With Shift, the base key is recorded, not the shifted character.

    + +

    Reset to default

    +

    When you have custom shortcuts, a Reset all button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.

    + +

    Export and import your shortcuts

    +

    Use the Import/Export button at the bottom of the page to move your setup to another account or share it with your team.

    +
      +
    • Export downloads a JSON file with your custom and disabled shortcuts. It is available once you have changed at least one shortcut.
    • +
    • Import reads a JSON file exported from Penpot and shows the shortcuts it will change. Click Import to confirm.
    • +
    +

    Importing replaces your custom shortcuts in each group included in the file (Workspace, Dashboard or Viewer). Custom shortcuts in those groups that are not in the file go back to their defaults. If an imported shortcut uses keys taken by a default one, the default one is disabled.

    Workspace

    The Workspace is where the designs are actually created. More about the Workspace.

    @@ -569,6 +605,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface

    Path editor

    +

    Copy, cut, paste, duplicate and select all also work on nodes while you edit a path. For mouse gestures, see Reshape with the mouse.

    @@ -584,12 +621,17 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - - - + + + - + + + + + + @@ -614,7 +656,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface - + @@ -628,6 +670,16 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface + + + + + + + + + +
    ⇧+
    Delete nodeSupr or Delete⌫Delete node, segment or handleDelete or Backspace⌦ or ⌫
    Draw PathDelete node and segmentsShiftDelete⇧⌫
    Draw nodes P P
    ⌘J
    Move NodesMove nodes M M
    Ctrl' ⌘'
    Flip horizontally (selected nodes, or the whole path)ShiftH⇧H
    Flip vertically (selected nodes, or the whole path)ShiftV⇧V
    diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index c3893f90e8..829e0e99ba 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -10,7 +10,8 @@ desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Be

    WebGL rendering (Beta)

    Since Penpot 2.16, optional WebGL rendering (Beta) is available on design.penpot.app and self-hosted instances. It is off by default.

    Turn it on from Your account → Settings or Workspace → Main menu → Preferences when you want a faster canvas for complex files.

    -

    In this beta, WebGL applies to the design workspace canvas only. View mode and exports still use the legacy renderer, so some visual differences are expected. If the beta gets in your way, you can switch back to the legacy renderer at any time.

    +

    With WebGL rendering enabled, the workspace canvas, View mode and exports (PNG, JPEG, WEBP, SVG and PDF) use the same engine, so exported files match what you see on the canvas. Text is edited with the new text editor, which also adds a right-click menu with Cut, Copy, Paste and Select all.

    +

    Some features need WebGL rendering, such as stroke per side, background blur and stroke to path.

    For what is in scope today, what we are prioritizing next, and known visual differences, read What's next for the Penpot WebGL renderer on the Penpot Community.

    diff --git a/frontend/deps.edn b/frontend/deps.edn index b1293a652c..c5184e43ae 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -15,8 +15,8 @@ :git/url "https://github.com/funcool/tubax.git"} funcool/potok2 - {:git/tag "v2.2" - :git/sha "0f7e15a" + {:git/tag "v2.3" + :git/sha "bdebbdb" :git/url "https://github.com/funcool/potok.git" :exclusions [funcool/beicon2]} @@ -26,8 +26,8 @@ :git/url "https://github.com/funcool/beicon.git"} funcool/rumext - {:git/tag "v2.25" - :git/sha "27e5a1a" + {:git/tag "v2.26" + :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} instaparse/instaparse {:mvn/version "1.5.0"} diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 06ffc849da..635572996f 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -17,7 +17,7 @@ [app.main.data.profile :as dp] [app.main.data.websocket :as ws] [app.main.data.workspace.mcp :as mcp] - [app.main.errors] + [app.main.errors :as errors] [app.main.features :as feat] [app.main.rasterizer :as thr] [app.main.store :as st] @@ -136,6 +136,8 @@ (i18n/init) (cur/init-styles) + (mf/set-update-scheduler {:on-trip errors/on-update-loop}) + (init-ui) (st/emit! (plugins/initialize) (initialize))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 928722f889..d2c4976024 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -100,6 +100,40 @@ (assoc ::instance cause) (assoc ::trace (.-stack cause))))) +;; Events in flight when each exception was caught, as report lines +(defonce ^:private in-flight-by-cause (js/WeakMap.)) + +;; Events kept from each end of a long in-flight list +(def ^:private in-flight-edge-size 10) + +(defn- in-flight-lines + "Report lines for the in-flight `events`, outermost first; a long list + keeps only both ends." + [events] + (let [total (count events) + size in-flight-edge-size] + (if (<= total (* 2 size)) + (mapv ptk/repr-event events) + (-> [] + (into (map ptk/repr-event) (take size events)) + (conj (str "... " (- total (* 2 size)) " more ...")) + (into (map ptk/repr-event) (take-last size events)))))) + +(defn- record-in-flight! + "Keeps the events the store is processing for the report of `cause`. + Runs synchronously on the error path: when an event's `update`, `effect` + or synchronous `watch` throws, the last line is that event. When a + `watch` pipeline fails on a value from the store stream, the store has + already finished that value, so the lines show the events that emitted + it, not the event that owns the pipeline." + [cause] + (try + (when (ex/exception? cause) + (when-let [events (not-empty (ptk/in-flight-events st/state))] + (.set in-flight-by-cause cause (in-flight-lines events)))) + (catch :default _ + nil))) + (defn on-error "A general purpose error handler. @@ -114,6 +148,7 @@ (ex/print-throwable error)) (do (vreset! handling-error? true) + (record-in-flight! (if (map? error) (::instance error) error)) (try (if (map? error) (ptk/handle-error error) @@ -166,8 +201,9 @@ (println "HREF: " (rt/get-current-href))))) (defn- generate-full-report - "Complete report: context, formatted throwable (including `ex-data`) and the - last events." + "Complete report: context, formatted throwable (including `ex-data`), the + events in flight when the error was caught (when recorded) and the last + events." [cause] (with-out-str (print (report-context cause)) @@ -177,6 +213,12 @@ (ex/format-throwable cause)) (println) + (when-let [lines (.get in-flight-by-cause cause)] + (println "Events in flight when the error was caught (outermost first):") + (println "--------------------") + (run! println lines) + (println)) + (println "Last events:") (println "--------------------") (println (st/format-last-events)) @@ -202,9 +244,9 @@ "Build the report string for `cause`. `:format` selects the payload: `:full` (default) includes the formatted - throwable and the last events; `:compact` keeps only the context and the - error type/code/uri, for environment failures. The option is accepted both - as keyword arguments and as a trailing map." + throwable, the in-flight and the last events; `:compact` keeps only the + context and the error type/code/uri, for environment failures. The option + is accepted both as keyword arguments and as a trailing map." [cause & {:keys [format] :or {format :full}}] (try (case format @@ -392,6 +434,24 @@ hint occurrences)))) +(defonce ^:private update-loop-warned-at + (volatile! 0)) + +(defn on-update-loop + "Handler for the rumext update scheduler trips: logs the throttled + update loop with the events being processed, at most once per + `report-window-ms`. Runs inside store watchers and effect bodies, so it + never throws." + [_] + (try + (let [now (inst-ms (ct/now))] + (when (>= (- now @update-loop-warned-at) report-window-ms) + (vreset! update-loop-warned-at now) + (.warn js/console "[update-scheduler] update loop throttled; in-flight events:" + (into-array (map ptk/repr-event (ptk/in-flight-events st/state)))))) + (catch :default _ + nil))) + (defn- download-report! [report event] (dom/prevent-default event) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 6b91ba1984..ae8397383e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -402,7 +402,7 @@ (defn inside-vbox [vbox objects frame-id] (let [frame (get objects frame-id)] - (and (some? frame) (gsh/overlaps? frame vbox)))) + (and (some? frame) (some? vbox) (gsh/overlaps? frame vbox)))) (defn setup-active-frames [objects hover-ids selected active-frames zoom transform vbox] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index a964620c15..c622647f84 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,6 +11,10 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations + - events in flight – the full report lists the events the + store was processing when it caught the exception; a failing stream + pipeline lists the emitters of the value, not its own event + - on-update-loop – never throws into the scheduler's caller - flash schedules async report and toast – neither the report nor ntf/show is emitted synchronously - organization SSO recovery – expired SSO sessions go back to the provider @@ -368,6 +372,144 @@ ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) +;; --------------------------------------------------------------------------- +;; Events in flight +;; +;; on-error records the events the store is processing when it catches an +;; exception; the full report lists them, outermost first. +;; --------------------------------------------------------------------------- + +(def ^:private in-flight-cause (atom nil)) + +(defmethod ptk/handle-error ::test-in-flight + [err] + (reset! in-flight-cause (::errors/instance err)) + nil) + +(def ^:private in-flight-heading + "Events in flight when the error was caught (outermost first):") + +(defn- in-flight-failure + [] + (ex-info "in-flight failure" {:type ::test-in-flight :hint "in-flight failure"})) + +(defn- recursive-event + [n] + (ptk/reify ::test-in-flight-recursive + ptk/WatchEvent + (watch [_ _ _] + (if (pos? n) + (rx/of (recursive-event (dec n))) + (throw (in-flight-failure)))))) + +(t/deftest ^:async report-lists-the-events-in-flight-of-a-failing-event + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-outer + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/reify ::test-in-flight-inner + ptk/UpdateEvent + (update [_ _] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause) + outer (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-outer)") + inner (.indexOf report "typ:(:frontend-tests.main-errors-test/test-in-flight-inner)")] + (t/is (str/includes? report in-flight-heading)) + (t/is (< -1 outer inner) "outer event listed before the failing one")))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async report-keeps-both-ends-of-a-long-in-flight-list + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; 25 nested events: 10 from each end are kept + (st/emit! (recursive-event 24)) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (str/includes? report "... 5 more ...")) + (t/is (= 20 (count (re-seq #"test-in-flight-recursive" report))))))) + (finally + (rx/dispose! store))))) + +(defn- in-flight-section + "The in-flight section of `report`, or nil when absent." + [report] + (let [start (.indexOf report in-flight-heading)] + (when (<= 0 start) + (subs report start (.indexOf report "Last events:" start))))) + +(t/deftest ^:async stream-pipeline-failure-lists-the-emitters-of-the-value + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + ;; The listener's pipeline fails on the trigger that the emitter emits + (st/emit! + (ptk/reify ::test-in-flight-listener + ptk/WatchEvent + (watch [_ _ stream] + (->> stream + (rx/filter (ptk/type? ::test-in-flight-trigger)) + (rx/take 1) + (rx/map (fn [_] (throw (in-flight-failure))))))) + (ptk/reify ::test-in-flight-emitter + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::test-in-flight-trigger {}))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [section (in-flight-section (errors/generate-report @in-flight-cause))] + (t/is (str/includes? section "test-in-flight-emitter")) + (t/is (not (str/includes? section "test-in-flight-trigger"))) + (t/is (not (str/includes? section "test-in-flight-listener")))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async delayed-watch-failure-has-no-in-flight-section + (let [store (ptk/store {:state {} :on-error errors/on-error})] + (try + (await + (mock/with-mocks* + {st/state store + st/format-last-events (mock/stub (fn [& _] "(stub last events)")) + rt/get-current-href (constantly "https://penpot.example.com/#/workspace")} + (reset! in-flight-cause nil) + (st/emit! + (ptk/reify ::test-in-flight-delayed + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/timer 0) + (rx/map (fn [_] (throw (in-flight-failure)))))))) + (await (async/wait-for #(some? @in-flight-cause) "the failure reaches the handler")) + (let [report (errors/generate-report @in-flight-cause)] + (t/is (not (str/includes? report in-flight-heading))) + (t/is (str/includes? report "Last events:"))))) + (finally + (rx/dispose! store))))) + +(t/deftest ^:async update-loop-handler-never-throws + (await + (mock/with-mocks* + {ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))} + (t/is (nil? (errors/on-update-loop {:rounds 26})))))) + ;; --------------------------------------------------------------------------- ;; Expired organization SSO session ;; diff --git a/scripts/check-commit b/scripts/check-commit index 3f2792552e..d0e9f59899 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -47,10 +47,12 @@ MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") MAX_BODY_LINE = 76 TRAILER_PATTERN = re.compile( - r"^(Signed-off-by|Co-authored-by|Co-developed-by|Reviewed-by|" + r"^(Signed-off-by|Co-developed-by|Reviewed-by|" r"Acked-by|Tested-by|Reported-by|Suggested-by|AI-assisted-by):" ) +FORBIDDEN_TRAILER_PATTERN = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) + URL_PATTERN = re.compile(r"https?://\S+") # ═══════════════════════════════════════════════════════════════════════════════ @@ -209,6 +211,25 @@ def check_signed_off_by(message): return True, None +def check_no_co_authored_by(message): + """Check that the forbidden Co-authored-by trailer is absent.""" + offenders = [ + (line_number, line) + for line_number, line in enumerate(message.split("\n"), start=1) + if FORBIDDEN_TRAILER_PATTERN.match(line) + ] + if not offenders: + return True, None + details = "\n".join( + f" line {line_number}: {line!r}" + for line_number, line in offenders + ) + return False, ( + "The 'Co-authored-by:' trailer is forbidden and must not appear " + "in the commit message. Offending line(s):\n" + details + ) + + # ═══════════════════════════════════════════════════════════════════════════════ def main(): @@ -234,6 +255,7 @@ def main(): ("Subject capitalized", check_subject_capitalized), ("Blank line after subject", check_body_blank_line), ("Body wrapped at 76 chars", check_body_line_length), + ("No Co-authored-by trailer", check_no_co_authored_by), ] all_ok = True diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index 4e4940429e..a3a7a1c2b9 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -106,6 +106,35 @@ class BodyLineLengthTests(unittest.TestCase): self.assertIn("line 4", error) +class ForbiddenTrailerTests(unittest.TestCase): + def assert_ok(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertTrue(ok, error) + self.assertIsNone(error) + + def assert_fail(self, message): + ok, error = check_commit.check_no_co_authored_by(message) + self.assertFalse(ok) + self.assertIsNotNone(error) + return error + + def test_body_without_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nSigned-off-by: Someone \n") + + def test_co_authored_by_fails(self): + error = self.assert_fail(":bug: Fix crash\n\nBody.\n\nCo-authored-by: Someone \n") + self.assertIn("Co-authored-by", error) + + def test_co_authored_by_case_insensitive_fails(self): + self.assert_fail(":bug: Fix crash\n\nBody.\n\nco-authored-by: Someone \n") + + def test_co_authored_by_in_body_fails(self): + self.assert_fail(":bug: Fix crash\n\nCo-authored-by: Someone \nBody.\n") + + def test_similar_trailer_passes(self): + self.assert_ok(":bug: Fix crash\n\nBody.\n\nCo-developed-by: Someone \n") + + class SubjectRulesRegressionTests(unittest.TestCase): """Guard the pre-existing validators against accidental breakage."""