Compare commits

..
22 Commits
Author SHA1 Message Date
Andrey Antukh f9c02926b9 Merge remote-tracking branch 'origin/main' into staging 2026-09-10 20:21:41 +02:00
bameda bae3900537 ♻️ Rebalance CI runners and drop pinned ubuntu-24.04
Move build-docker and build-docker-devenv jobs from penpot-extended-runner
to penpot-standar-runner, point tests-exporter at the canonical
penpot-extended-runner label instead of the stale penpot-runner-02 alias,
and switch build-tag/release notify jobs from ubuntu-24.04 to ubuntu-latest.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-10 19:23:13 +02:00
bameda 757a5bd479 ♻️ Rebalance CI runners and drop pinned ubuntu-24.04
Move build-docker and build-docker-devenv jobs from penpot-extended-runner
to penpot-standar-runner, point tests-exporter at the canonical
penpot-extended-runner label instead of the stale penpot-runner-02 alias,
and switch build-tag/release notify jobs from ubuntu-24.04 to ubuntu-latest.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-10 19:22:46 +02:00
Andrey Antukh 8952d70fd2 Optimize get-profiles-for-file-comments query (#11622)
Rewrite sql:file-comment-users to join comment with
comment_thread and union the requesting profile id, then
join the resulting small id set against profile.

The previous "id IN (subquery) OR id = ?" forced a
sequential scan over the whole profile table with a hashed
subplan filter, taking ~1.9s on large instances. The
semi-join lets the planner use profile_pkey, dropping the
query to sub-millisecond time. UNION (not UNION ALL) keeps
the previous dedup semantics when the requesting profile is
also a commenter.

AI-assisted-by: deepseek-flash
2026-09-10 16:45:22 +02:00
Andrey Antukh 4ce459d720 🐛 Escape LDAP filter values and use directory email in retrieve-user (#11085)
Fix LDAP injection vulnerability (T5-N1-03) where the client-supplied email was used directly in the LDAP search filter without escaping RFC 4515 special characters (*, (, ), \, NUL), and the profile email was taken from client input instead of the LDAP directory attribute.

Changes:
- Add escape-ldap-filter-value per RFC 4515 section 3
- Apply escaping in search-user before building LDAP filter
- Add get-attr helper for multi-valued LDAP attributes
- Fix retrieve-user to use directory email (attrs-email) instead of client email
- Use cuerdas.core instead of clojure.string

Closes #11084

AI-assisted-by: mimo-v2.5-pro
2026-09-10 16:39:35 +02:00
Luis de Dios c589563912 ♻️ Replace digit with number in password validations (#11609) 2026-09-10 12:15:58 +02:00
andrés gonzález 0eb3179016 💄 Adjust release notes 2.18 titles (#11608) 2026-09-10 11:50:57 +02:00
Andrey Antukh d1ebf4cda2 📎 Update changelog 2026-09-10 10:41:10 +02:00
Andrey Antukh fdb9e97572 📎 Update planner skill and AGENTS.md 2026-09-10 10:12:09 +02:00
Elenzakaleidos 9cd3b63eea 📚 Update README.md (#11602)
Added a new section for Penpot Enterprise detailing its features and benefits for organizations.

Signed-off-by: Elenzakaleidos <elena.scilinguo@kaleidos.net>
2026-09-10 09:52:06 +02:00
David Barragán Merino 94555c027e 🔧 Sync .github/workflows with develop
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-10 09:47:10 +02:00
Juan de la CruzandLuis de Dios b283d952a8 Add new slides content for 2.18 release (#11222)
*  Add new slides content for 2.18 release

* ♻️ Use buttons from DS

* ♻️ Use new SCSS guidelines

* ♻️ Use a base stylesheet for all version files

* ♻️ Use new SCSS guidelines

*  Add new images and wording

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-09-10 09:35:34 +02:00
Alejandro Alonso ac84557740 🐛 Re-upload WASM text after WebGL context restore (#11589)
During reload-renderer!, reloading? keeps initialized?/ready? false
while set-objects runs (especially the sync path for small files).
Text content used that guard and was skipped; geometry already used
live?. Gate use-shape, has-shape, and set-shape-text-content on
wasm/live? so text is restored with the rest of the shapes.
2026-09-09 17:25:18 +02:00
Andrey Antukh 43f7e49aa0 📎 Update changelog 2026-09-09 11:48:37 +02:00
Andrey Antukh eca1d81692 🔧 Remove legacy pnpm build key and clarify updating doc
Drop the ignored-since-pnpm-11 onlyBuiltDependencies entry from
render-wasm/pnpm-workspace.yaml, keeping allowBuilds as the single
source of build approvals. Clarify the updating-pnpm gotcha so it no
longer claims pnpm writes ignoredBuiltDependencies.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 11:45:00 +02:00
Pablo Alba d263c23a58 🐛 Add ssrf check for nitrate sso and add timeouts to http client (#11576) 2026-09-09 11:23:53 +02:00
Andrey Antukh 8a3540336b 📚 Add local-ci skill and scripts/ci memory
Teach agents to verify their changes with ./scripts/ci: module list,
task selection flags, log locations under .ci-logs/, and per-module
workflows (lint-only pass, --fix, --paren-repair, common/ consumer
checks). Register the skill in the skills README, point AGENTS.md at
the new memory, and add the script to the critical-info dev scripts.

AI-assisted-by: omen-alpha
2026-09-09 11:01:49 +02:00
Andrey Antukh 6f4a68100d Add direct mode to implement-plan flow
The flow only supported continue mode (existing branch) or standalone
mode (issue + branch). Add a "direct" argument (aliases: "no branch",
"direct commit") that skips both and commits on the current branch as
it is, even when it is a base branch — for small tooling changes the
user wants committed in place.

AI-assisted-by: omen-alpha
2026-09-09 11:01:49 +02:00
esther-morenoandmiryamgduque 62fdc02c0f 📚 Adding enterprise section in the user guide (#10821)
* 📚 Add enterprise section

* 📚 Update images

* 📚 Add SSO module section to Enterprise plan user guide

* 📚 Add more FAQs to Enterprise plan user guide

* 📚 Add SSO configuration screenshot to Enterprise plan user guide

* 📚 Replace membership screenshot with webp to fix broken reference

The membership image reference already pointed to .webp while the tracked
file was .png, leaving a broken image. Swap the asset to match.

* 📚 Document how to access the Admin Console

Add a paragraph explaining how to reach the Admin Console and an
accompanying screenshot to the Enterprise plan user guide.

* 📚 Update Admin Console access screenshot

* 📚 Open Enterprise plan images in a new tab on click

Wrap the enterprise screenshots in a target="_blank" link so they open
full size, matching the behavior on other user-guide pages. The
organization hierarchy diagram is left inline.

* 📚 Document identity provider configuration for SSO

* 📚 Refine SSO session, Admin Console docs, and screenshots

* 📚 Split Organizations content out of the Enterprise plan page

* 📚 Fix wording in Enterprise plan FAQ answers

---------

Co-authored-by: miryamgduque <miryam.gonzalez@kaleidos.net>
2026-09-09 10:59:17 +02:00
Eva Marco 011feeaf71 🐛 Fix missing text in legacy SVG board thumbnails (#11552)
* 🐛 Fix missing text in legacy SVG board thumbnails

Board thumbnails rendered by frame-imposter used React's
renderToStaticMarkup, a synchronous pass with no live DOM to measure
text against. Text shapes without a persisted position-data value
therefore rendered as nothing, so a cached board thumbnail silently
lost its text until the board was hovered, selected, or the canvas
was zoomed past 130%, all of which bypass the cached thumbnail in
favor of live content.

frame-imposter now provides the same is-render? context the
standalone exporter already sets, so text without position-data
falls back to the synchronous foreignObject renderer instead of
rendering nothing.

Thumbnails cached before this fix stay broken until something
regenerates them, so on each page load, board thumbnails containing
text are opportunistically regenerated once per browser (tracked via
local-storage) so existing files self-heal without requiring an
edit.

AI-assisted-by: claude-sonnet-5

* ♻️ Use a transducer in heal-stale-text-thumbnails

Fixes a PR review comment: the frame filtering/mapping was spread
across four separate rx operators. Collapse it into a single
transducer pass over frame-ids, leaving only one rx/map to perform
the mark-healed side effect and build the update-thumbnail action.
2026-09-09 09:56:10 +02:00
Andrey Antukh e96a75d366 Send telemetry event batch as plain JSON vector
The event batch sent to the telemetry server was encoded as a
fressian+zstd base64 blob. Send it as a plain vector of event maps
instead: the JSON encoder handles UUID and temporal types natively,
the payload becomes inspectable, and the receiver schema coerces
values back to proper types.

The receiver (penpot-telemetry) now accepts both the blob and the
plain vector, so it must be deployed before this backend change.

AI-assisted-by: omen-alpha
2026-09-09 09:39:30 +02:00
andrés gonzález 3b1290c6a2 📚 Update user guide for upcoming release features (#11458)
Document stroke to path, toolbar flyouts, line and arrow tools,
font previews, page multi-select, and dashboard grid/list view.

Relates to #9145, #10403, #10580, #10691
2026-09-09 09:36:48 +02:00
125 changed files with 2188 additions and 2504 deletions

No files matched your search

+2 -1
View File
@@ -34,7 +34,7 @@ JSON, REPL access, and so on.
|---|---|---|
| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. | "implement the plan" · "step by step, one commit per task" |
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. On request ("direct"): no issue and no branch, commits on the current branch. | "implement the plan" · "step by step, one commit per task" · "direct, no branch" |
| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
@@ -65,6 +65,7 @@ JSON, REPL access, and so on.
| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
| [`local-ci`](skills/local-ci/SKILL.md) | Run CI-style lint, test, and format checks for the modules you touched with `scripts/ci`, and read the logs when they fail. |
| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
+22 -10
View File
@@ -1,6 +1,6 @@
---
name: implement-plan
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each. Use it when the user asks to implement or execute a plan, in any phrasing.
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each; on request ("direct"), no issue and no branch — the commit lands on the current branch. Use it when the user asks to implement or execute a plan, in any phrasing.
---
# Implement Plan
@@ -45,9 +45,14 @@ read-only: nothing is created until the user confirms (step 3).
No issue or branch is created. The branch name provides the issue
reference when it follows the `issue-NNNN` pattern.
Arguments override detection: `standalone`, `continue`,
`no issue` / `without issue`, or an explicit base such as
`from origin/develop`.
Arguments override detection: `standalone`, `continue`, `direct`
(`no branch` / `direct commit`), `no issue` / `without issue`, or an
explicit base such as `from origin/develop`.
**Direct mode** (`direct`, `no branch`, `direct commit`): no issue and
no branch — the implementation and the commit land on the current branch
as it is, even when it is a base branch. Best for small or tooling-only
changes the user wants committed in place.
**Standalone while already on a feature branch:** stop and explain that this
would stack branches. Ask the user to re-run with an explicit base, for
@@ -59,8 +64,10 @@ Before touching the repository, show the user the full picture:
- **The flow**: whether the GitHub issue and the branch will be created
(standalone mode — give the planned branch name, `issue-NNNN` or
`plan-<slug>`), or whether you continue on the current branch
(continue mode — name it).
`plan-<slug>`), whether you continue on the current branch
(continue mode — name it), or whether everything lands on the current
branch as it is (direct mode — name it, and say so when it is a base
branch).
- **The execution style**: batch or step-by-step (see *Execution modes*).
- A checklist (todolist) of the plan's tasks, in order.
@@ -84,6 +91,11 @@ If the arguments say `no issue` / `without issue`, skip the issue and
create a branch named `plan-<slug>` instead, where `<slug>` is the plan
title, lowercase and hyphen-separated.
If the arguments say `direct` / `no branch` / `direct commit`, skip the
issue and the branch: implement and commit on the current branch as it
is. If it is a base branch, the checklist presentation already said so —
no further confirmation is needed.
### Batch mode (default)
Implement every task in one go. Work methodically, keeping changes
@@ -126,7 +138,7 @@ instruction from me overrides them):
Extra context in the user's invocation (the message that triggered this
skill) plays the role command arguments play elsewhere: `standalone`,
`continue`, `no issue` / `without issue`, an explicit base such as
`from origin/develop`, or `step by step` / `one commit per task` for the
step-by-step execution mode. Modes combine freely, for example
"standalone step by step".
`continue`, `direct` (`no branch` / `direct commit`), `no issue` /
`without issue`, an explicit base such as `from origin/develop`, or
`step by step` / `one commit per task` for the step-by-step execution
mode. Modes combine freely, for example "standalone step by step".
+95
View File
@@ -0,0 +1,95 @@
---
name: local-ci
description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
---
# Local CI
Run the same checks CI runs, locally, for the modules you touched, with
`scripts/ci`. Each task writes a log file; the final summary says what
passed and what failed.
Full details: `mem:scripts/ci` (file: `.serena/memories/scripts/ci.md`)
## When to use
- After implementing or fixing code — verify every module you touched
before declaring the work done.
- When the user asks to run CI, lint, tests, or format checks locally.
- When you changed `common/` — validate its consumers too.
**Skip:** while exploring, planning, or reading code.
## Command reference
Run from the repo root:
```bash
./scripts/ci [OPTIONS] [MODULES...]
```
Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
`plugins` `library`, or `--all` for every module.
With no task flags it runs three tasks per module, in order: **lint**,
**test**, **fmt** (format check; `--fix` formats files instead).
| Flag | Effect |
|------|--------|
| `--all` | Run every module |
| `--exclude MOD` | Skip one module (repeatable) |
| `--lint` / `--no-lint` | Run only lint / drop lint |
| `--test` / `--no-test` | Run only tests / drop tests |
| `--fmt` / `--no-fmt` | Run only format check / drop it |
| `--fix` | Format files instead of checking (other tasks unaffected) |
| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
| `--fail-fast` | Stop at the first failure |
| `--quiet` | Suppress failure output |
| `--dry-run` | Show what would run, execute nothing |
| `--clean` | Delete the `.ci-logs/` directory |
## Reading failures
Every task writes its full output to `.ci-logs/<module>-<task>.log`. On
failure the script prints only the last 30 lines. To diagnose a failure,
**read the log file** — never re-run the command piped through filters
(repo rule: redirect to a file first, then read it). The exit code is 1
when any task failed; the summary lists each failed `module:task` and its
log path.
## Typical workflows
```bash
# Verify a module you changed: lint + tests + format check
./scripts/ci frontend
# Fast pass while iterating: lint only
./scripts/ci --lint frontend
# Lint + format check, skip the long test suite
./scripts/ci --no-test frontend
# Format the module without running the test suite
./scripts/ci --fix --no-test frontend
# Broke delimiters in Clojure/CLJS files: repair first, then lint
./scripts/ci --paren-repair frontend
./scripts/ci --lint frontend
# Changed common/ — validate its consumers too
./scripts/ci frontend backend exporter
# Preview what would run, without running it
./scripts/ci --dry-run --all
```
## Gotchas
- Run from the repo root.
- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
the bash call a generous timeout (1020 minutes) instead of letting it
time out mid-run.
- `mcp` has no lint task — it shows as skipped, not failed.
- `--paren-repair` only fixes delimiters; run lint afterwards to catch
what remains. See `mem:scripts/paren-repair`.
- What to run and how to read test results: `mem:testing`.
+57 -232
View File
@@ -1,13 +1,11 @@
---
name: planner
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
description: Read-only planning and architecture analysis — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan, suggested save path and the next steps.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans with task breakdowns that engineers or other agents can
execute. Never writes or modifies code.
Produce a plan that another engineer or agent can execute without guessing.
## When to Use
@@ -21,24 +19,7 @@ execute. Never writes or modifies code.
- A task feels too large or vague to start.
- Work needs to be parallelized across multiple agents or sessions.
Do **not** use this skill to actually implement anything — it is read-only.
**When NOT to use:** Single-file changes with obvious scope, or when the spec
already contains well-defined tasks.
## Role
You help users understand the Penpot codebase, design solutions, and produce
implementation plans that other agents or developers can execute. The plan
tells them what to build and how to verify it, task by task.
The implementer reads the project's agent docs (`AGENTS.md`, project memories
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
before working. Reference those memories instead of re-explaining tooling,
conventions, or test design — explain in the plan only what they do not cover.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the implementer's responsibility.
Do not use for a small change with obvious scope or an existing executable plan.
## CRITICAL: Required Reading Before Planning
@@ -55,67 +36,36 @@ Before drafting any plan, work through the project's own guidance:
Skipping this step is the #1 cause of incorrect or incomplete plans.
---
## Constraints
## The Planning Process
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
### Phase 1: Architecture Analysis
## Planning Process
1. Read the spec, requirements, or feature request.
2. Analyze the codebase architecture and identify affected modules.
3. Read project conventions (starting with `critical-info` and module core
memories) before drafting.
4. Map dependencies between components (see the dependency graph in
`critical-info`).
5. Identify risks, edge cases, performance implications, and breaking changes.
1. Define the problem, desired outcome, constraints, and exclusions.
2. Trace the current behavior through the affected modules.
3. Map dependencies and choose an implementation order that builds foundations
before their consumers.
4. Identify open product or architecture decisions. Resolve implementation
details from existing conventions when they do not affect public behavior.
5. Identify edge cases, security and data risks, performance bounds, breaking
changes, and external dependencies.
6. Split the work into small, ordered tasks. Prefer complete testable slices
over unrelated layer-wide batches. Apply DRY and KISS to the proposed
implementation.
7. Define exact acceptance criteria and verification for every task.
8. Add a checkpoint after every two or three tasks in a longer plan.
9. State which tasks can run in parallel and which must remain sequential.
### Phase 2: Task Breakdown
#### Identify the Dependency Graph
Map what depends on what, following the monorepo's module dependency graph:
```
common (shared types, schemas — no deps)
├── backend (depends common)
│ ├── RPC handlers
│ └── persistence / migrations
├── frontend (depends common, render-wasm)
│ ├── UI components
│ └── state / API integration
├── exporter (depends common)
└── render-wasm (consumed by frontend)
```
Implementation order follows the dependency graph bottom-up: build shared
foundations first, then layer consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
**Bad (horizontal slicing):**
```
Task 1: Build all common types
Task 2: Build all backend handlers
Task 3: Build all frontend components
```
**Good (vertical slicing):**
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
Task 3: frontend UI component + API integration
```
Each vertical slice delivers working, testable functionality.
#### Write Tasks
## Task Format
Each task follows this structure:
@@ -152,17 +102,16 @@ implementation. Omit when the task is mechanical.
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
```
Replace "module-specific test command" with the actual commands for the module
(e.g. `clojure -M:dev:test` for backend/common,
`npx shadow-cljs compile test && npx karma start` for frontend, or the
commands noted in the module's core memory).
Use commands from `mem:testing` and affected module memories. Never substitute
generic text such as "run the tests" when the project documents an exact
command.
When possible, design each task with TDD in mind: acceptance criteria double
as a test list, and the natural first step of the task is writing those tests
before the implementation. Some tasks resist this (config, migrations, pure
wiring) — for those, keep the usual verification steps.
When possible, design each task with TDD in mind: acceptance criteria double as a test
list, and the natural first step of the task is writing those tests before the
implementation. Some tasks resist this (config, migrations, pure wiring) — for those, keep
the usual verification steps.
#### Estimate Scope
## Task Sizing
| Size | Files | Scope | Example |
|------|-------|-------|---------|
@@ -172,16 +121,11 @@ wiring) — for those, keep the usual verification steps.
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
| **XL** | 8+ | **Too large — break it down further** | — |
If a task is XL, it should be broken into smaller tasks. Agents perform best
on S and M tasks.
Split a task when it contains independent outcomes, spans unrelated systems, or cannot be
completed and verified in one focused session (if a task is XL, it should be broken into
smaller tasks; agents perform best on S and M tasks).
**When to break a task down further:**
- It would take more than one focused session
- You cannot describe the acceptance criteria in 3 or fewer bullet points
- It touches two or more independent subsystems
- You find yourself writing "and" in the task title (a sign it is two tasks)
#### Order and Checkpoints
## Task order and checkpoints
Arrange tasks so that:
@@ -197,153 +141,43 @@ Add explicit checkpoints with the relevant module commands:
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
```
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read project conventions before drafting (start with `critical-info` and
affected module core memories).
- Break down complex features or bugs into atomic, actionable steps.
- Propose solutions with clear rationale, trade-offs, and sequencing.
- Identify risks, edge cases, performance implications, and breaking changes.
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
- Checkpoints must exist after every 2-3 tasks.
## Constraints
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Output Format
The plan is always delivered in the response so the user sees it regardless
of which agent is running the skill. By default you never write the plan file;
announce the path instead. Write the file only when the command or user
explicitly instructs you to save it — and then only that file.
of which agent is running the skill. File writes follow `Constraints`
by default announce the path instead of writing.
Announce the suggested save path:
```
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
```
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
provides a target file path, announce that path instead of the default.
Announce the save path `.agents/plans/YYYY-MM-DD-<slug>.md` (today's date,
lowercase hyphen-separated slug, e.g. `2026-09-10-add-batch-get-profiles`;
an explicit user path wins).
End the response by suggesting the next steps: `/review-plan` to get a second
opinion on the plan and `/implement-plan` to execute it.
### Plan Document Template
### Plan Structure
Use this document shape:
```markdown
# Plan: [Feature/Project Name]
# Plan: Title
## Context
[One paragraph: what is the problem or feature request? Why is it needed?]
## Affected Modules
[Which modules of the monorepo are involved? Reference module paths and any
`mem:` memories that were consulted.]
## Architecture Decisions
- [Key decision 1 and rationale]
- [Key decision 2 and rationale]
## Risks & Considerations
[Edge cases, performance implications, breaking changes, migration concerns,
security implications.]
## Risks and Considerations
## Approach
[A short strategy summary: 3-5 sentences describing the overall approach and
the shape of the dependency graph (what depends on what, what gets built
first). High-level only — the task-by-task detail lives in the Task List.]
## Task List
Each task uses the full task structure defined in
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
verification, dependencies, files, estimated scope, and optional code sketch.
Never reduce a task to a one-line checkbox; the plan must be self-contained
and executable without other context.
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
into phases, milestones, or sprints; ordering and dependencies are already
captured per task. Insert a checkpoint after every 2-3 tasks.
## Task 1: [Short descriptive title]
**Description:** [What this task accomplishes.]
**Rationale:** [Why this approach over the alternatives.]
**Acceptance criteria:**
- [ ] [Specific, testable condition]
**Verification:**
- [ ] Relevant tests pass (module-specific command).
**Dependencies:** None
**Files likely touched:**
- `path/to/file`
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
**Code sketch (optional):** [Short contract-level example, only if the shape
is non-obvious.]
## Task 2: [Short descriptive title]
[Same structure as Task 1.]
## Task 3: [Short descriptive title]
[Same structure as Task 1.]
### Checkpoint: After Tasks 1-3
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
## Task 4: [Short descriptive title]
[Same structure as Task 1.]
## Task 5: [Short descriptive title]
[Same structure as Task 1.]
## Verification & Testing
[How to verify each task and the whole plan: the project's real test, lint,
build, and run commands (extracted during Required Reading), coverage
expectations, and manual checks. Consult each module's core memory for the
exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
modules, tests for already-implemented features, documentation
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
## Verification and Testing
## Parallelization
## Open Questions
- [Question needing human input]
```
Omit empty sections only when they do not apply. Every implementation task
still requires acceptance criteria, verification, dependencies, likely files,
and scope.
When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** and **Task List** sections and
lead with **Findings** instead, keeping the rest of the structure.
@@ -357,15 +191,6 @@ lead with **Findings** instead, keeping the rest of the structure.
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
## Red Flags
- Delivering prose without a task breakdown
- Tasks that say "implement the feature" without acceptance criteria
- No verification steps in the plan
- All tasks are XL-sized
- No checkpoints between tasks
- Dependency order isn't considered
## Verification Checklist
Before delivering the plan, confirm:
+44
View File
@@ -0,0 +1,44 @@
name: _ADHOC
run-name: >-
_ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Branch/ref to build in penpot/penpot'
type: string
required: true
nitrate_ref:
description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
type: string
required: false
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: ${{ inputs.gh_ref }}
force: ${{ inputs.force }}
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: ${{ inputs.gh_ref }}
force: ${{ inputs.force }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
force: ${{ inputs.force }}
+43 -5
View File
@@ -9,6 +9,11 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this version already exists in S3'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -16,20 +21,31 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this version already exists in S3'
type: boolean
required: false
default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
# by the same caller into a single shared group, and left a manual dispatch of
# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
group: build-bundle-${{ inputs.gh_ref }}
cancel-in-progress: true
jobs:
# ── 1. Decide whether there is anything to build ───────────────────────
check:
name: Check current bundle
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 10
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -44,10 +60,12 @@ jobs:
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
# The uploaded zip carries its version as S3 metadata. If the
# existing object was already built from this same commit, the
# whole build job is skipped.
# whole build job is skipped. `force` bypasses this check entirely.
- name: Check if this bundle is already built
id: check
env:
@@ -55,6 +73,16 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
{
echo "### 🔁 Bundle build forced"
echo ""
echo "\`force: true\` — skipping the S3 version check."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
EXISTING_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
@@ -75,7 +103,7 @@ jobs:
# ── 2. Build and upload, only when needed ──────────────────────────────
build:
name: Build and Upload Penpot Bundle
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 90
needs: check
if: needs.check.outputs.exists == 'false'
@@ -113,10 +141,20 @@ jobs:
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
- name: Write step summary
run: |
{
echo "### ✅ Bundle built"
echo ""
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
} >> "$GITHUB_STEP_SUMMARY"
# ── 3. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [check, build]
if: failure()
+17 -1
View File
@@ -1,16 +1,30 @@
name: _DEVELOP
run-name: >-
_DEVELOP (develop @ ${{ github.sha }})
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
schedule:
- cron: '16 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -18,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
@@ -13,6 +13,11 @@ on:
type: string
required: false
default: 'develop'
force:
description: 'Rebuild and overwrite even if already built'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -24,6 +29,11 @@ on:
type: string
required: false
default: 'develop'
force:
description: 'Rebuild and overwrite even if already built'
type: boolean
required: false
default: false
secrets:
ORG_WORKFLOW_TOKEN:
description: 'Token with Actions write access on penpot-nitrate'
@@ -47,6 +57,7 @@ jobs:
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
-f gh_ref="$GH_REF" \
-f force="${{ inputs.force }}" \
-f caller_run_id="$DISTINCT_ID" \
-f caller_run_url="$CALLER_URL"
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
steps:
- name: Set common environment variables
+46 -7
View File
@@ -8,6 +8,11 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -15,9 +20,18 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
# by the same caller into a single shared group, and left a manual dispatch of
# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
group: build-docker-${{ inputs.gh_ref }}
cancel-in-progress: true
env:
@@ -32,12 +46,13 @@ jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -56,6 +71,7 @@ jobs:
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
BUNDLE_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
@@ -67,7 +83,8 @@ jobs:
# The image set is a single block, so a single set-level check is
# enough: `promote` drops a marker object in S3 only after every
# image was built AND every branch tag was moved. Marker present
# means there is nothing at all to do for this commit.
# means there is nothing at all to do for this commit. `force`
# bypasses this check entirely.
- name: Check if this image set is already built
id: check
env:
@@ -75,6 +92,21 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
{
echo "### 🔁 Image set build forced"
echo ""
echo "\`force: true\` — skipping the S3 marker check."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
@@ -103,7 +135,7 @@ jobs:
# ── 2. One build per image, in parallel, only when needed ──────────────
build:
name: Build ${{ matrix.image }}
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.exists == 'false'
@@ -134,7 +166,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the 429 Too Many Requests error when downloading
# To avoid the "429 Too Many Requests" error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
@@ -216,7 +248,7 @@ jobs:
# the S3 marker guarantees the branch tags were already moved.
promote:
name: Promote image set
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 10
needs: [prepare, build]
@@ -254,16 +286,23 @@ jobs:
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
- name: Write step summary
run: |
{
echo "### ✅ Image set promoted"
echo ""
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [prepare, build, promote]
if: failure()
+17 -1
View File
@@ -1,16 +1,30 @@
name: _STAGING
run-name: >-
_STAGING (staging)
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
schedule:
- cron: '36 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -18,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
+31 -3
View File
@@ -1,17 +1,33 @@
name: _TAG
run-name: >-
_TAG (${{ github.ref_name }} @ ${{ github.sha }})
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
type: boolean
required: false
default: false
push:
tags:
- '*'
# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1
# must not abort the release already in flight.
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -19,11 +35,21 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
runs-on: ubuntu-latest
needs:
- build-docker
- build-docker-admin-console
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
@@ -37,7 +63,9 @@ jobs:
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
uses: ./.github/workflows/release.yml
secrets: inherit
with:
+24
View File
@@ -0,0 +1,24 @@
name: _TMP TOKENS
on:
workflow_dispatch:
schedule:
- cron: '46 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "hiru-tokens-in-libs"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "hiru-tokens-in-libs"
+1 -1
View File
@@ -34,7 +34,7 @@ permissions:
jobs:
deploy:
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
steps:
- name: Checkout
uses: actions/checkout@v6
+1 -1
View File
@@ -19,7 +19,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
version: ${{ steps.vars.outputs.gh_ref }}
release_notes: ${{ steps.extract_release_notes.outputs.release_notes }}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
composable-test-suite:
if: ${{ !github.event.pull_request.draft }}
name: "Run composable test suite (mocked backend)"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+158 -32
View File
@@ -5,11 +5,37 @@ defaults:
shell: bash
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
shards:
description: 'Shard layout (JSON array)'
type: choice
required: true
default: '[1, 2, 3, 4]'
options:
- '[1, 2, 3, 4]'
- '[1, 2, 3, 4, 5, 6]'
- '[1, 2]'
- '[1]'
workers:
description: 'Playwright workers per shard'
type: string
required: true
default: '2'
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
types:
- opened
@@ -25,25 +51,41 @@ on:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
timeout-minutes: 30
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
outputs:
bundle_key: ${{ steps.vars.outputs.bundle_key }}
steps:
# An empty `ref` makes checkout fall back to its default (the PR merge
# ref on pull_request, the pushed ref on push).
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
# The cache key must come from the SHA actually checked out: on a manual
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
- name: Extract cache key
id: vars
run: |
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Build Bundle
working-directory: ./frontend
@@ -53,58 +95,142 @@ jobs:
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
runs-on: penpot-runner-02
name: "Integration Tests (${{ matrix.shard }})"
runs-on: penpot-extended-runner
timeout-minutes: 40
needs: build-integration
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
- /var/cache/github-runner/ms-playwright:/ms-playwright
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-integration.outputs.bundle_key }}
path: frontend/resources/public
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
# No-op once the shared volume is warm; keeps the first run working.
- name: Install Playwright Chromium
working-directory: ./frontend
run: pnpm exec playwright install chromium
# `strategy.job-total` is the matrix size, so the shard denominator
# follows the `shards` input without being hardcoded.
- name: Run Tests
working-directory: ./frontend
env:
WORKERS: ${{ inputs.workers }}
run: |
WORKERS=${WORKERS:-2}
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
pnpm exec playwright test --project default \
--workers="$WORKERS" \
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
--reporter=blob
- name: Upload blob report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-blob-report-${{ matrix.shard }}
path: frontend/blob-report/
overwrite: true
retention-days: 3
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-${{ matrix.shard }}
path: frontend/test-results/
overwrite: true
if-no-files-found: ignore
retention-days: 3
merge-reports:
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
name: "Merge Integration Reports"
runs-on: penpot-extended-runner
timeout-minutes: 15
needs: test-integration
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
ref: ${{ inputs.gh_ref }}
- name: Run Tests
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
- name: Download blob reports
uses: actions/download-artifact@v7
with:
path: frontend/all-blob-reports
pattern: integration-blob-report-*
merge-multiple: true
- name: Merge into HTML report
working-directory: ./frontend
env:
PLAYWRIGHT_REPORTER: list,json
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
run: |
./scripts/test-e2e
pnpm exec playwright merge-reports \
--reporter=html,json,list ./all-blob-reports
- name: Flaky summary
- name: Test summary
if: always()
working-directory: ./frontend
run: |
if [ ! -f report.json ]; then
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
jq -r '
[ .. | objects
| select(has("tests") and has("file"))
| select(any(.tests[]; .status == "flaky"))
| "- `\(.file):\(.line)` — \(.title)"
] as $f
| "## Flaky tests: \($f | length)\n"
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
' report.json >> "$GITHUB_STEP_SUMMARY"
# Kept for 30 days so flakiness rates can be aggregated across runs
# without scraping job logs.
- name: Upload JSON report
uses: actions/upload-artifact@v7
if: always()
@@ -112,13 +238,13 @@ jobs:
name: integration-json-report
path: frontend/report.json
overwrite: true
if-no-files-found: ignore
retention-days: 30
- name: Upload test result
- name: Upload HTML report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
name: integration-html-report
path: frontend/playwright-report/
overwrite: true
retention-days: 3
retention-days: 7
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
test-mcp:
if: ${{ !github.event.pull_request.draft }}
name: "Test MCP"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container: penpotapp/devenv:latest
steps:
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
@@ -95,7 +95,7 @@ jobs:
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-runner-02
# runs-on: penpot-extended-runner
# container:
# image: penpotapp/devenv:latest
#
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -1,5 +1,5 @@
---
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch — loads and follows the implement-plan skill
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch, or commits on the current branch with "direct" — loads and follows the implement-plan skill
agent: build
---
+4
View File
@@ -78,6 +78,10 @@ module. You can read it from `mem:<MODULE>/core`
workspaces (root, modules, member packages). Keeps the shared pnpm store
at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and
`.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
- `scripts/ci` — CI orchestration script: runs lint, tests, and format
checks per module (`frontend backend common render-wasm exporter mcp
plugins library`). Logs go to `.ci-logs/`; read the log file on failure.
See `mem:scripts/ci`.
# Dependency graph
+61
View File
@@ -0,0 +1,61 @@
# CI (scripts/ci)
`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
monorepo modules and prints a per-task summary. It is the local equivalent
of CI; use it to verify changes before declaring work done.
## When to use
- After implementing or fixing code in a module: run its checks before
finishing (AGENTS.md: run the applicable lint and format checks).
- When `common/` changed: validate its consumers too (frontend, backend,
exporter; see the dependency graph in `mem:critical-info`).
- To fix formatting across a module (`--fix`) or repair delimiters
(`--paren-repair`) before linting.
## How to use (CLI)
Run from the repo root:
```bash
./scripts/ci MODULE... # lint + test + fmt per module
./scripts/ci --all --no-test # lint + fmt on all modules
./scripts/ci --lint frontend # lint only
./scripts/ci --fix --no-test frontend # format files, skip tests
./scripts/ci --paren-repair --all # fix delimiters in all Clojure modules
./scripts/ci --dry-run --all # preview what would run
```
Modules: `frontend backend common render-wasm exporter mcp plugins library`.
Flags:
- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
instead).
- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
`--no-test` / `--no-fmt` drop one task from the default set.
- `--paren-repair` runs only the delimiter repair — it wraps
`scripts/paren-repair` over each module's Clojure/CLJS sources; see
`mem:scripts/paren-repair`.
- `--all` selects every module; `--exclude MOD` drops one (repeatable).
- `--fail-fast` stops at the first failure; `--quiet` suppresses failure
output; `--dry-run` prints commands without running; `--clean` removes
the log directory.
## Logs and exit codes
- Full output of every task: `.ci-logs/<module>-<task>.log`.
- On failure the script prints the last 30 lines; the final summary lists
every failed `module:task` with its log path.
- Exit code 0 when all selected tasks passed, 1 otherwise.
- Diagnose failures by reading the log file — never pipe test output
through filters (AGENTS.md hard rule).
## Notes
- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
`./test`, and `cargo fmt`.
- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
generous timeout when calling it from an agent shell.
- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
- Testing principles and output discipline: `mem:testing`.
+6 -4
View File
@@ -52,10 +52,12 @@ file (never pipe tool output through filters).
then re-run `corepack use pnpm@<tag>` in that directory.
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
a placeholder scaffold into its `pnpm-workspace.yaml`:
`allowBuilds: esbuild: set this to true or false` plus
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
then re-run.
`allowBuilds: esbuild: set this to true or false`. Current pnpm writes
only the `allowBuilds` placeholder; any legacy key still present
(`ignoredBuiltDependencies`, `onlyBuiltDependencies`,
`neverBuiltDependencies`) is ignored since pnpm 11. Repo convention is
`allowBuilds: esbuild: true`. Replace the placeholder and drop the
legacy entry, then re-run.
- `plugins/apps/composable-test-suite` once had its own
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
+4 -1
View File
@@ -14,6 +14,9 @@
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
This prevents hiding test failures. See `mem:testing` for details.
- **`.claude/skills` is a symlink to `.agents/skills`.**
Edit skills only in their canonical location (`.agents/skills`); never edit
through `.claude/skills`.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
@@ -145,6 +148,6 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
+21
View File
@@ -2,6 +2,12 @@
## 2.18.0 (Unreleased)
### :rocket: Epics and highlights
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
@@ -103,6 +109,21 @@
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
- Fix missing text in legacy SVG board thumbnails [#10182](https://github.com/penpot/penpot/issues/10182) (PR: [#11552](https://github.com/penpot/penpot/pull/11552))
- Fix workspace crash when applying transform modifiers in the WASM renderer [#10894](https://github.com/penpot/penpot/issues/10894) (PR: [#10896](https://github.com/penpot/penpot/pull/10896))
- Limit ZIP entry count and object size on V3 binfile import [#11021](https://github.com/penpot/penpot/issues/11021) (PR: [#11022](https://github.com/penpot/penpot/pull/11022))
- Block plugin UI iframe URLs targeting the Penpot domain [#11271](https://github.com/penpot/penpot/issues/11271) (PR: [#11273](https://github.com/penpot/penpot/pull/11273))
- Restrict the MCP REPL code execution endpoint to development environments [#11283](https://github.com/penpot/penpot/issues/11283) (PR: [#11282](https://github.com/penpot/penpot/pull/11282))
- Filter share-link tokens from the get-view-only-bundle response [#11285](https://github.com/penpot/penpot/issues/11285) (PR: [#11286](https://github.com/penpot/penpot/pull/11286))
- Disable MCP developer tools in multi-user mode [#11291](https://github.com/penpot/penpot/issues/11291) (PR: [#11310](https://github.com/penpot/penpot/pull/11310))
- Fix Hide comments setting being ignored after opening the Comments section [#11308](https://github.com/penpot/penpot/issues/11308) (PR: [#11492](https://github.com/penpot/penpot/pull/11492))
- Block NAT64/6to4/Teredo IPv6 transition addresses in the SSRF guard [#11319](https://github.com/penpot/penpot/issues/11319) (PR: [#11320](https://github.com/penpot/penpot/pull/11320))
- Prevent team admins from removing the team owner [#11367](https://github.com/penpot/penpot/issues/11367) (PR: [#11368](https://github.com/penpot/penpot/pull/11368))
- Enforce share-link comment permissions and page scope [#11370](https://github.com/penpot/penpot/issues/11370) (PR: [#11371](https://github.com/penpot/penpot/pull/11371))
- Clean up orphaned teams, projects and files on profile deletion [#11394](https://github.com/penpot/penpot/issues/11394) (PR: [#11395](https://github.com/penpot/penpot/pull/11395))
- Fix crash when pressing Ctrl+D with no shape selected [#11448](https://github.com/penpot/penpot/issues/11448) (PR: [#11491](https://github.com/penpot/penpot/pull/11491))
- Fix text layout not updating when auto-width is set by double-clicking the bounding box [#11480](https://github.com/penpot/penpot/issues/11480) (PR: [#11541](https://github.com/penpot/penpot/pull/11541))
- Fix boolean shapes rendering deformed in the WASM renderer and exports [#11482](https://github.com/penpot/penpot/issues/11482) (PR: [#11551](https://github.com/penpot/penpot/pull/11551))
### :sparkles: New features & Enhancements
+7
View File
@@ -56,6 +56,7 @@ If your organization is scaling and needs extra support, were here to help. [
- [Why Penpot](#why-penpot)
- [Getting Started](#getting-started)
- [Penpot Enterprise](#penpot-enterprise)
- [Community](#community)
- [Contributing](#contributing)
- [Resources](#resources)
@@ -93,6 +94,12 @@ Penpot is the only design & prototype platform that is deployment agnostic. You
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
<img width="100%" height="1010" alt="2" src="https://github.com/user-attachments/assets/243e796e-a140-481a-b68f-b24be6a70e37" />
## Penpot Enterprise ##
Penpot Enterprise is our paid plan for organizations that need to scale their design work across multiple teams with advanced governance, security, and administration. Manage teams and access from a centralized **Admin Console**, configure advanced permissions, and connect your **identity provider through SSO**. Available for cloud and self-hosted environments, it combines enterprise controls with Penpots open-source foundation and open standards.
## Community ##
We love the Open Source software community. Contributing is our passion and if its yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
+1 -1
View File
@@ -27,7 +27,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
disable-login-with-ldap \
enable-login-with-ldap \
disable-login-with-oidc \
disable-login-with-google \
disable-login-with-github \
+24 -6
View File
@@ -10,7 +10,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[clj-ldap.client :as ldap]
[clojure.string]
[cuerdas.core :as str]
[integrant.core :as ig]))
(defn- prepare-params
@@ -36,11 +36,22 @@
:cause cause))))
(defn- replace-several [s & {:as replacements}]
(reduce-kv clojure.string/replace s replacements))
(reduce-kv str/replace s replacements))
(defn- escape-ldap-filter-value
"Escapes special characters in a string for use in LDAP filter values,
per RFC 4515 section 3."
[s]
(-> s
(str/replace "\\" "\\5c")
(str/replace "*" "\\2a")
(str/replace "(" "\\28")
(str/replace ")" "\\29")
(str/replace "\u0000" "\\00")))
(defn- search-user
[{:keys [::conn base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" email)
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]
@@ -49,12 +60,19 @@
:attributes attrs}]
(first (ldap/search conn base-dn params))))
(defn- get-attr
"Retrieves an attribute from an LDAP entry. Handles multi-valued
attributes by returning the first value."
[entry attr-key]
(let [v (get entry attr-key)]
(if (coll? v) (first v) v)))
(defn- retrieve-user
[{:keys [::conn] :as cfg} {:keys [email password]}]
(when-let [{:keys [dn] :as user} (search-user cfg email)]
(when (ldap/bind? conn dn password)
{:fullname (get user (-> cfg :attrs-fullname keyword))
:email email
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
:email (get-attr user (-> cfg :attrs-email keyword))
:backend "ldap"})))
(def ^:private schema:info-data
@@ -79,7 +97,7 @@
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
(ex/raise :type :restriction
:code :wrong-ldap-response
:explain explain)))
::sm/explain explain)))
user)))
(defn- try-connectivity
+8 -3
View File
@@ -15,6 +15,7 @@
(:require
[app.common.schema :as sm]
[app.util.ssrf :as ssrf]
[app.worker :as-alias wrk]
[cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
@@ -23,6 +24,8 @@
java.net.URI))
(def default-max-redirects 5)
(def default-connect-timeout 30000)
(def default-request-timeout 30000)
(defn client?
[o]
@@ -33,15 +36,17 @@
:pred client?})
(defmethod ig/init-key ::client
[_ _]
(http/build-client {:connect-timeout 30000
[_ {:keys [::wrk/executor]}]
(http/build-client {:connect-timeout default-connect-timeout
:executor executor
:follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client")
(http/send req {:client client :as response-type})))
(http/send (merge {:timeout default-request-timeout} req)
{:client client :as response-type})))
(defn- resolve-client
[params]
+7 -1
View File
@@ -60,7 +60,13 @@
(defmethod handle-error :restriction
[err request _]
(let [{:keys [code] :as data} (ex-data err)]
(let [data (ex-data err)
code (get data :code)
explain (ex/explain data)
data (-> data
(dissoc ::sm/explain)
(cond-> explain (assoc :explain explain)))]
(if (= code :method-not-allowed)
{::yres/status 405
::yres/body data}
+1 -1
View File
@@ -200,7 +200,7 @@
{::db/pool (ig/ref ::db/pool)}
::http.client/client
{}
{::wrk/executor (ig/ref ::wrk/executor)}
::session/manager
{::db/pool (ig/ref ::db/pool)}
+3 -3
View File
@@ -75,10 +75,10 @@
{:method method
:uri uri
:body body
:headers headers}
:headers headers
:timeout timeout}
{:response-type :input-stream
:skip-ssrf-check? true
:timeout timeout})
:skip-ssrf-check? true})
status (:status resp)]
(when (not (<= 200 status 299))
(let [body (:body resp)]
+14 -6
View File
@@ -390,18 +390,26 @@
(def ^:private sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT owner_id AS id
FROM comment
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
SELECT DISTINCT c.owner_id AS id
FROM comment c
JOIN comment_thread ct
ON ct.id = c.thread_id
WHERE ct.file_id = ?::uuid
),
profile_ids AS (
SELECT id FROM available_profiles
UNION
SELECT ?::uuid
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname AS fullname,
p.fullname,
p.photo_id,
p.is_active
FROM profile AS p
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
FROM profile p
JOIN profile_ids AS x
ON x.id = p.id;")
(defn get-file-comments-users
[conn file-id profile-id]
+25 -56
View File
@@ -10,7 +10,6 @@
[app.auth :refer [derive-password-weak]]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
@@ -26,35 +25,7 @@
(def ^:private
schema:create-demo-profile
[:map
[:skip-onboarding {:optional true} ::sm/boolean]
[:expires-in {:optional true} ::ct/duration]])
(def ^:private min-expires-in
(ct/duration "5m"))
(defn- resolve-deletion-delay
"Resolve the effective `:demo-purge` delay for a demo profile. Without
`expires-in` it falls back to the global deletion delay. Otherwise the
value is only allowed to shorten the lifetime: below the 5 minutes
minimum or above the global delay it raises a validation error."
[expires-in]
(let [max-delay (cf/get-deletion-delay)]
(cond
(nil? expires-in)
max-delay
(ct/is-before? expires-in min-expires-in)
(ex/raise :type :validation
:code :invalid-expires-in
:hint "expires-in is below the 5 minutes minimum.")
(ct/is-after? expires-in max-delay)
(ex/raise :type :validation
:code :invalid-expires-in
:hint "expires-in exceeds the configured deletion delay.")
:else
expires-in)))
[:skip-onboarding {:optional true} ::sm/boolean]])
(sv/defmethod ::create-demo-profile
"A command that is responsible of creating a demo purpose
@@ -63,44 +34,42 @@
{::rpc/auth false
::doc/added "1.15"
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]
["2.18" "Add optional `expires-in` param. When set, the demo purge is scheduled that long after creation instead of the global deletion delay. Only values between 5 minutes and the global delay are accepted."]]
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]]
::sm/params schema:create-demo-profile}
[cfg {:keys [skip-onboarding expires-in]}]
[cfg {:keys [skip-onboarding]}]
(when-not (contains? cf/flags :demo-users)
(ex/raise :type :validation
:code :demo-users-not-allowed
:hint "Demo users are disabled by config."))
(let [deletion-delay (resolve-deletion-delay expires-in)
sem (uuid/next)
email (str "demo-" sem "@demo.example.com")
fullname (str "Demo User " sem)
(let [sem (uuid/next)
email (str "demo-" sem "@demo.example.com")
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
(bc/bytes->b64 true)
(bc/bytes->str))
password (-> (bn/random-bytes 16)
(bc/bytes->b64 true)
(bc/bytes->str))
params {:email email
:fullname fullname
:is-active true
:is-demo true
:password (derive-password-weak password)
:props (cond-> {}
skip-onboarding (assoc :onboarding-viewed true
;; Redundant today: auth/create-profile
;; overwrites this with the current
;; version, kept so the skip does not
;; depend on that default.
:release-notes-viewed (:main cf/version)))}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
params {:email email
:fullname fullname
:is-active true
:is-demo true
:password (derive-password-weak password)
:props (cond-> {}
skip-onboarding (assoc :onboarding-viewed true
;; Redundant today: auth/create-profile
;; overwrites this with the current
;; version, kept so the skip does not
;; depend on that default.
:release-notes-viewed (:main cf/version)))}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
(wrk/submit! (-> cfg
(assoc ::wrk/task :demo-purge)
(assoc ::wrk/delay deletion-delay)
(assoc ::wrk/delay (cf/get-deletion-delay))
(assoc ::wrk/params {:profile-id (:id profile)})))
(with-meta {:email email
+8 -2
View File
@@ -41,6 +41,7 @@
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[cuerdas.core :as str]))
@@ -960,13 +961,18 @@ RETURNING id, deleted_at;")
(sv/defmethod ::check-organization-sso
"Validate an organization SSO configuration by generating a login redirect URL.
Nitrate calls this while configuring SSO to verify client credentials and OIDC
discovery before saving the settings."
discovery before saving the settings. The issuer URL is nitrate-supplied
(customer-configured), so it is checked against the SSRF blocklist before
any outbound request is attempted."
{::doc/added "2.18"
::sm/params cto/schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
[cfg params]
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
(let [issuer (oidc/organization-sso-discovery-uri params)]
{:valid (boolean (and issuer
(ssrf/safe-url? issuer)
(oidc/is-organization-sso-config-valid? cfg params)))}))
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
+4 -9
View File
@@ -17,7 +17,6 @@
[app.http.client :as http]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.util.blob :as blob]
[app.util.json :as json]
[integrant.core :as ig]
[promesa.exec :as px]))
@@ -248,20 +247,16 @@
:props (or (some-> props db/decode-transit-pgobject) {})
:context (or (some-> context db/decode-transit-pgobject) {})}))
(defn- encode-batch
"Encode a sequence of event maps into a fressian+zstd base64 string
suitable for JSON transport."
^String [events]
(blob/encode-str events {:version 4}))
(defn send-event-batch
"Send a single batch of events to the telemetry endpoint. Returns
true on success."
true on success. The events are sent as a plain vector of event
maps; the JSON encoder handles UUID and temporal types natively and
the receiver coerces them back to proper types."
[{:keys [::setup/props] :as cfg} batch]
(let [payload {:type :telemetry-events
:version (:full cf/version)
:instance-id (:instance-id props)
:events (encode-batch batch)}
:events (vec batch)}
request {:method :post
:uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
@@ -0,0 +1,76 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.auth-ldap-test
(:require
[app.auth.ldap :as ldap-auth]
[clj-ldap.client :as ldap]
[clojure.test :as t]))
;; --- search-user: filter must be escaped (RED: currently not escaped)
(t/deftest search-user-escapes-email-in-filter
(t/testing "wildcard * is escaped before building LDAP filter"
(let [captured-query (atom nil)
fake-search (fn [_conn _base-dn params]
(reset! captured-query (:filter params))
[])]
(with-redefs [ldap/search fake-search]
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
"fry*@planetexpress.com"))
;; After fix: * should be escaped as \2a
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
"filter must have * escaped per RFC 4515"))))
;; --- retrieve-user: email must come from directory, not client (RED)
(t/deftest retrieve-user-uses-directory-email
(t/testing "returned email is from LDAP directory, not client input"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
:mail "fry@planetexpress.com"
:cn "Philip J. Fry"
:uid "fry"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
;; After fix: email should be from directory (fry@planetexpress.com)
;; BUG: email is client input (fry*@planetexpress.com)
(t/is (= "fry@planetexpress.com" (:email result))
"email must come from LDAP directory attribute, not client input"))))))
;; --- authenticate: full flow with directory email (RED)
(t/deftest authenticate-returns-directory-email
(t/testing "authenticate returns directory email for profile"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
:mail "amy@planetexpress.com"
:cn "Amy Wong"
:uid "amy"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"
:bind-dn "cn=admin,dc=planetexpress,dc=com"
:bind-password "GoodNewsEveryone"
:host "localhost" :port 10389
:ssl false :tls false
:base-dn "ou=people,dc=planetexpress,dc=com"}
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
;; After fix: email should be amy@planetexpress.com (directory)
;; BUG: email is *@planetexpress.com (client)
(t/is (= "amy@planetexpress.com" (:email result))
"authenticate must return directory email, not client-supplied wildcard"))))))
@@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.http-client-test
(:require
[app.http.client :as http]
[clojure.test :as t]
[java-http-clj.core :as jhttp]
[mockery.core :refer [with-mocks]]))
(t/deftest send-injects-default-timeout-when-absent
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get :uri "https://example.com/"})
(let [[req _opts] (:call-args @mock)]
(t/is (= http/default-request-timeout (:timeout req)))))))
(t/deftest send-preserves-caller-supplied-timeout
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get
:uri "https://example.com/"
:timeout 5000})
(let [[req _opts] (:call-args @mock)]
(t/is (= 5000 (:timeout req)))))))
@@ -8,6 +8,7 @@
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.http.client :as http]
[app.media.remote :as media.remote]
[app.setup :as-alias setup]
[app.util.json :as json]
@@ -500,6 +501,22 @@
:headers {}})]
(t/is (= 200 (:status resp))))))))
(t/deftest service-request-puts-configured-timeout-in-request
(t/testing "service-request puts media-processing-service-timeout on the http request"
(let [captured (atom nil)]
(with-redefs [cf/get (th/config-get-mock config-mock)
http/req (fn [_client request _opts]
(reset! captured request)
{:status 200
:body (json-stream {:width 100 :height 100})})]
(media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}})
(t/is (= 5000 (:timeout @captured)))))))
;; ---------------------------------------------------------------------------
;; Shared key
;; ---------------------------------------------------------------------------
@@ -7,10 +7,8 @@
(ns backend-tests.rpc-demo-test
(:require
[app.auth :as auth]
[app.common.time :as ct]
[app.config :as cf]
[app.rpc.commands.profile :as profile]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.test :as t]))
@@ -76,48 +74,3 @@
:skip-onboarding "yes"})]
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation)))))
(t/deftest create-demo-profile-uses-global-delay-by-default
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [captured (atom nil)]
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}]
(reset! captured {:task task :delay delay}))]
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})]
(t/is (nil? error))
(t/is (some? (:email result)))
(t/is (= :demo-purge (:task @captured)))
(t/is (= (cf/get-deletion-delay) (:delay @captured))))))))
(t/deftest create-demo-profile-accepts-short-expires-in
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [captured (atom nil)]
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/delay]}]
(reset! captured {:task task :delay delay}))]
(let [{:keys [error result]} (th/command! {::th/type :create-demo-profile
:expires-in "10m"})]
(t/is (nil? error))
(t/is (some? (:email result)))
(t/is (= :demo-purge (:task @captured)))
(t/is (= (ct/duration "10m") (:delay @captured))))))))
(t/deftest create-demo-profile-rejects-expires-in-below-minimum
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
:expires-in "1m"})]
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :invalid-expires-in)))))
(t/deftest create-demo-profile-rejects-expires-in-above-global-delay
(with-redefs [cf/flags (conj cf/flags :demo-users)
cf/get-deletion-delay (fn [] (ct/duration {:days 7}))]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
:expires-in "200h"})]
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :invalid-expires-in)))))
(t/deftest create-demo-profile-rejects-non-duration-expires-in
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile
:expires-in "yes"})]
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :params-validation)))))
@@ -17,6 +17,7 @@
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.set :as set]
@@ -1806,13 +1807,14 @@
(t/deftest check-organization-sso-returns-valid-true
(let [organization-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
@@ -1827,19 +1829,36 @@
(t/deftest check-organization-sso-passes-issuer-to-validation
(let [organization-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
(let [called? (atom false)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg _sso] (reset! called? true) true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id (uuid/random)
:client-id "test-client"
:client-secret "test-secret"
:issuer "http://127.0.0.1/idp"}))]
(t/is (th/success? out))
(t/is (false? (-> out :result :valid)))
(t/is (false? @called?)
"OIDC validation should not run when the issuer is SSRF-blocked"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUSH AUDIT EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -12,7 +12,6 @@
[app.db :as db]
[app.loggers.audit :as audit]
[app.tasks.telemetry :as telemetry]
[app.util.blob :as blob]
[app.util.json :as json]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -59,11 +58,6 @@
:cnt
long))
(defn- decode-event-batch
"Decode the base64+fressian+zstd event-batch sent to the mock."
[b64-str]
(blob/decode-str b64-str))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; STATS / REPORT STRUCTURE TESTS (existing behaviour, extended)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -245,21 +239,19 @@
(t/is (not (contains? ev :ip-addr)))))))))
(t/deftest test-batch-encoding-is-decodable
;; Verify that encode-batch produces a blob that round-trips back
;; through blob/decode to the original data.
;; Events are sent as a plain vector of raw event maps (no blob
;; encoding): every batch must JSON round-trip unchanged, because
;; the receiver coerces types from the plain JSON representation.
(let [events [{:name "navigate" :type "action" :source "telemetry"
:tracked-at (ct/now)}
{:name "create-file" :type "action" :source "telemetry"
:tracked-at (ct/now)}]
;; Call the private fn through the ns-mapped var
encode (ns-resolve 'app.tasks.telemetry 'encode-batch)
encoded (encode events)
decoded (decode-event-batch encoded)]
(t/is (string? encoded))
(t/is (seq decoded))
(t/is (= (count events) (count decoded)))
(t/is (= "navigate" (:name (first decoded))))
(t/is (= "create-file" (:name (second decoded))))))
encoded (json/encode-str {:events (vec events)})
decoded (json/decode encoded)]
(t/is (vector? (:events decoded)))
(t/is (= (count events) (count (:events decoded))))
(t/is (= "navigate" (:name (first (:events decoded)))))
(t/is (= "create-file" (:name (second (:events decoded)))))))
(t/deftest test-multiple-batches-when-many-events
;; Lower batch-size to 1 so that 3 events produce 3 separate
@@ -787,9 +779,13 @@
(t/is (= "telemetry-events" (name (:type body))))
(t/is (string? (:version body)))
(t/is (some? (:instance-id body)))
;; :events is a base64-encoded blob
(t/is (string? (:events body)))
(t/is (pos? (count (:events body))))))))))
;; :events is a plain vector of raw event maps
(t/is (vector? (:events body)))
(t/is (pos? (count (:events body))))
(doseq [ev (:events body)]
(t/is (string? (:name ev)))
(t/is (string? (:source ev)))
(t/is (string? (:tracked-at ev))))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK BRANCH COVERAGE
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { rpcPost, extractCookie } from "./helpers/client.mjs";
async function loginWithLdap(email, password) {
const res = await rpcPost("login-with-ldap", { email, password });
if (res.status !== 200 || res.body.type) {
throw new Error(
`LDAP login failed: ${JSON.stringify(res.body)}`
);
}
const cookie = extractCookie(res.setCookie);
return { profile: res.body, cookie };
}
describe("LDAP injection — T5-N1-03", () => {
it("normal LDAP login works with valid credentials", async () => {
const { profile, cookie } = await loginWithLdap(
"fry@planetexpress.com",
"fry"
);
assert.equal(profile.email, "fry@planetexpress.com");
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
});
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
// ATTACK SCENARIO (from Criptored audit):
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
// 4. Bind succeeds: amy's DN + amy's password = valid
//
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
//
// Either outcome is correct — the vulnerability is fixed.
try {
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
// Outcome B: login succeeded, verify email is from directory
assert.equal(
profile.email,
"amy@planetexpress.com",
"email must come from LDAP directory, not client input"
);
} catch (e) {
// Outcome A: injection blocked — * is escaped, no LDAP match
assert.ok(
e.message.includes("wrong-credentials"),
"wildcard should be rejected or return directory email"
);
}
});
it("identity swap: alternate email must return primary directory email", async () => {
// Professor has two emails in LDAP: professor@ and hubert@.
// Login with hubert@ — the profile email should be the one
// the LDAP directory returns as attrs-email, not what the client typed.
//
// EXPECTED BEHAVIOR AFTER FIX:
// Profile email should be "professor@planetexpress.com" (primary directory email),
// NOT "hubert@planetexpress.com" (client literal).
//
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
const { profile, cookie } = await loginWithLdap(
"hubert@planetexpress.com",
"professor"
);
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
// This assertion FAILS with current code (RED) — proves the vulnerability
assert.equal(
profile.email,
"professor@planetexpress.com",
"email must come from LDAP directory, not client input"
);
});
it("wrong password fails", async () => {
try {
await loginWithLdap("fry@planetexpress.com", "wrong-password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail with wrong credentials"
);
}
});
it("non-existent user fails", async () => {
try {
await loginWithLdap("nobody@planetexpress.com", "password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail for non-existent user"
);
}
});
});
+1 -7
View File
@@ -175,14 +175,8 @@
#?(:clj
(defn parse-duration
"Parse a value into a Duration. Total: returns the input unchanged
when it cannot be parsed, so schema decoding never throws and
invalid values fail validation with a clean params error instead."
[s]
(try
(duration s)
(catch Exception _
s))))
(duration s)))
#?(:clj
(defn format-duration
-14
View File
@@ -14,17 +14,3 @@
dtb (dt/inst 20000)]
(t/is (false? (dt/is-after? dta dtb)))
(t/is (true? (dt/is-before? dta dtb)))))
#?(:clj
(t/deftest parse-duration-test
(t/is (dt/duration? (dt/parse-duration "10m")))
(t/is (= (dt/duration "10m") (dt/parse-duration "10m")))
(t/is (= (dt/duration "1h") (dt/parse-duration "1h")))
;; Invalid values are returned unchanged instead of throwing, so
;; they fail the `duration?` schema predicate with a clean
;; validation error downstream.
(t/is (= "yes" (dt/parse-duration "yes")))
(t/is (not (dt/duration? (dt/parse-duration "yes"))))
(t/is (= true (dt/parse-duration true)))
(t/is (not (dt/duration? (dt/parse-duration true))))))
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Comments
order: 4
order: 6
desc: Learn how to import and export files in Penpot, the free, open-source design tool. Discover file formats, backups, sharing, and library management.
---
@@ -0,0 +1,35 @@
---
title: Enterprise plan
order: 2
desc: Learn how the Enterprise plan works in Penpot. Discover its features and how to use it within your organization.
---
<h1 id="enterprise">Enterprise plan</h1>
<p class="main-paragraph">Penpot Enterprise gives organizations the tools to govern how design work happens across their teams: from creating a structured org and managing members, to applying fine-grained permissions and configuration through the Admin Console.</p>
<h3 id="what-is-penpot-enterprise">What is Penpot Enterprise?
</h3>
<p>Penpot Enterprise is the plan that unlocks organizational governance features. While Penpot remains free and unlimited as an open-source platform, Enterprise adds a layer of control on top: the ability to create Organizations, manage teams under it, and apply configuration settings that define what members can and cannot do.</p>
<p>The organization owner is the user who creates the organization. They have exclusive access to the Admin Console and are responsible for configuring Modules.</p>
<p>The key concepts you'll work with:</p>
<ul>
<li><strong><a href="/user-guide/account-teams/organizations/">Organization</a>:</strong> the top-level structure that groups one or more Teams under a shared governance layer.</li>
<li><strong><a href="/user-guide/account-teams/organizations/#the-admin-console">Admin Console</a>:</strong> the back-office interface where the org owner manages settings, teams and members.</li>
<li><strong><a href="/user-guide/account-teams/organizations/#modules-and-controls">Modules</a>:</strong> the paid, configurable capabilities applied to an organization. Each Module consists of individual Controls (specific settings or restrictions).</li>
</ul>
<h3 id="subscribing-to-enterprise">Subscribing to Enterprise
<a class="direct-link" href="#subscribing-to-enterprise">#</a>
</h3>
<p>To create an organization, you first need to upgrade to the <a href="https://penpot.app/pricing" target="_blank">Enterprise plan</a>. Click "Create Organization" to begin. Once the subscription process is complete, you'll be redirected to the Admin Console to finish creating your organization.</p>
<h3 id="frequently-asked-questions"> Frequently asked questions
<a class="direct-link" href="#frequently-asked-questions">#</a>
</h3>
<h4>Can I have multiple organizations?</h4>
<p>Yes. You can create more than one organization under a single Enterprise subscription and manage them from the Admin Console.</p>
<h4>What happens if I cancel my Enterprise subscription?</h4>
<p>Your organizations are deleted but not their teams. Governance settings no longer apply to any of them.</p>
+13 -1
View File
@@ -13,7 +13,19 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Access your account settings and manage personal access tokens</p>
</a>
</li>
<li>
<li>
<a href="/user-guide/account-teams/enterprise-plan/">
<h2>Enterprise plan →</h2>
<p>Learn how the Enterprise plan works and what it includes</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/organizations/">
<h2>Organizations →</h2>
<p>Create and manage organizations, the Admin Console, and SSO</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/teams">
<h2>Teams →</h2>
<p>Create and manage your teams</p>
@@ -0,0 +1,227 @@
---
title: Organizations
order: 3
desc: Learn how Organizations work in Penpot Enterprise, creating one, the Admin Console, membership, Modules and Controls, and Single Sign-On (SSO).
---
<h1 id="organizations">Organizations</h1>
<p class="main-paragraph">An organization is the governance layer Penpot Enterprise adds on top of your teams. This section covers how to create and manage one: the Admin Console, membership, and the Modules and Controls used to configure it, including Single Sign-On (SSO).</p>
<h3 id="creating-an-organization">Creating an organization
<a class="direct-link" href="#creating-an-organization">#</a>
</h3>
<p>Creating an organization is as easy as giving it a name.</p>
<p>Once the organization is created, you'll be taken to the Admin Console. At this point, the organization has one member: you, the owner.</p>
<h3 id="the-admin-console">The Admin Console
<a class="direct-link" href="#the-admin-console">#</a>
</h3>
<p>The Admin Console is the admin interface for your organization. Only the organization owner can access it.</p>
<p>You can reach the Admin Console directly at <code>/admin-console</code>, or from any team dashboard in Penpot by opening the organization navigation menu and clicking the Admin Console link. If you own more than one organization, you can switch between them from within the Admin Console.</p>
<figure>
<a href="/img/enterprise/enterprise-access-admin-console.webp" target="_blank">
<img alt="Accessing the Admin Console" src="/img/enterprise/enterprise-access-admin-console.webp"/>
</a>
</figure>
<p>The Admin Console includes:</p>
<ol>
<li><strong>Switch organization menu: </strong> To navigate between the organizations you own.</li>
<li><strong>Organization settings: </strong> To change basic settings, such as renaming it or deleting it.</li>
<li><strong>A "Go to files" button </strong> that returns you to a team dashboard.</li>
<li><strong>Your user avatar</strong>, that expands into a full user menu.</li>
<li><strong>Module: </strong> The control itself to configure.</li>
</ol>
<figure>
<a href="/img/enterprise/enterprise-admin-console.webp" target="_blank">
<img alt="Admin Console" src="/img/enterprise/enterprise-admin-console.webp"/>
</a>
</figure>
<h3 id="organizations-and-teams">Organizations and teams
<a class="direct-link" href="#organizations-and-teams">#</a>
</h3>
<p>An organization groups one or more Teams under a shared governance structure. Teams continue to work just as they do in standard Penpot, with the added layer that the org owner can apply configuration that affects all members across the organization's teams.</p>
<h4>How teams relate to organizations</h4>
<p>Teams inside an organization inherit the governance settings applied at the org level via the Admin Console. The structure is:</p>
<figure>
<img alt="organization-hierarchy" src="/img/enterprise/enterprise-organization-hierarchy-w.webp"/>
</figure>
<p>Team members work within projects and files as usual. What changes under Enterprise is the org owner's ability to restrict or govern that work from the Admin Console.</p>
<p>Within each team, the <a href="https://help.penpot.app/user-guide/account-teams/teams/#teams-members" target="_blank">standard Penpot roles</a> apply.</p>
<h3 id="managing-organization-membership">Managing organization membership
<a class="direct-link" href="#managing-organization-membership">#</a>
</h3>
<p>At launch, an organization has a single member: the owner. Additional members are brought in by being part of a team added to the organization, being invited to teams within the organization, or being directly invited to the organization by the owner.</p>
<p>The Admin Console provides a unified view of all members across teams within the organization.</p>
<figure>
<a href="/img/enterprise/enterprise-membership-w.webp" target="_blank">
<img alt="Admin-Console Members page" src="/img/enterprise/enterprise-membership-w.webp"/>
</a>
</figure>
<h3 id="modules-and-controls">Modules and Controls
<a class="direct-link" href="#modules-and-controls">#</a>
</h3>
<p>Modules are the configurable governance capabilities available to Enterprise organizations. Each Module is made up of one or more Controls: the individual settings that define who can do what, and where.</p>
<p>Modules are configured from the Admin Console and apply organization-wide. </p>
<table cellspacing="0" cellpadding="1" border="1" width="100%">
<thead>
<tr>
<th valign="top" class="cellrowborder">Modules</th>
<th valign="top" class="cellrowborder" style="text-align: center;">What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td>Single Sign-On (SSO)</td>
<td>Requires all org members to authenticate through your corporate identity provider before accessing the organization's teams and files.</td>
</tr>
<tr>
<td>Advanced permissions</td>
<td>Defines who can create, view, edit, administer, or share teams, projects, and files. Also controls who can invite new members to teams.</td>
</tr>
</tbody>
</table>
<h3 id="module-advanced-permissions">Module: Advanced permissions
<a class="direct-link" href="#module-advanced-permissions">#</a>
</h3>
<p>Advanced Permissions gives the organization owner fine-grained control over what members can do across all teams in the organization. Rather than relying on the default Penpot team roles alone, this module lets you restrict or open up specific actions at the organization level.</p>
<h4>What it controls</h4>
<p>The Advanced Permissions module is made up of individual Controls. Each Control governs a specific action, and each has a set of options to choose from. The selected option becomes the rule for the entire organization.</p>
<h4>How to configure it</h4>
<p>Advanced Permissions is configured from the Admin Console. Changes apply to all teams within the organization immediately.</p>
<ol>
<li>Open the Admin Console.</li>
<li>Select Advanced Permissions from the left sidebar.</li>
<li>For each Control, select the option that fits your governance policy.</li>
<li>Changes take effect right away. There is no publish or save step.</li>
</ol>
<h4>How it relates to team roles</h4>
<p>Advanced Permissions works on top of the standard Penpot team roles (Viewer, Editor, Admin, Owner). It does not replace them. Think of it as a ceiling: even if a member's team role would normally allow an action, an Advanced Permissions Control can prevent it organization-wide.</p>
<p>For example, if “New team members” is set to "Organization members only," a team owner who would normally be able to invite anyone will find that option restricted to people who are already part of the organization.</p>
<h4>Default behavior</h4>
<p>When an organization is first created, all controls are configured with the most permissive setting; the same setting is used by all teams that are not part of any organization. No behavior changes until you actively configure a Control.</p>
<h3 id="module-single-sign-on">Module: Single Sign-On (SSO)
<a class="direct-link" href="#module-single-sign-on">#</a>
</h3>
<p>Single Sign-On lets you require all members of your organization to authenticate through your corporate identity provider (IdP) before accessing any of the organization's teams and files.</p>
<p>SSO applies to teams and files only. The Admin Console is always accessible without SSO, so you can always reach your configuration to adjust or deactivate it, even if your own directory entry changes.</p>
<h4>Configuring your identity provider</h4>
<p>Before setting up SSO in Penpot, you need to register Penpot as an application in your identity provider. The steps vary by provider, but you will always need to set the following <strong>callback URL</strong> in your IdP configuration:</p>
<p><strong><code>https://&lt;your-penpot-domain&gt;/api/auth/oidc/callback</code></strong></p>
<p>Your IdP will then give you a <strong>Client ID</strong> and <strong>Client Secret</strong> to use in Penpot. Once you have those:</p>
<ol>
<li>Open the Admin Console.</li>
<li>Select <strong>SSO Config</strong>.</li>
<li>Choose your provider and fill in the fields.</li>
</ol>
<figure>
<a href="/img/enterprise/enterprise-module-sso.webp" target="_blank">
<img alt="SSO configuration" src="/img/enterprise/enterprise-module-sso.webp"/>
</a>
</figure>
<h5>Generic authentication (OpenID Connect)</h5>
<p>Use this option for any identity provider that supports the OIDC protocol, such as Okta, Keycloak, or Auth0.</p>
<ul>
<li><strong>Issuer / Authority URL:</strong> base URL of your OIDC provider, used to autodiscover endpoints</li>
<li><strong>Client ID:</strong> client identifier assigned by your provider</li>
<li><strong>Client Secret:</strong> client secret assigned by your provider</li>
</ul>
<h5>Azure Active Directory (OpenID Connect)</h5>
<ul>
<li><strong>Issuer / Authority URL:</strong> <code>https://login.microsoftonline.com/&lt;your-tenant-id&gt;/v2.0/</code></li>
<li><strong>Client ID:</strong> Application (client) ID from your Azure app registration</li>
<li><strong>Client Secret:</strong> client secret value from your Azure app registration</li>
</ul>
<h5>Google (OAuth)</h5>
<ul>
<li><strong>Client ID:</strong> client identifier from your Google Cloud OAuth 2.0 credentials</li>
<li><strong>Client Secret:</strong> client secret from your Google Cloud OAuth 2.0 credentials</li>
</ul>
<p>When all fields are filled, click <strong>Activate SSO</strong>. Penpot will run a test connection against your IdP. If the connection fails, your draft is kept and no changes are applied.</p>
<p>If the test passes, a confirmation dialog will appear. It will warn you that members not in your directory will lose access to the organization's teams once SSO is active. Review your member list if needed, then confirm. SSO becomes active immediately.</p>
<h4>What happens to existing sessions</h4>
<p>When SSO is activated, any member who is currently inside one of the organization's teams is cut off immediately and sent through the SSO login. This does not log them out of Penpot entirely. They can still reach teams that do not belong to your organization without re-authenticating.</p>
<p>SSO sessions last 4 hours. When a session expires, members are routed through the SSO login again. If they are still in the directory, they are signed back in immediately.</p>
<h4>Editing an active configuration</h4>
<p>While SSO is active, you can edit any field. As soon as you make a change, <strong>Apply changes</strong> and <strong>Discard changes</strong> buttons appear. Discarding restores every field to the current live configuration. Applying runs the same test connection as the initial setup, without a confirmation dialog. If the connection fails, your live configuration is not touched. Changes may take up to 5 minutes to apply for members who are currently working in a file.</p>
<h4>Deactivating SSO</h4>
<p>Click <strong>Deactivate SSO</strong> and confirm. The configuration is preserved as a draft so you can reactivate it later without re-entering your credentials. Members are notified by email the first time SSO is activated. If you deactivate and reactivate within 24 hours, the notification is not re-sent.</p>
<h4>For your members</h4>
<p>Members do not need to do anything to prepare. When they next try to access the organization's teams, they will be asked to authenticate through your IdP. If they are already signed in through that provider, the step is skipped automatically.</p>
<p>Org membership still requires an invitation from you. Being in the directory alone does not grant access to Penpot or to your organization.</p>
<p>If a member is not in your directory, they remain an org member but cannot enter the organization's teams until they are added. A single email is sent to all current members and pending invitees when SSO is first activated, explaining what changed and who to contact if they cannot get in.</p>
<h3 id="frequently-asked-questions"> Frequently asked questions
<a class="direct-link" href="#frequently-asked-questions">#</a>
</h3>
<h4>Can a team belong to more than one organization?</h4>
<p>No. A team belongs to a single organization.</p>
<h4>Can non-owners access the Admin Console?</h4>
<p>No. Access to the Admin Console is currently exclusive to the organization owner.</p>
<h4>What is the organization name used for?</h4>
<p>It's the human-readable name used to identify your organization in the UI, in emails, and in URLs. It is not your official billing name. You can change it at any time without affecting navigation or functionality.</p>
<h4>Do Advanced Permissions replace the standard Penpot team roles?</h4>
<p>No. Advanced Permissions work on top of the existing roles (Viewer, Editor, Admin, Owner). They add an organization-wide ceiling on what any role can do, but they do not change how roles work within a team.</p>
<h4>What is the default behavior when I first create an organization?</h4>
<p>All Controls are set to their most permissive option. Nothing changes until you actively configure a Control.</p>
<h4>What happens to existing team admins if I change a Control that restricts their permissions?</h4>
<p>The restriction applies immediately. An admin who could previously perform an action will no longer be able to do so as soon as the Control is changed, with no grace period.</p>
<h4>Is this the same as the authentication providers in Penpot's self-hosting configuration?</h4>
<p>No. Penpot's self-hosted configuration lets server administrators enable login methods (Google, GitHub, GitLab, OIDC) at the instance level, so users can sign in to Penpot itself with those providers. That is a server-level setting managed by whoever runs the infrastructure.</p>
<p>The SSO module in Enterprise is different in scope and purpose. It is configured by you, the organization owner, from the Admin Console, and it governs access to your organization's teams and files specifically. It does not change how users log in to Penpot as a platform, only whether they need to pass through your corporate identity provider to reach your organization's content.</p>
<h4>Does SSO affect the Admin Console?</h4>
<p>No. The Admin Console is always accessible without SSO, regardless of your configuration. This ensures you can always reach your settings to adjust or deactivate SSO, even if something changes on the directory side.</p>
<h4>What happens if my identity provider goes down while SSO is active?</h4>
<p>Current sessions continue until they expire. The next time a member tries to authenticate through your IdP and the provider is unreachable, the login will fail. There is no automatic bypass. Since the Admin Console is outside SSO, you can still reach your configuration to deactivate SSO if needed.</p>
<h4>Can I use the same identity provider for more than one organization?</h4>
<p>Yes. Two different organizations, and the Penpot instance itself, can share the same IdP. A successful SSO login never grants org membership on its own, so there is no risk of cross-organization access. Membership always requires an explicit invitation.</p>
<h4>What if a member is not in my directory?</h4>
<p>They remain an org member and keep their Penpot account, but they cannot enter the organization's teams until they are added to the directory. We send them an email when SSO is first activated explaining the situation and telling them to contact you.</p>
<h4>Does accepting an invitation automatically give someone access to my org's teams?</h4>
<p>Only if they are also in your directory. An invitee can register and accept the invitation, but if they are not in the directory, they become an org member without being able to enter the teams. Acceptance is never blocked on that basis.</p>
@@ -1,6 +1,6 @@
---
title: Projects and Files
order: 3
order: 5
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
@@ -56,6 +56,10 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro
</figure>
<p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p>
<h3 id="file-view">Grid and list view</h3>
<p>In the dashboard header you can switch how files are shown: a thumbnail <strong>Grid view</strong> or a compact <strong>List view</strong>. The choice is kept in the browser and applies both to the team files view and inside a project.</p>
<p>List view shows the file name, whether the file is a shared library, the last modification time, and the same options menu as the file cards.</p>
<h3 id="edit-file">Edit a file</h3>
<p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p>
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Teams
order: 2
order: 4
desc: Manage teams and roles with Penpot's collaboration features! Learn how to manage teams, roles (Viewer, Editor, Admin, Owner), send invites and use webhooks.
---
+7 -1
View File
@@ -122,8 +122,14 @@ desc: Style your designs with Penpot's options! Learn about color fills, gradien
<img alt="Multiple strokes" src="/img/styling/stroke-multiple.webp"/>
</figure>
<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>
<p>To convert a stroke, select a layer that has one, open the layer menu, and choose <strong>Stroke to path</strong>. Penpot creates a new path from each stroke (named with " (stroke)") and removes the strokes from the original layer.</p>
<p class="advice">Stroke to path is currently available when <a href="/user-guide/first-steps/troubleshooting-webgl/">WebGL rendering</a> is enabled.</p>
<h2 id="stroke-caps">Stroke Caps</h2>
<p>Ever needed an arrow to point something? You can style the ends of any open paths selecting different styles for each end of an open path.</p>
<p>Ever needed an arrow to point to something? You can style the ends of any open paths selecting different styles for each end of an open path. You can also start from the <a href="/user-guide/designing/layers/#lines-arrows">Arrow tool</a>, which already applies a triangle cap.</p>
<figure>
<video title="Stroke cap" muted="" playsinline="" controls="" width="100%" poster="/img/styling/stroke-cap.webp" height="auto">
<source src="/img/styling/stroke-cap.mp4" type="video/mp4">
+15 -4
View File
@@ -140,7 +140,7 @@ Penpot allows you to decide if the fill of an artboard will be shown in exports,
<h3 id="rectangles-ellipses">Rectangles and ellipses</h3>
<p>Rectangle and ellipses are two basic “primitive” geometric shapes that are useful when starting
a design.</p>
<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles.</p>
<p>The shortcut keys are <kbd>E</kbd> for ellipses and <kbd>R</kbd> for rectangles. Both tools live in the shapes flyout of the toolbar, together with line and arrow.</p>
<p>To find out more about how to edit and modify these shapes go to <a href="/user-guide/designing/layers/#layer-actions">Layer basics</a>.</p>
<figure>
<video title="Rectangles and ellipses" muted="" playsinline="" controls="" width="auto" poster="/img/objects/rectangles-ellipses.webp" height="auto">
@@ -151,13 +151,24 @@ a design.</p>
<h3 id="text">Text</h3>
<p>Text layers are how you add copy to your designs in Penpot. If you want to go deeper into fonts, typography and advanced text options, check the dedicated <a href="/user-guide/designing/text-typo/">Text & Typography</a> section.</p>
<h3 id="lines-arrows">Lines and arrows</h3>
<p>Use the Line and Arrow tools to draw a two-point path in one drag. They live in the shapes flyout of the toolbar, together with rectangle and ellipse.</p>
<ul>
<li><strong>Line</strong> (<kbd>L</kbd>): click and drag. Hold <kbd>Shift/⇧</kbd> to snap the angle in 15 degree increments.</li>
<li><strong>Arrow</strong>: the same gesture, with a triangle arrow cap on the end. Pick it from the shapes flyout. You can change or remove the cap from the <a href="/user-guide/designing/color-stroke/#stroke-caps">stroke caps</a>.</li>
</ul>
<p>The result is a regular path, so you can edit its nodes and stroke like any other path.</p>
<figure>
<img src="/img/objects/line-arrow-tools.webp" alt="Line and Arrow tools in the shapes flyout of the toolbar" />
</figure>
<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 by clicking on the icon at the toolbar or pressing <kbd>Shift/⇧</kbd> + <kbd>c</kbd>.
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>
<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 you have to select the path tool by clicking on the icon at the toolbar or pressing <kbd>P</kbd>. Then you have two ways to create the path:</p>
<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>
<ol>
<li><strong>Click</strong> to create a new corner node.</li>
<li><strong>Click and drag</strong> to create a curved node.</li>
@@ -211,7 +222,7 @@ You can choose to edit individual nodes or create new ones. Press <kbd>Esc</kbd>
<h2 id="layer-actions">Layer actions</h2>
<h3 id="creating-layers">Create</h3>
<p>To create a layer you have to select the type of layer by clicking the selected tool (board, rectangle, ellipse, text, image, path or curve) at the toolbar. Then you usually have to click and drag your mouse on the viewport. </p>
<p>To create a layer, pick a tool from the toolbar. Board, text, and image stay on the toolbar. Rectangle, ellipse, line, and arrow are in the shapes flyout. Path and curve are in the free-draw flyout. Then you usually click and drag on the viewport.</p>
<p>Hold <kbd>Shift/⇧</kbd> while creating an ellipse or a rectangle to maintain equal width and height.</p>
<figure>
<video title="Layers create" muted="" playsinline="" controls="" width="auto" poster="/img/layers/layers-create.webp" height="auto">
+1 -1
View File
@@ -32,7 +32,7 @@ desc: Penpot's guide on custom fonts! Upload, manage, and use custom fonts in Pe
<img src="/img/objects/text-options.webp" alt="text options">
</figure>
<ol>
<li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>.</li>
<li><strong>Font family.</strong> Penpot includes by default the <a href="https://fonts.google.com/" target=”_blank”>Google Fonts</a> cataloge. You can also <a href="/user-guide/designing/text-typo/#custom-fonts">install your own fonts</a>. In the font selector, each family name is shown in its own typeface so you can compare fonts before applying one. The name is the preview. While a font loads, you may briefly see the interface font instead. The preview uses a default variant, not every weight or style of the family.</li>
<li><strong>Font size.</strong></li>
<li><strong>Font type.</strong></li>
<li><strong>Line height</strong> (in pixels).</li>
@@ -43,6 +43,15 @@ desc: Master Penpot's workspace basics! Learn interface navigation, zoom tools,
</video>
</figure>
<h4 id="select-delete-pages">Select and delete several pages</h4>
<p>In the Pages panel you can select more than one page and delete them in a single action.</p>
<ul>
<li>Click a page to open it. This selects only that page.</li>
<li><kbd>Shift/⇧</kbd> + click to select a range of pages. This does not change the page you are viewing.</li>
<li><kbd>Ctrl/⌘</kbd> + click to add or remove one page from the selection. This does not navigate.</li>
</ul>
<p>Right-click a selected page and choose <strong>Delete pages</strong>. Confirm to remove all selected pages in one step. A file always keeps at least one page. Page separators cannot be included in the selection.</p>
<h4 id="page-separators">Page separators</h4>
<p>You can add visual dividers in the page list to group pages without extra structure. Create an empty page, then rename it to <code>---</code>. The page appears as a horizontal line in the list.</p>
<p>A page only becomes a separator if its name is <code>---</code> and the page has no content on the canvas. A page with content that is named <code>---</code> stays a normal page. Separators cannot be opened or selected; you can reorder or delete them like other pages.</p>
+7 -2
View File
@@ -681,8 +681,8 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
</tr>
<tr>
<td>Curve</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd></kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd>Shift</kbd><kbd>C</kbd></td>
<td style="text-align: center;"><kbd></kbd><kbd>C</kbd></td>
</tr>
<tr>
<td>Ellipse</td>
@@ -694,6 +694,11 @@ desc: Get quickstart tips, shortcuts, and tutorials for Penpot! Learn interface
<td style="text-align: center;"><kbd>Shift</kbd><kbd>K</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>K</kbd></td>
</tr>
<tr>
<td>Line</td>
<td style="text-align: center;"><kbd>L</kbd></td>
<td style="text-align: center;"><kbd>L</kbd></td>
</tr>
<tr>
<td>Path</td>
<td style="text-align: center;"><kbd>P</kbd></td>
@@ -41,9 +41,9 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
<ol>
<li><strong>Viewport:</strong> An infinite canvas where you can design without limits.</li>
<li><strong>Toolbar: </strong> This is where youll find all the tools to quickly and easily create different types of layers: board, rectangle, ellipse, text, graphic, path, and free drawing. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li>
<li><strong>Toolbar: </strong> Tools to create layers. Board, text, and image stay on the toolbar. Shapes (rectangle, ellipse, line, and arrow) and free-draw tools (path and curve) are grouped in flyouts. Open a flyout to pick a tool, or use its shortcut. <a href="/user-guide/designing/layers/">Learn more about layers.</a></li>
<li><strong>Main menu:</strong> From the main menu, you can customize your workspace. Manage the visibility of grids, rulers, and panels. Enable or disable snapping and dynamic alignment. Add or remove the file as a Shared Library. Youll also find help resources here.</li>
<li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages as needed.</li>
<li><strong>Pages:</strong> A file can contain as many pages as you need. Each page has its own viewport (the almost infinite area where you design) and its own layers. You can create, delete, or reorder pages, and select several pages at once to delete them. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about pages.</a></li>
<li><strong>Layers:</strong> Layers are the different objects you can place in the design viewport. <a href="/user-guide/designing/workspace-basics/#layer-basics">More about Layers panel.</a></li>
<li><strong>Rulers:</strong> Rulers provide coordinates to help you design. You can also drag guides from them.</li>
<li><strong>Color palette:</strong> The color palette gives you quick access to a visible library of colors. Use the menu to easily switch between libraries. <a href="/user-guide/designing/color-stroke/#color-palette">Learn more about the color palette.</a>.</li>
@@ -137,7 +137,7 @@ desc: Discover Penpot's free user guide! Learn the interface, workspace basics,
<li><strong>User area:</strong> This must be you! Access your <a href="/user-guide/account-teams/your-account"> profile settings</a>, Penpot tutorials, the Penpot Community and more. You can also find here a way to leave us feedback. Wed love to read your thoughts :). </li>
<li><strong>Comments notifications:</strong> Here you will be able to see if you have unread comments inside the files of the team. There's also a button to mark all notifications as read.</li>
<li><strong>Create project:</strong> Create as many projects as you need to organize your designs.</li>
<li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if its added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete).</li>
<li><strong>File card:</strong> Basic information about a file at plain sight. A preview, update info or if its added as a Shared Library. From there you can perform several actions over the file (rename, duplicate, move, download, delete). You can also switch the files area between a thumbnail grid and a compact list. <a href="/user-guide/account-teams/projects-files/#file-view">More about grid and list view.</a></li>
<li><strong>Libraries & Templates module:</strong> A curated selection of Libraries & Templates files ready to import.</li>
</ol>
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -291,8 +291,6 @@
--modal-link-foreground-color: var(--color-accent-primary);
--modal-border-color: var(--color-background-quaternary);
--modal-separator-background-color: var(--color-background-quaternary);
--modal-navigator-foreground-color-rest: var(--color-background-quaternary);
--modal-navigator-foreground-color-active: var(--color-accent-primary);
// ALERTS, NOTIFICATION, TOAST & BADGES
@@ -12,6 +12,7 @@
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.component :as ctc]
[app.common.types.shape-tree :as ctt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
@@ -23,6 +24,7 @@
[app.main.render :as render]
[app.main.repo :as rp]
[app.util.queue :as q]
[app.util.storage :as storage]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@@ -289,13 +291,60 @@
(mapcat get-frame-ids-cached))
changes))))
;; Board thumbnails used to render text shapes without position-data as
;; nothing instead of falling back to the foreignObject renderer (see
;; frame-imposter in app.main.render), so any board thumbnail cached before
;; that fix may be missing its text. The backend doesn't tell the client
;; when a fetched thumbnail was generated, so we can't tell stale apart from
;; fresh by inspecting it; instead each board thumbnail with text content is
;; regenerated at most once per browser, tracked via local-storage so repeat
;; visits (once healed) don't keep re-rendering it.
(def ^:private healed-storage-key ::healed-text-thumbnails)
(defn- frame-has-text?
[objects frame-id]
(->> (cfh/get-children-with-self objects frame-id)
(some cfh/text-shape?)
(some?)))
(defn- unhealed-text-thumbnail?
[state object-id]
(and (some? (dm/get-in state [:thumbnails object-id :uri]))
(not (contains? (get @storage/global healed-storage-key) object-id))))
(defn- mark-thumbnail-healed!
[object-id]
(swap! storage/global update healed-storage-key (fnil conj #{}) object-id))
(defn- heal-stale-text-thumbnails
"Emits an `update-thumbnail` for every board on the page that has text
content and hasn't already been healed (see `healed-storage-key`) in this
browser."
[state file-id page-id]
(let [objects (-> (dsh/lookup-file-data state file-id)
(dsh/get-page page-id)
:objects)
frame-ids (ctt/get-root-frames-ids objects)
xf (comp
(filter #(frame-has-text? objects %))
(keep
(fn [frame-id]
(let [object-id (thc/fmt-object-id file-id page-id frame-id "frame")]
(when (unhealed-text-thumbnail? state object-id)
[frame-id object-id])))))]
(->> (rx/from (eduction xf frame-ids))
(rx/map
(fn [[frame-id object-id]]
(mark-thumbnail-healed! object-id)
(update-thumbnail file-id page-id frame-id "frame" "heal-stale-text-thumbnails"))))))
(defn watch-state-changes
"Watch the state for changes inside frames. If a change is detected will force a rendering
of the frame data so the thumbnail can be updated."
[file-id page-id]
(ptk/reify ::watch-state-changes
ptk/WatchEvent
(watch [_ _ stream]
(watch [_ state stream]
(let [stopper-s (rx/filter
(fn [event]
(as-> (ptk/type event) type
@@ -330,6 +379,10 @@
(rx/tap #(l/trc :hint "buffer initialized")))]
(->> (rx/merge
;; Heal boards with text whose cached thumbnail may predate the
;; text-position fix (see heal-stale-text-thumbnails).
(heal-stale-text-thumbnails state file-id page-id)
;; Perform instant thumbnail cleaning of affected frames
;; and interrupt any ongoing update-thumbnail process
;; related to current frame-id
+11 -10
View File
@@ -266,16 +266,17 @@
[{:keys [objects frame vbox x y width height background]}]
(let [shape-wrapper (shape-wrapper-factory objects)]
[:& (mf/provider muc/render-thumbnails) {:value false}
[:svg {:view-box vbox
:width (ust/format-precision width viewbox-decimal-precision)
:height (ust/format-precision height viewbox-decimal-precision)
:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:fill "none"}
(when (some? background)
[:rect {:x x :y y :width width :height height :fill background}])
[:& shape-wrapper {:shape frame}]]]))
[:& (mf/provider muc/is-render?) {:value true}
[:svg {:view-box vbox
:width (ust/format-precision width viewbox-decimal-precision)
:height (ust/format-precision height viewbox-decimal-precision)
:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
:xmlnsXlink "http://www.w3.org/1999/xlink"
:fill "none"}
(when (some? background)
[:rect {:x x :y y :width width :height height :fill background}])
[:& shape-wrapper {:shape frame}]]]]))
;; Component that serves for render frame thumbnails, mainly used in
;; the viewer and inspector
+2 -1
View File
@@ -36,6 +36,7 @@
[app.main.ui.releases.v2-15]
[app.main.ui.releases.v2-16]
[app.main.ui.releases.v2-17]
[app.main.ui.releases.v2-18]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@@ -107,4 +108,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.17")))
(rc/render-release-notes (assoc params :version "2.18")))
+116
View File
@@ -0,0 +1,116 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.modal-overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
z-index: var(--z-index-set);
background-color: var(--color-overlay-default);
}
.modal-container {
display: grid;
grid-template-columns: px2rem(324) 1fr;
block-size: $sz-500;
inline-size: px2rem(888);
border-radius: $br-8;
background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
}
.start-image {
inline-size: px2rem(324);
border-radius: $br-8 0 0 $br-8;
}
.modal-content {
padding: $sz-40;
display: grid;
grid-template-rows: auto 1fr $sz-32;
gap: var(--sp-xxl);
}
.modal-header {
display: grid;
gap: var(--sp-s);
}
.link {
color: var(--color-accent-primary);
}
.version-tag {
@include use-typography("headline-small");
display: flex;
justify-content: center;
align-items: center;
block-size: $sz-32;
inline-size: $sz-96;
background-color: var(--color-foreground-primary);
color: var(--color-background-tertiary);
border-radius: $br-8;
}
.modal-title {
@include use-typography("headline-large");
color: var(--color-foreground-primary);
}
.features-block {
display: flex;
flex-direction: column;
gap: var(--sp-l);
inline-size: px2rem(440);
}
.feature {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.feature-title {
@include use-typography("body-large");
color: var(--color-foreground-primary);
}
.feature-content {
@include use-typography("body-medium");
margin: 0;
color: var(--color-foreground-secondary);
}
.feature-list {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
list-style: disc;
display: grid;
gap: var(--sp-s);
}
.navigation {
inline-size: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
justify-self: flex-end;
grid-area: button;
}
+11 -10
View File
@@ -4,28 +4,29 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.step-dots {
display: grid;
grid-template-columns: none;
grid-auto-flow: column;
gap: deprecated.$s-8;
height: fit-content;
width: fit-content;
gap: var(--sp-s);
block-size: fit-content;
inline-size: fit-content;
margin: 0;
padding: 0;
place-self: center flex-start;
}
.dot {
height: deprecated.$s-12;
width: deprecated.$s-12;
border-radius: deprecated.$br-circle;
background-color: var(--modal-navigator-foreground-color-rest);
cursor: pointer;
block-size: $sz-12;
inline-size: $sz-12;
border-radius: $br-circle;
background-color: var(--color-background-quaternary);
}
.current {
background-color: var(--modal-navigator-foreground-color-active);
background-color: var(--color-accent-primary);
}
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -57,8 +58,10 @@
" Ready to dive in? Let 's get started!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -97,8 +100,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -131,8 +136,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -166,8 +173,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -198,6 +207,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -98
View File
@@ -4,101 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+5 -3
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -43,6 +44,7 @@
" Ready to dive in? Let 's get started!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click finish} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -77
View File
@@ -4,80 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+23 -12
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -107,7 +112,8 @@
"And one more thing…Tune into our product showcase to see future plans and help us shape Penpot. Come for the insights, stay for the community…"]
[:p {:class (stl/css :feature-content)}
[:a {:href "https://penpot.app/penpotfest"
[:a {:class (stl/css :link)
:href "https://penpot.app/penpotfest"
:target "_blank"}
"Get your tickets"]
" now to join us 8-10 October, in Madrid!"]]
@@ -118,8 +124,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -148,8 +156,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -179,6 +189,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -49,8 +50,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -115,8 +120,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -153,8 +160,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -184,6 +193,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -85,8 +88,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -121,8 +126,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
@@ -157,6 +164,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+13 -7
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -113,6 +118,7 @@
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -109,8 +114,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -139,8 +146,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -173,6 +182,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -120,8 +125,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -154,6 +161,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+26 -14
View File
@@ -2,12 +2,13 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.releases.v2-16
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -44,8 +45,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -74,8 +77,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -104,8 +109,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -134,8 +141,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -177,8 +186,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
4
[:div {:class (stl/css-case :modal-overlay true)}
@@ -207,6 +218,7 @@
:navigate navigate
:total 5}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more