Merge remote-tracking branch 'origin/staging' into develop
@ -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).
|
||||
|
||||
41
.github/workflows/commit-checker.yml
vendored
@ -47,4 +47,45 @@ jobs:
|
||||
# pattern: '^.+(Resolves|Fixes): \#[0-9]+$'
|
||||
# error: 'You need at least one "Resolves|Fixes: #<issue number>" 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"
|
||||
|
||||
|
||||
|
||||
@ -37,8 +37,10 @@ that breaks them is wrong, even if the code is right.
|
||||
- For another commit: `./scripts/check-commit -c <ref>`
|
||||
- **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`
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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; \
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB |
BIN
docs/img/libraries/libraries-tokens-source.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/img/shortcuts/shortcuts-settings.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
docs/img/styling/stroke-per-side.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/img/teams/team-selector-organizations.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
@ -14,18 +14,24 @@ member is allowed to do depends on their permissions.</p>
|
||||
<p>At Penpot you can create as many teams as you need and be invited to teams owned by others. Learn how to manage them.</p>
|
||||
|
||||
<h3>Select team</h3>
|
||||
<p>At the top left of the dashboard you can find the team selector.</p>
|
||||
<p>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 <a href="/user-guide/account-teams/organizations/">organization</a>, the organization name.</p>
|
||||
<p>If you belong to an organization, the menu has two columns:</p>
|
||||
<ul>
|
||||
<li><strong>Organizations</strong>: the organizations you belong to, plus <strong>Other teams</strong> for teams outside any organization. A check mark shows where you are.</li>
|
||||
<li><strong>Teams</strong>: the teams of the organization you point at, with <strong>Personal Projects</strong> at the end. Click a team to go to it. <kbd>Ctrl/⌘</kbd> + click opens it in a new tab.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
|
||||
<p>"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.</p>
|
||||
<figure><img src="/img/teams/team-selector.webp" alt="Teams selector" /></figure>
|
||||
<figure><img src="/img/teams/team-selector-organizations.webp" alt="Team selector with the Organizations and Teams columns" /></figure>
|
||||
|
||||
<h3>Create teams</h3>
|
||||
<p>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.</p>
|
||||
<p>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 <strong>Other teams</strong> 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.</p>
|
||||
<figure><img src="/img/teams/team-selector-projects.webp" alt="Teams selector" /></figure>
|
||||
|
||||
<h3>Delete and leave teams</h3>
|
||||
<p>All members can leave the team anytime from the same menu.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<figure><img src="/img/teams/team-selector-delete.webp" alt="Teams selector" /></figure>
|
||||
|
||||
<h3>Team settings</h3>
|
||||
|
||||
@ -18,11 +18,18 @@ desc: Learn how to import and export files in Penpot, the free, open-source desi
|
||||
<h3 id="your-account-password">Password
|
||||
<a class="direct-link" href="#your-account-password">#</a>
|
||||
</h3>
|
||||
<p>If you want to change your password to a new one, this can be done in the <b>Password</b> section.</p>
|
||||
<p>If you want to change your password to a new one, this can be done in the <b>Password</b> section. Penpot sends you an email each time your password is changed, set or reset.</p>
|
||||
<figure>
|
||||
<img src="/img/interface/youraccount-password.webp" alt="Penpot's password" />
|
||||
</figure>
|
||||
|
||||
<h3 id="your-account-sessions">Sessions and sign-in protection
|
||||
<a class="direct-link" href="#your-account-sessions">#</a>
|
||||
</h3>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>On self-hosted installations, the administrator sets these limits. See <a href="/technical-guide/configuration/#session-expiration">Session expiration</a> and <a href="/technical-guide/configuration/#account-lockout">Account lockout</a> in the Technical Guide.</p>
|
||||
|
||||
<h3 id="your-account-notifications">Notifications
|
||||
<a class="direct-link" href="#your-account-notifications">#</a>
|
||||
</h3>
|
||||
|
||||
@ -19,6 +19,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
|
||||
<p>Penpot Design Tokens adhere to the <a href="https://design-tokens.github.io/community-group/format/" target="_blank">Design Tokens Format Module</a> and <a href="https://www.designtokens.org/glossary/" target="_blank">its definitions</a>, a draft by the <a href="https://www.w3.org/community/design-tokens/" target="_blank">W3C DTCG</a>. Penpot ensures compatibility across various disciplines, tools, and technologies by following the most standardized approach available for design tokens.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<p>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 <a href="/user-guide/design-systems/libraries/#tokens-from-library">Use tokens from a shared library</a>.</p>
|
||||
|
||||
<h2 id="design-tokens-use-create">Creating a token</h2>
|
||||
<p>You can create reusable and semantic tokens to be referenced in your designs at the <strong>Tokens</strong> 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.</p>
|
||||
<figure>
|
||||
@ -194,6 +196,7 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
|
||||
<li>Clicking on a color token will apply it to the element as a fill by default.</li>
|
||||
<li>Right-clicking on a color token, allows you to select whether it should be applied as a fill or stroke color.</li>
|
||||
</ul>
|
||||
<p>You can also use a color token for the <strong>canvas background</strong>. With nothing selected, click the swatch in the <strong>Canvas background</strong> section of the Design tab, switch the color picker to <strong>Color tokens</strong> and pick a token. The background then follows the token, for example when you switch between a light and a dark theme. Use <strong>Detach token</strong> to keep the color as a plain value.</p>
|
||||
<figure>
|
||||
<img src="/img/design-tokens/08-tokens-color.webp" alt="Tokens color" />
|
||||
</figure>
|
||||
@ -343,6 +346,8 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
|
||||
|
||||
<h3 id="design-tokens-stroke-width">Stroke Width</h3>
|
||||
<p>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.</p>
|
||||
<p>On rectangles and boards you can apply it to a single side. Right-click the token and choose <strong>All</strong>, <strong>Top</strong>, <strong>Right</strong>, <strong>Bottom</strong> or <strong>Left</strong>. Dimension tokens offer the same options under <strong>Stroke Width</strong>. You can also pick a token in each side field of the <a href="/user-guide/designing/color-stroke/#stroke-per-side">stroke per side</a> controls.</p>
|
||||
<p class="advice">Tokens apply only to the first stroke of a layer. Applying a token to one side requires <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a>.</p>
|
||||
|
||||
|
||||
<h3 id="design-tokens-number">Number token</h3>
|
||||
@ -677,7 +682,7 @@ ExtraBold Italic
|
||||
|
||||
<h2 id="design-tokens-import-export">Importing and Exporting Tokens</h2>
|
||||
<p>You can export Tokens from Penpot and import them from your computer to a Penpot file. Tokens can be imported from the <strong>Tools</strong> option at the bottom of the <strong>Tokens</strong> tab.</p>
|
||||
<p>You can also <a href="/user-guide/design-systems/libraries/#import-tokens-from-library">import tokens from a connected shared library</a> 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.</p>
|
||||
<p>To use the tokens of a shared library without copying them, set the library as the file's tokens source. See <a href="/user-guide/design-systems/libraries/#tokens-from-library">Use tokens from a shared library</a>. Import is not available while a library is the tokens source.</p>
|
||||
<p>The <strong>Import</strong> functionality allows you to upload and replace the global token set using a single file or a folder with multiple files in it.</p>
|
||||
<p>These features support JSON files formatted according to specific guidelines and preserve the ability to undo changes if needed.</p>
|
||||
<figure>
|
||||
|
||||
@ -71,22 +71,30 @@ desc: Use Penpot's libraries for reusable design elements! Learn to create, mana
|
||||
<img src="/img/libraries/libraries-open.webp" alt="Open libraries">
|
||||
</figure>
|
||||
|
||||
<h4 id="import-tokens-from-library">Import tokens from a connected library</h4>
|
||||
<p>If a shared library you have connected includes design tokens, you can copy them into the current file from the <strong>Libraries</strong> modal.</p>
|
||||
<p>The <strong>Import tokens</strong> button appears only next to connected libraries that define tokens. Libraries without tokens do not show this control.</p>
|
||||
<h4 id="tokens-from-library">Use tokens from a shared library</h4>
|
||||
<p>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.</p>
|
||||
<p>A file uses one tokens source at a time: its own tokens or the tokens of one connected library.</p>
|
||||
<ol>
|
||||
<li>Open the <strong>Libraries</strong> panel from the assets tab.</li>
|
||||
<li>Under <strong>Libraries in this file</strong>, find the connected library that includes tokens and click <strong>Import tokens</strong> (beside the disconnect control).</li>
|
||||
<li>Review the confirmation dialog. Importing brings in the library's full token data, including sets and themes when the library defines them.</li>
|
||||
<li>Click <strong>Import tokens</strong> to apply, or <strong>Cancel</strong> to go back without changes.</li>
|
||||
<li>Connect the library to the file (see <a href="#shared-libraries">Connect shared libraries</a>).</li>
|
||||
<li>Open the <strong>Libraries</strong> panel from the assets tab. The <strong>This file</strong> tab lists the file library and the connected libraries, with how many tokens, sets and themes each one has.</li>
|
||||
<li>Click <strong>Set as tokens source</strong> next to the library. The current source is labelled <strong>Tokens source</strong>.</li>
|
||||
</ol>
|
||||
<p class="advice">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.</p>
|
||||
<p>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.</p>
|
||||
<figure>
|
||||
<img src="/img/libraries/libraries-import-tokens-button.webp" alt="Import tokens button in the Libraries modal">
|
||||
<figcaption>The Import tokens button on a connected library that includes tokens.</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="/img/libraries/libraries-import-tokens-confirm.webp" alt="Import tokens from library confirmation dialog">
|
||||
<figcaption>Confirm before importing. All current tokens, sets and themes in this file will be replaced.</figcaption>
|
||||
<img src="/img/libraries/libraries-tokens-source.webp" alt="This file tab of the Libraries modal with a connected library set as tokens source">
|
||||
</figure>
|
||||
<p>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.</p>
|
||||
<p>You can also change the source from the top of the <strong>Tokens</strong> tab, which shows <strong>Source:</strong> followed by <strong>This file</strong> or the library name. In the <strong>Assets</strong> tab, a tokens icon marks the library that is the current source.</p>
|
||||
|
||||
<h5>What you can do with library tokens</h5>
|
||||
<ul>
|
||||
<li><strong>Apply</strong> them to layers, as with local tokens.</li>
|
||||
<li><strong>Activate themes and sets.</strong> 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.</li>
|
||||
<li><strong>Edit them in the library.</strong> 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 <strong>Edit themes</strong> in the theme menu, to open the library on its Tokens tab.</li>
|
||||
</ul>
|
||||
|
||||
<h5>How changes reach your file</h5>
|
||||
<p>Token changes in the library reach the files that use it right away. You do not need to accept them in <strong>Updates</strong>, 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.</p>
|
||||
<p class="advice">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.</p>
|
||||
|
||||
<h5>Switching back to the file's own tokens</h5>
|
||||
<p>Click <strong>Set as tokens source</strong> on the <strong>File library</strong> 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.</p>
|
||||
|
||||
@ -122,6 +122,21 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
|
||||
<img alt="Multiple strokes" src="/img/styling/stroke-multiple.webp"/>
|
||||
</figure>
|
||||
|
||||
<h3 id="stroke-per-side">Stroke per side</h3>
|
||||
<p>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.</p>
|
||||
<ol>
|
||||
<li>Select a rectangle or a board with a stroke.</li>
|
||||
<li>Click the <strong>Stroke per side</strong> button at the end of the stroke row.</li>
|
||||
<li>Set the width of each side: <strong>Top</strong>, <strong>Right</strong>, <strong>Bottom</strong> and <strong>Left</strong>. Use 0 to hide a side.</li>
|
||||
</ol>
|
||||
<figure>
|
||||
<img alt="Stroke section with Stroke per side enabled and a different width on each side" src="/img/styling/stroke-per-side.webp"/>
|
||||
</figure>
|
||||
<p>When the sides have different widths, the main width field shows <strong>Mixed</strong>. Typing a value in it sets all four sides to that value. Color, position and style are shared by all sides.</p>
|
||||
<p>Each stroke of a layer has its own per-side widths. You can also edit them on a multiple selection of rectangles and boards.</p>
|
||||
<p>You can apply <a href="/user-guide/design-systems/design-tokens/#design-tokens-stroke-width">stroke width tokens</a> to one side or to all of them. In the <a href="/user-guide/dev-tools/">Inspect tab</a>, per-side widths appear as CSS logical properties (<code>border-block-start-width</code>, <code>border-inline-end-width</code>, <code>border-block-end-width</code> and <code>border-inline-start-width</code>).</p>
|
||||
<p class="advice">Stroke per side requires <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a>. With the classic renderer the button is disabled and only the top width is drawn.</p>
|
||||
|
||||
<h2 id="stroke-to-path">Stroke to path</h2>
|
||||
<p>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.</p>
|
||||
<p><strong>Stroke to path</strong> 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.</p>
|
||||
|
||||
@ -22,7 +22,7 @@ are shown by default at the <a href="/user-guide/prototyping-testing/testing-vie
|
||||
|
||||
<p>Then, with the board tool selected, you have two options:</p>
|
||||
<ul>
|
||||
<li><strong>Select a board size upfront</strong>. You can choose one of the provided presets with the most common resolution for devices and standard print sizes. Use the search field to filter presets by name.</li>
|
||||
<li><strong>Select a board size upfront</strong>. You can choose one of the provided presets with the most common resolution for devices and standard print sizes. Use the search field to filter presets by name. The <strong>Penpot</strong> section has the sizes for a file thumbnail (300 × 200), a template cover (1390 × 781), a plugin icon (400 × 400) and a plugin cover (1390 × 724).</li>
|
||||
<li><strong>Click-and-drag</strong> to draw a frame of approximate size, then immediately edit its width/height values to be precise.</li>
|
||||
</ul>
|
||||
<figure>
|
||||
@ -165,7 +165,7 @@ a design.</p>
|
||||
<h3 id="curves">Curves (freehand)</h3>
|
||||
<p>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 <kbd>Shift/⇧</kbd> + <kbd>C</kbd>.</p>
|
||||
<p>The path created will contain a lot of points, but it is edited the same way as any other curve.</p>
|
||||
<p>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 <a href="#edit-nodes">edit</a> like any other.</p>
|
||||
|
||||
<h3 id="paths">Paths (bezier)</h3>
|
||||
<p>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 <kbd>P</kbd>. Then you have two ways to create the path:</p>
|
||||
@ -178,16 +178,37 @@ Select the curve tool from the free-draw flyout in the toolbar or press <kbd>Shi
|
||||
<li><strong>Close it</strong> clicking over the starting node.</li>
|
||||
<li><strong>Leave it open</strong> pressing <kbd>Esc</kbd> or <kbd>Enter</kbd> to stop editing. Then press <kbd>Esc</kbd> to exit the edit mode.</li>
|
||||
</ol>
|
||||
<p><strong>Tip:</strong> If you hold <kbd>Shift/⇧</kbd> while adding nodes the angle between the current and the next will change in 45 degree increments.</p>
|
||||
<p><strong>Tip:</strong> If you hold <kbd>Shift/⇧</kbd> while adding nodes, the angle between the current node and the next one changes in 15 degree increments.</p>
|
||||
<figure>
|
||||
<video title="Paths" muted="" playsinline="" controls="" width="100%" poster="/img/objects/path-create.webp" height="auto">
|
||||
<source src="/img/objects/path-create.mp4" type="video/mp4">
|
||||
</video>
|
||||
</figure>
|
||||
|
||||
<h4>Edit nodes</h4>
|
||||
<h4 id="edit-nodes">Edit nodes</h4>
|
||||
<p>To edit a node double click on a path or select and press <kbd>Enter</kbd>.
|
||||
You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd> to exit node edition. </p>
|
||||
<p>The path toolbar has two modes:</p>
|
||||
<ul>
|
||||
<li><strong>Move nodes</strong> (<kbd>M</kbd>): select and move nodes, segments and handles. Editing an existing path starts in this mode.</li>
|
||||
<li><strong>Draw nodes</strong> (<kbd>P</kbd>): add new nodes and segments to the path.</li>
|
||||
</ul>
|
||||
<p><strong>Snap nodes</strong> (<kbd>Ctrl/⌘</kbd> + <kbd>'</kbd>) is on by default, so new and moved nodes snap to the existing ones.</p>
|
||||
|
||||
<h4 id="continue-path">Continue or close a path</h4>
|
||||
<p>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 <kbd>Shift/⇧</kbd> to draw in 15 degree steps.</p>
|
||||
|
||||
<h4 id="path-gestures">Reshape with the mouse</h4>
|
||||
<p>These gestures work on nodes and segments while you edit a path:</p>
|
||||
<ul>
|
||||
<li><kbd>Ctrl/⌘</kbd> + drag a segment: bends it while both end nodes stay in place. <kbd>Ctrl/⌘</kbd> + click turns a segment from straight to curved and back.</li>
|
||||
<li><kbd>Ctrl/⌘</kbd> + drag a node: pulls out a pair of handles. <kbd>Ctrl/⌘</kbd> + click turns a node from corner to curve and back.</li>
|
||||
<li><kbd>Alt/⌥</kbd> + 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.</li>
|
||||
<li><kbd>Alt/⌥</kbd> + click a node: removes the node and keeps the path smooth around it.</li>
|
||||
<li><kbd>Ctrl/⌘</kbd> + <kbd>Alt/⌥</kbd> + click a node: removes the node and its segments, which leaves a gap.</li>
|
||||
</ul>
|
||||
<p><kbd>Delete</kbd> or <kbd>Backspace</kbd> removes the selected nodes and keeps the path joined. With selected handles, it removes them and the curve at that node becomes straight. <kbd>Shift/⇧</kbd> + <kbd>Delete</kbd> removes the selected nodes together with their segments.</p>
|
||||
<p>See all the <a href="/user-guide/first-steps/shortcuts/#path-editor">path editor shortcuts</a>.</p>
|
||||
<figure>
|
||||
<video title="Edit nodes" muted="" playsinline="" controls="" width="100%" poster="/img/objects/nodes-edit.webp" height="auto">
|
||||
<source src="/img/objects/nodes-edit.mp4" type="video/mp4">
|
||||
@ -219,6 +240,10 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
|
||||
</video>
|
||||
</figure>
|
||||
|
||||
<h4 id="image-frame">Change the image frame</h4>
|
||||
<p>Hold <kbd>Ctrl/⌘</kbd> 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.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="layer-actions">Layer actions</h2>
|
||||
|
||||
<h3 id="creating-layers">Create</h3>
|
||||
@ -477,7 +502,7 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
|
||||
<li><strong>Layer blur</strong> blurs the layer content itself.</li>
|
||||
<li><strong>Background blur</strong> blurs whatever sits behind the layer. Use it for frosted-glass or depth effects. Background blur requires the <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL renderer</a> to be enabled.</li>
|
||||
</ul>
|
||||
<p class="advice">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.</p>
|
||||
<p class="advice">Background blur is included in exports made with WebGL rendering enabled. In PDF files it is embedded as an image.</p>
|
||||
<figure>
|
||||
<img src="/img/styling/background-blur.webp" alt="Colorful shapes behind a frosted panel with background blur applied" />
|
||||
<figcaption>Background blur affects the layers behind the shape, not the shape itself.</figcaption>
|
||||
|
||||
@ -24,6 +24,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe
|
||||
</ul>
|
||||
<h4>Edit and style text content</h4>
|
||||
<p>Press <kbd>Enter</kbd> with a text layer selected to start editing the text content. You can style parts of the text content as rich text.</p>
|
||||
<p>With <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a> enabled, right-click while editing to open a menu with <strong>Cut</strong>, <strong>Copy</strong>, <strong>Paste</strong> and <strong>Select all</strong>. Your text selection is kept.</p>
|
||||
<figure>
|
||||
<img src="/img/objects/text-edit.webp" alt="editing text">
|
||||
</figure>
|
||||
|
||||
@ -185,6 +185,9 @@ press <kbd>Shift/⇧</kbd> + left click over the right arrow of a group or a boa
|
||||
<img src="/img/workspace-basics/comments-mark.webp" alt="Marking comments as read" />
|
||||
</figure>
|
||||
|
||||
<h3 id="comments-filter">Filter comments</h3>
|
||||
<p>The comments menu lets you show all comments, only your comments or only your mentions. <strong>Hide resolved comments</strong> is a separate option. Penpot remembers it in your browser for all files, in the workspace and in View mode.</p>
|
||||
|
||||
<h3>Edit and remove comments</h3>
|
||||
<p>At the top right of the comment popup you can find options to edit or delete comments.</p>
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ desc: Learn how to export layers in Penpot, the free, open-source design collabo
|
||||
<h2 id="export-options">Export options</h2>
|
||||
<p>The options of an export:</p>
|
||||
<ul>
|
||||
<li><strong>Size</strong> - Options for the most common sizing scales.</li>
|
||||
<li><strong>Size</strong> - 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.</li>
|
||||
<li><strong>Suffix</strong> - Especially useful if you are exporting at different scales.</li>
|
||||
<li><strong>File format</strong> - PNG, JPEG, WEBP, SVG, PDF.</li>
|
||||
</ul>
|
||||
|
||||
@ -15,3 +15,7 @@ desc: Start instantly with Penpot, the open-source design and prototyping platfo
|
||||
<h2>On your own server</h2>
|
||||
<p>Currently, private Penpot instances only require basic Docker knowledge.</p>
|
||||
<p>You can run your own Penpot server following the instructions at the <a href="/technical-guide/getting-started">Technical Guide</a></p>
|
||||
|
||||
<h3 id="check-for-updates">Check for updates</h3>
|
||||
<p>To see if your installation is up to date, open your profile menu in the dashboard and go to <strong>About Penpot → Check for updates</strong>. 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.</p>
|
||||
<p>The check runs from your browser and needs access to GitHub. It is hidden on installations configured as air-gapped.</p>
|
||||
|
||||
@ -10,7 +10,43 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
|
||||
|
||||
<p class="main-paragraph">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.</p>
|
||||
|
||||
<p>You can also check the most updated list of shortcuts at the <a href="https://github.com/penpot/penpot/blob/develop/frontend/src/app/main/data/workspace/shortcuts.cljs" target="_blank">GitHub file</a>.</p>
|
||||
<p>The tables below list the default shortcuts. You can change most of them to fit your keyboard layout or your habits. See <a href="#customize-shortcuts">Customize shortcuts</a>.</p>
|
||||
|
||||
<h2 id="customize-shortcuts">Customize shortcuts</h2>
|
||||
<p>Go to <strong>Your account → Shortcuts</strong> to see every shortcut and change the ones you want. From the workspace, open the shortcuts panel (<strong>Help & info → Shortcuts</strong> in the main menu, or <kbd>?</kbd>) and click <strong>Customize in Settings</strong>.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h3 id="customize-shortcuts-find">Find a shortcut</h3>
|
||||
<p>Shortcuts are grouped into <strong>Workspace</strong>, <strong>Dashboard</strong> and <strong>Viewer</strong>. Use the tabs to see <strong>All</strong> of them, only your <strong>Custom</strong> ones, or the ones you <strong>Disabled</strong>.</p>
|
||||
<p>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 <kbd>Shift</kbd>.</p>
|
||||
|
||||
<h3 id="customize-shortcuts-change">Change a shortcut</h3>
|
||||
<ol>
|
||||
<li>Click the shortcut you want to change.</li>
|
||||
<li>Press the new keys. The last combination you press is the one recorded.</li>
|
||||
<li>Click the check mark to save, or the cross to cancel. Clicking outside the editor also cancels. <kbd>Esc</kbd> does not cancel: it is recorded as a key.</li>
|
||||
</ol>
|
||||
<figure>
|
||||
<img src="/img/shortcuts/shortcuts-settings.webp" alt="Shortcuts settings with a shortcut being edited and a conflict warning" />
|
||||
</figure>
|
||||
<p>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 <strong>Disabled</strong>.</p>
|
||||
<p>Each action takes one combination. If an action had two defaults (for example, two ways to redo), saving a custom one replaces both.</p>
|
||||
<p>From the same editor you can also <strong>disable</strong> a shortcut so that no keys trigger it, or <strong>reset</strong> it to its default value. Custom shortcuts are marked with a dot, and the main menu highlights the custom keys.</p>
|
||||
<p class="advice"><strong>Paste</strong> cannot be customized.</p>
|
||||
|
||||
<h3 id="customize-shortcuts-layouts">Keyboard layouts</h3>
|
||||
<p>Punctuation keys (such as <kbd>-</kbd>, <kbd>[</kbd>, <kbd>/</kbd> or <kbd>'</kbd>) 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 <kbd>Shift</kbd>, the base key is recorded, not the shifted character.</p>
|
||||
|
||||
<h3 id="customize-shortcuts-reset">Reset to default</h3>
|
||||
<p>When you have custom shortcuts, a <strong>Reset all</strong> button appears next to the search field. It lists every shortcut that will go back to its default before you confirm.</p>
|
||||
|
||||
<h3 id="customize-shortcuts-import-export">Export and import your shortcuts</h3>
|
||||
<p>Use the <strong>Import/Export</strong> button at the bottom of the page to move your setup to another account or share it with your team.</p>
|
||||
<ul>
|
||||
<li><strong>Export</strong> downloads a JSON file with your custom and disabled shortcuts. It is available once you have changed at least one shortcut.</li>
|
||||
<li><strong>Import</strong> reads a JSON file exported from Penpot and shows the shortcuts it will change. Click <strong>Import</strong> to confirm.</li>
|
||||
</ul>
|
||||
<p class="advice">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.</p>
|
||||
|
||||
<h2 id="workspace-section"> Workspace </h2>
|
||||
<p>The Workspace is where the designs are actually created. <a href="/user-guide/first-steps/the-interface/#interface-workspace">More about the Workspace</a>.</p>
|
||||
@ -569,6 +605,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
|
||||
</table>
|
||||
|
||||
<h3 id="path-editor">Path editor</h3>
|
||||
<p>Copy, cut, paste, duplicate and select all also work on nodes while you edit a path. For mouse gestures, see <a href="/user-guide/designing/layers/#path-gestures">Reshape with the mouse</a>.</p>
|
||||
<table cellspacing="0" cellpadding="1" border="1" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -584,12 +621,17 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
|
||||
<td style="text-align: center;"><kbd>⇧</kbd><kbd>+</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete node</td>
|
||||
<td style="text-align: center;"><kbd>Supr</kbd> or <kbd>Delete</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⌫</kbd></td>
|
||||
<td>Delete node, segment or handle</td>
|
||||
<td style="text-align: center;"><kbd>Delete</kbd> or <kbd>Backspace</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⌦</kbd> or <kbd>⌫</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Draw Path</td>
|
||||
<td>Delete node and segments</td>
|
||||
<td style="text-align: center;"><kbd>Shift</kbd><kbd>Delete</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⇧</kbd><kbd>⌫</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Draw nodes</td>
|
||||
<td style="text-align: center;"><kbd>P</kbd></td>
|
||||
<td style="text-align: center;"><kbd>P</kbd></td>
|
||||
</tr>
|
||||
@ -614,7 +656,7 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
|
||||
<td style="text-align: center;"><kbd>⌘</kbd><kbd>J</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Move Nodes</td>
|
||||
<td>Move nodes</td>
|
||||
<td style="text-align: center;"><kbd>M</kbd></td>
|
||||
<td style="text-align: center;"><kbd>M</kbd></td>
|
||||
</tr>
|
||||
@ -628,6 +670,16 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
|
||||
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>'</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⌘</kbd><kbd>'</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Flip horizontally (selected nodes, or the whole path)</td>
|
||||
<td style="text-align: center;"><kbd>Shift</kbd><kbd>H</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⇧</kbd><kbd>H</kbd></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Flip vertically (selected nodes, or the whole path)</td>
|
||||
<td style="text-align: center;"><kbd>Shift</kbd><kbd>V</kbd></td>
|
||||
<td style="text-align: center;"><kbd>⇧</kbd><kbd>V</kbd></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@ -10,7 +10,8 @@ desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Be
|
||||
<h3>WebGL rendering (Beta)</h3>
|
||||
<p>Since <strong>Penpot 2.16</strong>, optional <strong>WebGL rendering (Beta)</strong> is available on <code>design.penpot.app</code> and self-hosted instances. It is <strong>off by default</strong>.</p>
|
||||
<p>Turn it on from <strong>Your account → Settings</strong> or <strong>Workspace → Main menu → Preferences</strong> when you want a faster canvas for complex files.</p>
|
||||
<p>In this beta, WebGL applies to the <strong>design workspace canvas</strong> 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.</p>
|
||||
<p>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.</p>
|
||||
<p>Some features need WebGL rendering, such as <a href="/user-guide/designing/color-stroke/#stroke-per-side">stroke per side</a>, background blur and <a href="/user-guide/designing/color-stroke/#stroke-to-path">stroke to path</a>.</p>
|
||||
<p>For what is in scope today, what we are prioritizing next, and known visual differences, read <a href="https://community.penpot.app/t/whats-next-for-the-penpot-webgl-renderer/10627" target="_blank">What's next for the Penpot WebGL renderer</a> on the Penpot Community.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
;;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 <someone@example.com>\n")
|
||||
|
||||
def test_co_authored_by_fails(self):
|
||||
error = self.assert_fail(":bug: Fix crash\n\nBody.\n\nCo-authored-by: Someone <someone@example.com>\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 <someone@example.com>\n")
|
||||
|
||||
def test_co_authored_by_in_body_fails(self):
|
||||
self.assert_fail(":bug: Fix crash\n\nCo-authored-by: Someone <someone@example.com>\nBody.\n")
|
||||
|
||||
def test_similar_trailer_passes(self):
|
||||
self.assert_ok(":bug: Fix crash\n\nBody.\n\nCo-developed-by: Someone <someone@example.com>\n")
|
||||
|
||||
|
||||
class SubjectRulesRegressionTests(unittest.TestCase):
|
||||
"""Guard the pre-existing validators against accidental breakage."""
|
||||
|
||||
|
||||