Merge branch 'develop' into niwinz-performance-tests

This commit is contained in:
Andrey Antukh 2026-07-20 10:20:05 +02:00 committed by GitHub
commit 501284169c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
174 changed files with 9185 additions and 2294 deletions

View File

@ -52,7 +52,7 @@ built fresh from `shared/codex.toml` + `templates/codex.toml` at launch.
`manage.sh`.
* **`mcp/`** (Claude Code, opencode) is the result of merging `shared/` with
the port-substituted `templates/`. `manage.sh` writes these on every
`run-devenv-agentic` pass. Gitignored, dedicated paths with no developer
`run-devenv --agentic` pass. Gitignored, dedicated paths with no developer
content — never edit by hand, your edits will be overwritten on the next
reconcile.
* **`.vscode/mcp.json`** is the same merge, but written to the path VS Code

View File

@ -3,7 +3,7 @@ name: Auto Label and Add to Project
on:
issues:
types: [opened]
pull_request:
pull_request_target:
types: [opened]
jobs:

View File

@ -85,7 +85,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -40,7 +40,7 @@ jobs:
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -175,7 +175,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -29,7 +29,7 @@ jobs:
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -131,7 +131,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -114,7 +114,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -129,7 +129,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -103,7 +103,7 @@ jobs:
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd

3
.gitignore vendored
View File

@ -100,6 +100,9 @@ opencode.json
/.playwright-mcp
/.devenv/mcp/
/opencode.json
/.opencode/plans
/.opencode/reports
/.opencode/prompts
/.codex/
/tools/__pycache__
/performance/results/

View File

@ -6,7 +6,6 @@ permission:
read: allow
glob: allow
grep: allow
list: allow
edit: deny
webfetch: deny
websearch: deny
@ -14,132 +13,43 @@ permission:
skill: deny
lsp: deny
todowrite: deny
question: allow
question: deny
external_directory: deny
bash:
# Broad read-side: any non-write git query
"git status*": allow
"git log*": allow
"git diff*": allow
"git show*": allow
"git rev-parse*": allow
"git branch*": allow
"git remote -v*": allow
"git config --get*": allow
# Commit flow: staged, explicit paths only. `git commit*` (no space)
# also covers `git commit --amend`. `git add -*` overrides the allow
# below to block flag-driven bulk adds (`-A`, `--all`, `-u`, ...).
"git add *": allow
"git commit*": allow
"git add -*": deny
# Read-only filesystem helpers used in the commit flow
"cat *": allow
"head *": allow
"tail *": allow
"wc *": allow
"date *": allow
# Dangerous: deny outright
"rm *": deny
"rmdir *": deny
"mv *": deny
"cp *": deny
"dd *": deny
"chmod *": deny
"chown *": deny
"sudo *": deny
"git push*": deny
"git clean*": deny
"git reset*": deny
"git checkout*": deny
"git restore*": deny
"git config --global*": deny
"curl *": deny
"wget *": deny
"ssh *": deny
"scp *": deny
"eval *": deny
# Risky-but-sometimes-needed: ask the user
"git stash*": ask
"git rebase*": ask
"git merge*": ask
"git tag*": ask
"git fetch*": ask
"git pull*": ask
# Note: `git config <anything-other-than-(--get|--global)>` falls
# through to the `*` catch-all below and is asked.
# Safety net
"*": ask
bash: allow
---
## Role
You are the Penpot commit assistant. You produce git commits that follow the
repository's commit conventions exactly: an emoji-prefixed imperative
subject, a body that explains the why, and the required trailers. You do
not implement features, review code, or push branches — you commit.
repository's commit conventions. You do not implement features, review code, or
push branches — you commit.
## Required Reading
Before drafting any commit, read `.serena/memories/workflow/creating-commits.md`
end-to-end. It is the canonical source for the emoji menu, subject/body
limits, and trailer format. The summary in this file does not replace it.
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
end-to-end**. It is the authoritative source for the commit message format, the
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
exactly — do not improvise the format and do not restate its contents here.
## Pre-commit Workflow
1. Run `git status` to inspect the working tree. If there are unstaged or
untracked changes that are unrelated to the user's request, STOP and ask
the user how to handle them. Do not silently include unrelated work in
the commit.
2. Run `git diff --staged` (or `git diff` for unstaged changes) and review
the content. If you see secrets (API keys, tokens, passwords, private
keys, `.env` values), debug prints, or anything that does not match the
user's stated intent, STOP and tell the user before committing.
3. Pick the commit emoji from the menu in
`mem:workflow/creating-commits`. If none of the listed emojis fit, use
`:paperclip:` (other) and explain in the body why.
4. Draft the commit message (see format below), then run
`git commit -s -m "<subject>" -m "<body>"` (or pass the message via
`git commit -s -F -` if the body has unusual characters).
## Commit Message Format
```
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why. Wrap at 80 chars. Use manual
line breaks; do not rely on the terminal to wrap.
Co-authored-by: <model-name> <model-name@penpot.app>
```
- Subject: imperative mood, capitalized, no trailing period, max 70 chars.
- Body: wraps at 80 chars. Explain the *why*, not just the *what* — what
was wrong before, what this change does about it, and any non-obvious
trade-offs.
- `Co-authored-by` trailer is mandatory. Replace `<model-name>` with your
own model identifier (e.g. `claude-sonnet-4-6`).
- `Signed-off-by` is added automatically by `git commit -s`, using the
local `git config user.name` / `user.email`.
1. **Stage the files** specified by the calling agent. Do not ask for
confirmation — the calling agent knows exactly which files to commit.
2. Run `git diff --staged` to review the content. If you see secrets (API
keys, tokens, passwords, private keys, `.env` values), debug prints, or
anything that does not match the stated intent, STOP and tell the user
before committing.
3. Following the format in the doc, draft the message and run
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
unusual characters). The `AI-assisted-by` trailer value is provided by the
calling agent — use it verbatim.
## Constraints
- Do not push. Pushing is a separate workflow handled by the user (the
agent's permission set also denies `git push*`).
- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`,
or `rm*` — the permission set denies these outright. If staged work
needs to be discarded, ask the user to do it.
- Do not pass `--author`. Author identity comes from the local git
config. Never guess or hallucinate a name or email.
- Do not amend a commit you did not create in this session, unless the
user explicitly asks. `git commit --amend` rewrites history and is
irreversible once pushed.
- Do not bypass pre-commit hooks (`--no-verify`) unless the user
explicitly asks, and call out the deviation in your response.
- If the user asks for something that conflicts with these rules, follow
the user's request and explain the deviation in your response. Do not
silently override the format.
- Do not push. Pushing is a separate workflow handled by the user.
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
- Do not pass `--author`. Author identity comes from the local git config.
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
- Do not add untracked files that were not created in this session.
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.

View File

@ -0,0 +1,43 @@
---
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
agent: build
---
# Implement Plan
This command is run once a plan is ready (for example, from plan mode). Execute
the plan already prepared in the current session context — it does not take
extra arguments. Follow these steps in order.
## 1. Create the issue
Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
plan. Capture the new issue's number — call it **NNNN** (needed for the branch
name and the commit reference).
## 2. Create the branch
Create and switch to a branch named after the issue:
```
git checkout -b issue-NNNN
```
(Replace NNNN with the issue number from step 1.)
## 3. Execute the plan
Implement the prepared plan from the session context. Work methodically, keeping
changes focused on what the issue requires. Do not commit — the commit happens in
step 4.
## 4. Commit with the commiter subagent
After the implementation is complete, delegate the commit to the **`commiter`**
subagent. Give it a brief summary of what was implemented and why, the issue
reference (`issue-NNNN`), and the model name you are running as so it sets the
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
conventions.
Do not push. Pushing is handled separately by the user.

View File

@ -0,0 +1,21 @@
---
description: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes
agent: plan
subtask: true
---
You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance).
The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent).
Workflow:
1. Determine the target to review:
- If the user provided a revision/range in $ARGUMENTS, use it.
- Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`).
2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched.
3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security.
4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem).
5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items.
Do not modify any code and do not create a commit — this command only reviews.

View File

@ -0,0 +1,397 @@
---
name: code-review-and-quality
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
---
# Code Review and Quality
## Overview
Multi-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.
**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.
## When to Use
- Before merging any PR or change
- After completing a feature implementation
- When another agent or model produced code you need to evaluate
- When refactoring existing code
- After any bug fix (review both the fix and the regression test)
## The Five-Axis Review
Every review evaluates code across these dimensions:
### 1. Correctness
Does the code do what it claims to do?
- Does it match the spec or task requirements?
- Are edge cases handled (null, empty, boundary values)?
- Are error paths handled (not just the happy path)?
- Does it pass all tests? Are the tests actually testing the right things?
- Are there off-by-one errors, race conditions, or state inconsistencies?
### 2. Readability & Simplicity
Can another engineer (or agent) understand this code without the author explaining it?
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
- Is the code organized logically (related code grouped, clear module boundaries)?
- Are there any "clever" tricks that should be simplified?
- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)
- **Are abstractions earning their complexity?** (Don't generalize until the third use case)
- Would comments help clarify non-obvious intent? (But don't comment obvious code.)
- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?
- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path.
- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt.
### 3. Architecture
Does the change fit the system's design?
- Does it follow existing patterns or introduce a new one? If new, is it justified?
- Does it maintain clean module boundaries?
- Is there code duplication that should be shared?
- Are dependencies flowing in the right direction (no circular dependencies)?
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
- **Does this refactor reduce complexity or just relocate it?** Count the concepts a reader must hold to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
- **Is feature-specific logic leaking into a shared or general-purpose module?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift.
- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler.
### 4. Security
For detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?
- Is user input validated and sanitized?
- Are secrets kept out of code, logs, and version control?
- Is authentication/authorization checked where needed?
- Are SQL queries parameterized (no string concatenation)?
- Are outputs encoded to prevent XSS?
- Are dependencies from trusted sources with no known vulnerabilities?
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
- Are external data flows validated at system boundaries before use in logic or rendering?
### 5. Performance
Does the change introduce performance problems?
- Any N+1 query patterns?
- Any unbounded loops or unconstrained data fetching?
- Any synchronous operations that should be async?
- Any unnecessary re-renders in UI components?
- Any missing pagination on list endpoints?
- Any large objects created in hot paths?
## Structural Remedies
When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring:
- **Replace a chain of conditionals** with a typed model or an explicit dispatcher.
- **Collapse duplicate branches** into a single clearer flow.
- **Separate orchestration from business logic** so each reads on its own.
- **Move feature-specific logic** out of a shared module into the package that owns the concept.
- **Reuse the canonical helper** instead of a bespoke near-duplicate.
- **Make a type boundary explicit** so downstream branching disappears.
- **Delete a pass-through wrapper** that adds indirection without clarifying the API.
- **Extract a helper, or split a large file** into focused modules.
Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
## Change Sizing
Small, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:
```
~100 lines changed → Good. Reviewable in one sitting.
~300 lines changed → Acceptable if it's a single logical change.
~1000 lines changed → Too large. Split it.
```
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
**Splitting strategies when a change is too large:**
| Strategy | How | When |
|----------|-----|------|
| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies |
| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns |
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.
**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.
## Change Descriptions
Every change needs a description that stands alone in version control history.
**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff.
**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.
**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions."
## Review Process
### Step 1: Understand the Context
Before looking at code, understand the intent:
```
- What is this change trying to accomplish?
- What spec or task does it implement?
- What is the expected behavior change?
```
### Step 2: Review the Tests First
Tests reveal intent and coverage:
```
- Do tests exist for the change?
- Do they test behavior (not implementation details)?
- Are edge cases covered?
- Do tests have descriptive names?
- Would the tests catch a regression if the code changed?
```
### Step 3: Review the Implementation
Walk through the code with the five axes in mind:
```
For each file changed:
1. Correctness: Does this code do what the test says it should?
2. Readability: Can I understand this without help?
3. Architecture: Does this fit the system?
4. Security: Any vulnerabilities?
5. Performance: Any bottlenecks?
```
### Step 4: Categorize Findings
Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| *(no prefix)* | Required change | Must address before merge |
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
### Step 5: Verify the Verification
Check the author's verification story:
```
- What tests were run?
- Did the build pass?
- Was the change tested manually?
- Are there screenshots for UI changes?
- Is there a before/after comparison?
```
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code
Model B reviews for correctness and architecture
Model A addresses the feedback
Human makes the final call
```
This catches issues that a single model might miss — different models have different blind spots.
**Example prompt for a review agent:**
```
Review this code change for correctness, security, and adherence to
our project conventions. The spec says [X]. The change should [Y].
Flag any issues as Critical, Required, Optional, or Nit.
```
## Dead Code Hygiene
After any refactoring or implementation change, check for orphaned code:
1. Identify code that is now unreachable or unused
2. List it explicitly
3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?"
Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.
```
DEAD CODE IDENTIFIED:
- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()
- OldTaskCard component in src/components/ — replaced by TaskCard
- LEGACY_API_URL constant in src/config.ts — no remaining references
→ Safe to remove these?
```
## Review Speed
Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.
- **Respond within one business day** — this is the maximum, not the target
- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day
- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed
- **Large changes:** Ask the author to split them rather than reviewing one massive changeset
## Handling Disagreements
When resolving review disputes, apply this hierarchy:
1. **Technical facts and data** override opinions and preferences
2. **Style guides** are the absolute authority on style matters
3. **Software design** must be evaluated on engineering principles, not personal preference
4. **Codebase consistency** is acceptable if it doesn't degrade overall health
**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.
## Honesty in Review
When reviewing code — whether written by you, another agent, or a human:
- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one.
- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest.
- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow."
- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.
- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.
## Dependency Discipline
Part of code review is dependency review:
**Before adding any dependency:**
1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.)
3. Is it actively maintained? (Check last commit, open issues.)
4. Does it have known vulnerabilities? (`npm audit`)
5. What's the license? (Must be compatible with the project.)
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline:
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
## The Review Checklist
```markdown
## Review: [PR/Change title]
### Context
- [ ] I understand what this change does and why
### Correctness
- [ ] Change matches spec/task requirements
- [ ] Edge cases handled
- [ ] Error paths handled
- [ ] Tests cover the change adequately
### Readability
- [ ] Names are clear and consistent
- [ ] Logic is straightforward
- [ ] No unnecessary complexity
### Architecture
- [ ] Follows existing patterns
- [ ] No unnecessary coupling or dependencies
- [ ] Appropriate abstraction level
- [ ] Refactors reduce complexity rather than relocate it
- [ ] No feature logic in shared modules; file stays within a healthy size
### Security
- [ ] No secrets in code
- [ ] Input validated at boundaries
- [ ] No injection vulnerabilities
- [ ] Auth checks in place
- [ ] External data sources treated as untrusted
### Performance
- [ ] No N+1 patterns
- [ ] No unbounded operations
- [ ] Pagination on list endpoints
### Verification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] Manual verification done (if applicable)
### Verdict
- [ ] **Approve** — Ready to merge
- [ ] **Request changes** — Issues must be addressed
```
## See Also
- For detailed security review guidance, see `security-and-hardening`
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve — look for the version where branches disappear. |
| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. |
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. |
| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. |
## Red Flags
- PRs merged without any review
- Review that only checks if tests pass (ignoring other axes)
- "LGTM" without evidence of actual review
- Security-sensitive changes without security-focused review
- Large PRs that are "too big to review properly" (split them)
- No regression tests with bug fix PRs
- Review comments without severity labels — makes it unclear what's required vs optional
- Accepting "I'll fix it later" — it never happens
- A refactor that moves code around without reducing the number of concepts a reader must hold
- A change that grows an already-large file instead of decomposing it
- New conditionals scattered into unrelated code paths (a missing abstraction)
- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module
- A bulk "bump dependencies" PR with no changelog review and no per-package isolation
- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff
## Verification
After review is complete:
- [ ] All Critical issues are resolved
- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed
**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant.

View File

@ -0,0 +1,27 @@
---
name: create-issue
description: Create or update GitHub issues (from PR, from draft body, retitle existing). Routes to the canonical flow in `mem:workflow/creating-issues`.
---
# Skill: create-issue
Entry point for all GitHub issue work. All rules (title derivation, metadata,
body templates, Issue Type IDs), all flows, and all `gh` / GraphQL commands
live in `mem:workflow/creating-issues` (file:
`.serena/memories/workflow/creating-issues.md`). This skill routes to the
right flow.
## When to Use
- **Create from PR** — PR exists; the issue is the changelog/release unit,
the PR is the implementation. Issue = WHAT, PR = HOW.
→ memory section **Creating Issues from PRs**
- **Create from draft body** — Taiga story, user report, discussion; no PR
yet.
→ memory section **Creating Issues from Draft Body**
- **Retitle existing issue** — current title is vague, prefixed, or stale.
→ memory section **Retitling an Existing Issue**
Everything else (title derivation, metadata policy, body templates, Issue
Type IDs, create/verify/cleanup commands) lives in the memory — go to the
matching section there.

View File

@ -1,227 +0,0 @@
---
name: gh-issue-from-pr
description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type.
---
# Skill: gh-issue-from-pr
Create a GitHub issue that captures the **WHAT** (user-facing feature or
bug) from an existing PR that describes the **HOW** (implementation).
Used when the project board needs an issue as the primary changelog/release unit.
## When to Use
- Create a tracking issue from a PR for changelog purposes
- Extract the user-facing problem/feature from a PR's implementation details
- Assign milestone, project, labels, and issue type to a new issue derived from a PR
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Permission to create issues and edit PRs in the target repository
## Workflow
### 1. Understand the PR
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
```
Identify:
- **WHAT** — user-facing problem or feature. Goes into the issue.
Describe symptoms and impact, not internal mechanisms.
- **HOW** — implementation details. These belong in the PR, not the issue.
### 2. Determine metadata
| Field | Source | Rule |
|-------|--------|------|
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. |
| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). |
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. |
### 3. Write the issue body
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
### 4. Create the issue
Write the body to a temp file to avoid shell quoting issues:
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
```
Create:
```bash
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
### 5. Assign to the PR author
Assign the issue to the PR author so they're responsible for it:
```bash
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
```
### 6. Set the Issue Type
`gh issue create` can't set the Issue Type directly. Use GraphQL.
Get the issue's GraphQL node ID:
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
```
Issue Type IDs for the Penpot repo:
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
Set it:
```bash
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
### 7. Verify
```bash
gh issue view <ISSUE_NUMBER> --repo penpot/penpot \
--json title,milestone,projectItems,labels \
--jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
### 8. Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue
— it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
### 9. Clean up
```bash
rm -f /tmp/issue-body.md /tmp/pr-body.md
```
## Label rules
| PR has | Issue gets |
|--------|-----------|
| `community contribution` | `community contribution` |
| `bug`, `enhancement` | *(skip — redundant with Issue Type)* |
| `backport candidate` | *(skip — workflow label)* |
| `team-qa` | *(skip — workflow label)* |
## Issue Type mapping
| PR label(s) / title prefix | Issue Type |
|----------------------------|-----------|
| `bug` or `:bug:` | Bug |
| `enhancement` or `:sparkles:` or `:tada:` | Enhancement |
| Feature / epic | Feature |
| Documentation | Docs |
| None of the above | Task |
## Key Principles
- **Issue = WHAT, PR = HOW.** Never put implementation details in the
issue body. The issue is for users, QA, and changelog readers.
- **Copy the milestone from the PR.** Don't guess based on branch names.
If the PR has no milestone, create the issue without one.
- **Set Issue Type via GraphQL**`gh issue create` can't set it.
- **Link via PR body**`Closes #<NUMBER>` creates the "Development"
sidebar link automatically.
- **One issue per PR** — even if a PR fixes multiple things, create a
single issue that summarizes the overall change.
- **Community attribution:** if the PR has the `community contribution`
label or the author is not a core team member, add the label to the issue.

View File

@ -1,123 +0,0 @@
---
name: issue-title
description: Derive a clear, well-formatted title for a GitHub issue from its description body, using descriptive present-tense for bugs and imperative mood for features, always including the "where" (location in the UI/module).
---
# Skill: issue-title
Derive a concise, descriptive title for a GitHub issue based on its body
content. Use **descriptive present tense for bugs** (e.g. "Plugin API
crashes when setting text fills") and **imperative mood for features** (e.g.
"Add customizable dash and gap controls"). No emoji or type prefixes
(`feat:`, `bug:`, `feature:`, etc.).
Can be used both when **creating a new issue** and when **updating an
existing one** that has a vague or outdated title.
## When to Use
- Creating a new issue and need a well-formatted title from the draft body
- An existing issue has a vague, outdated, or auto-generated title (e.g.
`[PENPOT FEEDBACK]: ...`, `feature: ...`)
- The current title doesn't reflect the actual content of the description
- The title is missing the "where" (which part of the UI/module is affected)
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
## Workflow
### 1. Get the issue body
For an **existing issue**, fetch it:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title,body
```
For a **new issue**, read the draft body from wherever it was provided
(Taiga link, user report, discussion, etc.).
### 2. Read the body and derive a title
Extract the core problem or request from the description. Distinguish between
bug reports and feature requests:
**Bug titles (descriptive, present tense):**
Describe the symptom as it appears to the user. Format:
`[Where] [present-tense verb] when [condition]`
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
- *"Text layer content is not deleted when WebGL render is enabled"*
Do **not** start bug titles with "Fix" or any imperative verb. The title
should state what's broken, not command a fix.
**Feature / Enhancement titles (imperative mood):**
Command what should be built. Format:
`[Imperative verb] [what] in/on [where]`
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
- *"Validate shape on add-object to catch malformed inputs early"*
**Universal rules (both types):**
- **Include the "where"** — specify the UI location or module (e.g.
"in the sidebar", "in the workspace history panel", "on the stroke
options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`,
`[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality. If the
description mentions two related problems, capture both.
**Examples:**
| Original / draft title | Type | New title |
|---|---|---|
| `[PENPOT FEEDBACK]: WebGL` | Bug | `Canvas renders glitches when zooming quickly — text appears distorted and nodes have background-colored rectangles` |
| `bug: flatten-nested-tokens-json uses $type instead of $value as the DTCG token/group discriminator` | Bug | `Token import fails when group-level type inheritance is used — parser misidentifies groups as tokens` |
| `feature: Dashed stroke customization` | Feature | `Add customizable dash and gap length controls to dashed strokes in the sidebar` |
| `feature: Add more detail to history of actions` | Feature | `Show user, timestamp, and hash in the workspace history panel like git commits` |
### 3. Apply the title
**If updating an existing issue:**
```bash
gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>"
```
**If creating a new issue:**
```bash
gh issue create --repo penpot/penpot --title "<NEW TITLE>" --body "<BODY>"
```
### 4. Confirm
For updates, the command returns the issue URL. Verify by optionally fetching
again:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title
```
## Key Principles
- **Bug titles describe the symptom** — present tense, 3rd person:
"crashes", "fails", "shows", "is cut off", "does not load". Do not
start with "Fix" or "Bug:".
- **Feature titles use imperative mood** — command form: "Add", "Show",
"Use", "Validate", "Support", "Toggle".
- **Always include the "where"** — a title like "Crashes when zooming"
is too vague; "Canvas crashes when zooming quickly" is clear.
- **No prefixes, no emoji** — strip all type labels and decorative
characters from the title.
- **Derive from the body, not the current title** — the body contains
the real detail; the current title may be auto-generated or stale.
- **Two problems → cover both** — if the description has two distinct
but related issues, capture both in the title joined by "and".

View File

@ -6,26 +6,20 @@ description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-ev
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
`tools/nrepl-eval.mjs`.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`)
## Usage
## Quick Reference
```bash
node tools/nrepl-eval.mjs [options] [<code>]
```
The tool is also executable directly:
```bash
./tools/nrepl-eval.mjs [options] [<code>]
```
## Options
| Flag | Description | Default |
|------|-------------|---------|
| `--backend` | Connect to backend nREPL (port 6064) | — |
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
| `-p, --port PORT` | nREPL server port | `6064` |
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
@ -33,88 +27,11 @@ The tool is also executable directly:
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
| `-h, --help` | Show help message | — |
## When to Use
## Examples
Use this tool when you need to:
1. **Evaluate Clojure code** during development — test functions, inspect
state, or run experiments against a running Clojure process.
2. **Verify that edited files compile** — require namespaces with `:reload`
to pick up changes.
3. **Inspect the last exception** after a failed evaluation — use `-e` to
print the error stored in `*e`.
## Workflow
### 1. Session management
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
```
### 2. Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### 3. Inspect last exception
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
./tools/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when
your nREPL server is on 6064. Use `-p <PORT>` for a different port.
- **Always use `:reload`** when requiring namespaces to pick up file changes.
- **Session is reused** across invocations — defs, in-ns, and var bindings
persist. Use `--reset-session` to clear.
- **Do not start any server** — the tool connects to an existing nREPL
server, it is not the agent's responsibility to start the nREPL server
(assume the server is already running on the specified port).

View File

@ -1,6 +1,6 @@
---
name: planner
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to plans/YYYY-MM-DD-<title>.md only when the calling agent has write permission.
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md.
---
# Planner
@ -17,7 +17,7 @@ or modifies code.
names, and test strategy.
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
- The user is about to start non-trivial work and wants a bite-sized task
breakdown (DRY, YAGNI, TDD, frequent commits).
breakdown.
Do **not** use this skill to actually implement anything — it is read-only.
@ -29,13 +29,8 @@ modify code.
You help users understand the codebase, design solutions, and create detailed
implementation plans that other agents or developers can execute. Document
everything they need to know: which files to touch for each task, code, tests,
docs they might need to check, and how to verify it. Give them the whole plan
as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Assume the implementer is a skilled developer, but knows almost nothing about
our toolset or problem domain. Assume they don't know good test design very
well.
everything they need to know: which files to touch for each task, code patterns,
tests, and how to verify correctness. Apply DRY and KISS principles.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
@ -44,92 +39,233 @@ responses — committing is the developer's responsibility.
Before drafting any plan, work through the project's own guidance:
1. Read `AGENTS.md` (root) for the project-level rules.
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
identify which modules are affected.
1. Read `critical-info` (`.serena/memories/critical-info.md`) — the entry point
that describes the monorepo structure and module dependency graph.
2. From `critical-info`, identify which modules your task affects.
3. Read each affected module's core memory, e.g. `mem:frontend/core`,
`mem:backend/core`, `mem:common/core`, `mem:exporter/core`,
`mem:render-wasm/core`. Follow `mem:` references deeper as needed.
4. For frontend/backend work, check the relevant section's notes on lint,
format, and test commands so the plan can include them.
4. For each affected module, note its lint, format, and test commands so the
plan can include concrete verification steps.
Skipping this step is the #1 cause of incorrect or incomplete plans.
## The Planning Process
### Phase 1: Architecture Analysis
1. Read the spec, requirements, or feature request.
2. Analyze the codebase architecture and identify affected modules.
3. Read project conventions (starting with `critical-info` and module core
memories) before drafting.
4. Map dependencies between components (see the dependency graph in
`critical-info`).
5. Identify risks, edge cases, performance implications, and breaking changes.
### Phase 2: Task Breakdown
Implementation order follows the monorepo's dependency graph:
`frontend -> common`, `backend -> common`, `exporter -> common`,
`frontend -> render-wasm`. Build shared foundations first, then layer
consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
Task 3: frontend UI component + API integration
```
Each vertical slice delivers working, testable functionality.
#### Write Tasks
Each task follows this structure:
```markdown
## Task [N]: [Short descriptive title]
**Description:** One paragraph explaining what this task accomplishes.
**Acceptance criteria:**
- [ ] [Specific, testable condition]
- [ ] [Specific, testable condition]
**Verification:**
- [ ] Tests pass (module-specific test command)
- [ ] Lint/formatter passes (module-specific check command)
**Dependencies:** [Task numbers this depends on, or "None"]
**Files likely touched:**
- `path/to/file.clj`
- `path/to/file_test.clj`
```
Replace "module-specific test command" with the actual commands for the module
(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend,
or the commands noted in the module's core memory).
#### Estimate Scope
| Size | Files | Scope |
|------|-------|-------|
| **XS** | 1 | Single function, config change, or schema tweak |
| **S** | 1-2 | One handler or component method |
| **M** | 3-5 | One vertical feature slice |
| **L** | 5-8 | Multi-component feature |
| **XL** | 8+ | **Too large — break it down further** |
If a task is L or larger, break it into smaller tasks. Agents perform best on
S and M tasks.
**When to break a task down further:**
- It would take more than one focused session
- You cannot describe the acceptance criteria in 3 or fewer bullet points
- It touches two or more independent subsystems
- You find yourself writing "and" in the task title (a sign it is two tasks)
#### Order and Checkpoints
Arrange tasks so that:
1. Dependencies are satisfied (build foundation first)
2. Each task leaves the system in a working state
3. Verification checkpoints occur after every 2-3 tasks
4. High-risk tasks are early (fail fast)
Add explicit checkpoints with the relevant module commands:
```markdown
## Checkpoint: After Tasks 1-3
- [ ] All tests pass (module-specific command)
- [ ] Lint/format passes (module-specific command)
- [ ] Core flow works end-to-end
- [ ] Review with human before proceeding
```
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read `AGENTS.md` and the memory system conventions before drafting.
- Read project conventions before drafting (start with `critical-info` and
affected module core memories).
- Break down complex features or bugs into atomic, actionable steps.
- Propose solutions with clear rationale, trade-offs, and sequencing.
- Identify risks, edge cases, performance implications, and breaking changes.
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
- Checkpoints must exist between major phases.
## Constraints
- You are **analysis-only** — never create, edit, or delete source code.
- The only file write you may attempt is the plan itself, and only when the
calling agent has write permission (see "Plan Output"). If the write is
denied, deliver the plan in the response and move on.
- The only file write you may attempt is the plan itself, saved to
`.opencode/plans/`.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- You do **not** execute shell commands beyond read-only searches.
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Plan Output
## Output Format
The plan is always delivered in the response so the user sees it regardless
of which agent is running the skill.
Persistence is a **separate, best-effort step** that only runs when the
calling agent has `edit` write permission:
Additionally, save the plan to:
- **Has write permission** (e.g. `build`, `general`, `engineer`): in addition
to the in-response plan, save the plan to:
```
.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md
```
```
plans/YYYY-MM-DD-<plan-one-line-title>.md
```
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
`.opencode/plans/` directory if it does not exist.
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). Create the `plans/`
directory if it does not exist.
Always attempt the write. If the user explicitly provides a target file path,
use that path instead of the default.
- **No write permission** (e.g. the built-in `plan` agent, which denies
`edit`): do not attempt to write the file — the write tool will be
rejected. Just deliver the plan in the response. The user can copy it into
`plans/...` manually if they want it persisted.
### Plan Document Template
If the user explicitly provides a target file path, use that path instead of
the default `plans/YYYY-MM-DD-<slug>.md` (still subject to write permission).
```markdown
# Plan: [Feature/Project Name]
How to detect write permission: try the write. If it is denied, treat the
plan as response-only and proceed — do not retry, do not ask the user, and do
not mention the failed write in the response.
## Context
[One paragraph: what is the problem or feature request? Why is it needed?]
## Output Format
## Affected Modules
[Which modules of the monorepo are involved? Reference module paths and any
`mem:` memories that were consulted.]
Structure the plan as:
## Architecture Decisions
- [Key decision 1 and rationale]
- [Key decision 2 and rationale]
1. **Context** — What is the problem or feature request? Why is it needed?
2. **Affected modules** — Which parts of the codebase are involved? Reference
module paths and any `mem:` memories that were consulted.
3. **Approach** — Step-by-step implementation plan with file paths, function
names, and code shape where applicable. Group steps into atomic, ordered
tasks.
4. **Risks & considerations** — Edge cases, performance implications,
breaking changes, migration concerns, security implications.
5. **Testing strategy** — How to verify the implementation works correctly:
which test commands to run per module, what cases to cover, manual
verification steps, lint/format checks.
## Risks & Considerations
[Edge cases, performance implications, breaking changes, migration concerns,
security implications.]
Each step in **Approach** should be small enough to be reviewed and committed
independently. Cite exact file paths (`path/to/file.ext:line` when useful) so
the implementer can navigate directly.
## Approach
[Step-by-step implementation plan with file paths, function names, and code
shape where applicable. Group steps into atomic, ordered tasks.]
## Task List
### Phase 1: Foundation
- [ ] Task 1: ...
- [ ] Task 2: ...
### Checkpoint: Phase 1
- [ ] Tests pass, lint/formatter clean (module-specific commands)
### Phase 2: Core Features
- [ ] Task 3: ...
- [ ] Task 4: ...
### Checkpoint: Phase 2
- [ ] End-to-end flow works
### Phase 3: Polish
- [ ] Task 5: ...
- [ ] Task 6: ...
### Checkpoint: Complete
- [ ] All acceptance criteria met
- [ ] Ready for review
## Testing Strategy
[How to verify: which test commands to run per module, what cases to cover,
manual verification steps, lint/format checks. Consult each module's core
memory for the exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
modules, tests for already-implemented features
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
## Open Questions
- [Question needing human input]
```
When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** section and lead with
**Findings** instead, keeping the rest of the structure.
with no implementation), skip the **Approach** and **Task List** sections and
lead with **Findings** instead, keeping the rest of the structure.
## Verification Checklist
Before starting implementation, confirm:
- [ ] Every task has acceptance criteria
- [ ] Every task has a verification step
- [ ] Task dependencies are identified and ordered correctly
- [ ] No task touches more than ~5 files
- [ ] Checkpoints exist between major phases
- [ ] The human has reviewed and approved the plan

View File

@ -0,0 +1,457 @@
---
name: security-and-hardening
description: Hardens code against vulnerabilities. Use when handling user input, authentication, data storage, or external integrations. Use when building any feature that accepts untrusted data, manages user sessions, or interacts with third-party services.
---
# Security and Hardening
## Overview
Security-first development practices for web applications. Treat every external input as hostile, every secret as sacred, and every authorization check as mandatory. Security isn't a phase — it's a constraint on every line of code that touches user data, authentication, or external systems.
## When to Use
- Building anything that accepts user input
- Implementing authentication or authorization
- Storing or transmitting sensitive data
- Integrating with external APIs or services
- Adding file uploads, webhooks, or callbacks
- Handling payment or PII data
## Process: Threat Model First
Controls bolted on without a threat model are guesses. Before hardening, spend five minutes thinking like an attacker:
1. **Map the trust boundaries.** Where does untrusted data cross into your system? HTTP requests, form fields, file uploads, webhooks, third-party APIs, message queues, and **LLM output**. Every boundary is attack surface.
2. **Name the assets.** What's worth stealing or breaking? Credentials, PII, payment data, admin actions, money movement.
3. **Run STRIDE over each boundary** — a quick lens, not a ceremony:
| Threat | Ask | Typical mitigation |
|---|---|---|
| **S**poofing | Can someone impersonate a user/service? | Authentication, signature verification |
| **T**ampering | Can data be altered in transit or at rest? | Integrity checks, parameterized queries, HTTPS |
| **R**epudiation | Can an action be denied later? | Audit logging of security events |
| **I**nformation disclosure | Can data leak? | Encryption, field allowlists, generic errors |
| **D**enial of service | Can it be overwhelmed? | Rate limiting, input size caps, timeouts |
| **E**levation of privilege | Can a user gain rights they shouldn't? | Authorization checks, least privilege |
4. **Write abuse cases next to use cases.** For each feature, ask "how would I misuse this?" — then make that your first test.
If you can't name the trust boundaries for a feature, you're not ready to secure it. This is OWASP **A04: Insecure Design** — most breaches begin in design, not code.
## The Three-Tier Boundary System
### Always Do (No Exceptions)
- **Validate all external input** at the system boundary (API routes, form handlers)
- **Parameterize all database queries** — never concatenate user input into SQL
- **Encode output** to prevent XSS (use framework auto-escaping, don't bypass it)
- **Use HTTPS** for all external communication
- **Hash passwords** with bcrypt/scrypt/argon2 (never store plaintext)
- **Set security headers** (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
- **Use httpOnly, secure, sameSite cookies** for sessions
- **Run `npm audit`** (or equivalent) before every release
### Ask First (Requires Human Approval)
- Adding new authentication flows or changing auth logic
- Storing new categories of sensitive data (PII, payment info)
- Adding new external service integrations
- Changing CORS configuration
- Adding file upload handlers
- Modifying rate limiting or throttling
- Granting elevated permissions or roles
### Never Do
- **Never commit secrets** to version control (API keys, passwords, tokens)
- **Never log sensitive data** (passwords, tokens, full credit card numbers)
- **Never trust client-side validation** as a security boundary
- **Never disable security headers** for convenience
- **Never use `eval()` or `innerHTML`** with user-provided data
- **Never store sessions in client-accessible storage** (localStorage for auth tokens)
- **Never expose stack traces** or internal error details to users
## OWASP Top 10 Prevention Patterns
These are prevention patterns, not a ranking. For the 2021 ordering, see the quick-reference table in `references/security-checklist.md`.
### Injection (SQL, NoSQL, OS Command)
```typescript
// BAD: SQL injection via string concatenation
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// GOOD: Parameterized query
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// GOOD: ORM with parameterized input
const user = await prisma.user.findUnique({ where: { id: userId } });
```
### Broken Authentication
```typescript
// Password hashing
import { hash, compare } from 'bcrypt';
const SALT_ROUNDS = 12;
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
const isValid = await compare(plaintext, hashedPassword);
// Session management
app.use(session({
secret: process.env.SESSION_SECRET, // From environment, not code
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
```
### Cross-Site Scripting (XSS)
```typescript
// BAD: Rendering user input as HTML
element.innerHTML = userInput;
// GOOD: Use framework auto-escaping (React does this by default)
return <div>{userInput}</div>;
// If you MUST render HTML, sanitize first
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
```
### Broken Access Control
```typescript
// Always check authorization, not just authentication
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
const task = await taskService.findById(req.params.id);
// Check that the authenticated user owns this resource
if (task.ownerId !== req.user.id) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' }
});
}
// Proceed with update
const updated = await taskService.update(req.params.id, req.body);
return res.json(updated);
});
```
### Security Misconfiguration
```typescript
// Security headers (use helmet for Express)
import helmet from 'helmet';
app.use(helmet());
// Content Security Policy
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Tighten if possible
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}));
// CORS — restrict to known origins
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));
```
### Sensitive Data Exposure
```typescript
// Never return sensitive fields in API responses
function sanitizeUser(user: UserRecord): PublicUser {
const { passwordHash, resetToken, ...publicFields } = user;
return publicFields;
}
// Use environment variables for secrets
const API_KEY = process.env.STRIPE_API_KEY;
if (!API_KEY) throw new Error('STRIPE_API_KEY not configured');
```
### Server-Side Request Forgery (SSRF)
Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, `localhost`, private IPs).
```typescript
// BAD: fetch whatever the user gives you
await fetch(req.body.webhookUrl);
// GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('https only');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
// Resolve ALL records; a single private/reserved address fails the check.
const addrs = await lookup(url.hostname, { all: true });
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
throw new Error('private/reserved IP');
}
return url;
}
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });
```
The `range() !== 'unicast'` check covers loopback, link-local `169.254.169.254` (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6.
**Caveat — this still has a TOCTOU gap.** `fetch` resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a filtering agent in front (`request-filtering-agent` / `ssrf-req-filter`).
## Input Validation Patterns
### Schema Validation at Boundaries
```typescript
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).trim(),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.string().datetime().optional(),
});
// Validate at the route handler
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: result.error.flatten(),
},
});
}
// result.data is now typed and validated
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
```
### File Upload Safety
```typescript
// Restrict file types and sizes
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
function validateUpload(file: UploadedFile) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
throw new ValidationError('File type not allowed');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('File too large (max 5MB)');
}
// Don't trust the file extension — check magic bytes if critical
}
```
## Triaging npm audit Results
Not all audit findings require immediate action. Use this decision tree:
```
npm audit reports a vulnerability
├── Severity: critical or high
│ ├── Is the vulnerable code reachable in your app?
│ │ ├── YES --> Fix immediately (update, patch, or replace the dependency)
│ │ └── NO (dev-only dep, unused code path) --> Fix soon, but not a blocker
│ └── Is a fix available?
│ ├── YES --> Update to the patched version
│ └── NO --> Check for workarounds, consider replacing the dependency, or add to allowlist with a review date
├── Severity: moderate
│ ├── Reachable in production? --> Fix in the next release cycle
│ └── Dev-only? --> Fix when convenient, track in backlog
└── Severity: low
└── Track and fix during regular dependency updates
```
**Key questions:**
- Is the vulnerable function actually called in your code path?
- Is the dependency a runtime dependency or dev-only?
- Is the vulnerability exploitable given your deployment context (e.g., a server-side vulnerability in a client-only app)?
When you defer a fix, document the reason and set a review date.
### Supply-Chain Hygiene
`npm audit` catches known CVEs; it won't catch a malicious or typosquatted package. Also:
- **Commit the lockfile** and install with `npm ci` (not `npm install`) in CI — reproducible builds, no silent version drift.
- **Review new dependencies before adding them** — maintenance, download counts, and whether they truly earn their place. Every dependency is attack surface (OWASP **A06: Vulnerable Components**, **LLM03: Supply Chain**).
- **Be wary of `postinstall` scripts** in unfamiliar packages — they run arbitrary code at install time.
- **Watch for typosquats**`cross-env` vs `crossenv`, `react-dom` vs `reactdom`.
## Rate Limiting
```typescript
import rateLimit from 'express-rate-limit';
// General API rate limit
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
}));
// Stricter limit for auth endpoints
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
}));
```
## Secrets Management
```
.env files:
├── .env.example → Committed (template with placeholder values)
├── .env → NOT committed (contains real secrets)
└── .env.local → NOT committed (local overrides)
.gitignore must include:
.env
.env.local
.env.*.local
*.pem
*.key
```
**Always check before committing:**
```bash
# Check for accidentally staged secrets
git diff --cached | grep -i "password\|secret\|api_key\|token"
```
**If a secret is ever committed, rotate it.** Deleting the line or rewriting history is not enough — assume it's compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history.
## Securing AI / LLM Features
If your app calls an LLM — chatbots, summarizers, agents, RAG — it inherits a new attack surface. Map it to the [OWASP Top 10 for LLM Applications (2025)](https://genai.owasp.org/llm-top-10/):
- **Treat all model output as untrusted input (LLM05: Improper Output Handling).** Never pass LLM output straight into `eval`, SQL, a shell, `innerHTML`, or a file path. Validate and encode it exactly as you would raw user input.
- **Assume prompts can be hijacked (LLM01: Prompt Injection).** Untrusted text in the context window — a user message, a fetched web page, a PDF — can carry instructions. The system prompt is not a security boundary; enforce permissions in code, not in the prompt.
- **Keep secrets and other users' data out of prompts (LLM02 / LLM07).** Anything in the context can be echoed back. Don't put API keys, cross-tenant data, or the full system prompt where the model can repeat it.
- **Constrain tool and agent permissions (LLM06: Excessive Agency).** Scope tools to the minimum, require confirmation for destructive or irreversible actions, and validate every tool argument.
- **Bound consumption (LLM10: Unbounded Consumption).** Cap tokens, request rate, and loop/recursion depth so a crafted input can't run up cost or hang the system.
- **Isolate retrieval data (LLM08: Vector and Embedding Weaknesses).** In RAG, treat the vector store as a trust boundary: partition embeddings per tenant so one user can't retrieve another's data, and validate documents before indexing so poisoned content can't steer answers.
```typescript
// BAD: trusting model output as a command or as markup
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
await db.query(sql); // arbitrary query execution
container.innerHTML = await llm.reply(userMessage); // stored XSS, via the model
// GOOD: model output is data — parse defensively, then validate, then encode
let intent;
try {
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
} catch {
throw new ValidationError('unexpected model output'); // JSON.parse or schema failed
}
await runAllowlistedAction(intent.action, intent.params);
container.textContent = await llm.reply(userMessage);
```
## Security Review Checklist
```markdown
### Authentication
- [ ] Passwords hashed with bcrypt/scrypt/argon2 (salt rounds ≥ 12)
- [ ] Session tokens are httpOnly, secure, sameSite
- [ ] Login has rate limiting
- [ ] Password reset tokens expire
### Authorization
- [ ] Every endpoint checks user permissions
- [ ] Users can only access their own resources
- [ ] Admin actions require admin role verification
### Input
- [ ] All user input validated at the boundary
- [ ] SQL queries are parameterized
- [ ] HTML output is encoded/escaped
- [ ] Server-side URL fetches are allowlisted (no SSRF to internal services)
### Data
- [ ] No secrets in code or version control
- [ ] Sensitive fields excluded from API responses
- [ ] PII encrypted at rest (if applicable)
### Infrastructure
- [ ] Security headers configured (CSP, HSTS, etc.)
- [ ] CORS restricted to known origins
- [ ] Dependencies audited for vulnerabilities
- [ ] Error messages don't expose internals
### Supply Chain
- [ ] Lockfile committed; CI installs with `npm ci`
- [ ] New dependencies reviewed (maintenance, downloads, postinstall scripts)
### AI / LLM (if used)
- [ ] Model output treated as untrusted (no eval/SQL/innerHTML/shell)
- [ ] Secrets and other users' data kept out of prompts
- [ ] Tool/agent permissions scoped; destructive actions require confirmation
```
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "This is an internal tool, security doesn't matter" | Internal tools get compromised. Attackers target the weakest link. |
| "We'll add security later" | Security retrofitting is 10x harder than building it in. Add it now. |
| "No one would try to exploit this" | Automated scanners will find it. Security by obscurity is not security. |
| "The framework handles security" | Frameworks provide tools, not guarantees. You still need to use them correctly. |
| "It's just a prototype" | Prototypes become production. Security habits from day one. |
| "Threat modeling is overkill here" | Five minutes of "how would I attack this?" prevents the design flaws no control can patch later. |
| "It's just LLM output, it's only text" | That "text" can be a SQL statement, a script tag, or a shell command. Treat it like any untrusted input. |
## Red Flags
- User input passed directly to database queries, shell commands, or HTML rendering
- Secrets in source code or commit history
- API endpoints without authentication or authorization checks
- Missing CORS configuration or wildcard (`*`) origins
- No rate limiting on authentication endpoints
- Stack traces or internal errors exposed to users
- Dependencies with known critical vulnerabilities
- Server fetches user-supplied URLs without an allowlist (SSRF)
- LLM/model output passed into a query, the DOM, a shell, or `eval`
- Secrets, PII, or the full system prompt placed inside an LLM context window
## Verification
After implementing security-relevant code:
- [ ] `npm audit` shows no critical or high vulnerabilities
- [ ] No secrets in source code or git history
- [ ] All user input validated at system boundaries
- [ ] Authentication and authorization checked on every protected endpoint
- [ ] Security headers present in response (check with browser DevTools)
- [ ] Error responses don't expose internal details
- [ ] Rate limiting active on auth endpoints
- [ ] Server-side URL fetches validated against an allowlist (no SSRF)
- [ ] LLM/model output validated and encoded before use (if AI features present)

View File

@ -39,6 +39,9 @@ Backend RPC command areas without focused memories include access tokens, binfil
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
## Background tasks
@ -98,8 +101,10 @@ misleading linter/compiler output. See `mem:tools/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.

View File

@ -49,6 +49,7 @@ Components, variants, and debugging:
Text and tests:
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
## Areas without focused memories

View File

@ -4,20 +4,22 @@
## Unit tests
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS and JVM test runs.
Common tests live under `common/test/common_tests/` and use `clojure.test`.
They are CLJC and run on both JVM and JS.
From `common/`:
- Full JVM test run: `clojure -M:dev:test`
- Full JS test run: `pnpm run test:quiet`
- Full JS test run (always builds, suppressed output): `pnpm run test:quiet`
- Full JS test run (always builds, build output visible): `pnpm run test`
- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test`
- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch`
- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`
- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute`
- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`)
- Build JS test target only: `pnpm run build:test`
- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`
- Watch tests: `pnpm run watch:test`
- Build JS test target only (no run): `pnpm run build:test`
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
New common JS test namespaces must be required/listed in `common_tests/runner.cljc`;
new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags

View File

@ -6,10 +6,15 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
- A section's top-level memory is `<section>/core`. When a section is relevant, read the core memory
before focused memories.
- Edits/stale refs/duplication cleanup: `mem:memory-maintenance`.
- Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`.
# Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit``mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
@ -44,12 +49,24 @@ module. You can read it from `mem:<MODULE>/core`
When working on devenv startup, compose layout, instance config (`defaults.env`),
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
`*-devenv` commands, read `mem:devenv/core`.
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and
`taiga.py` / `gh.py` (issue management helpers).
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
# Dev tools
- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
See `mem:tools/nrepl-eval`.
- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
See `mem:tools/paren-repair`.
- `tools/psql` — PostgreSQL client wrapper with devenv defaults.
Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`.
- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the
Penpot Taiga project without authentication. See `mem:tools/taiga`.
- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`.
# Dependency graph
`frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can

View File

@ -25,7 +25,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Worker policy
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv-agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
## Port layout
@ -44,13 +44,13 @@ Everything else (frontend dev, backend API, exporter, storybook, REPLs, plugin d
## Tmux + MCP routing
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv-agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv-agentic` — the conditional windows are only added at session create time.
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv --agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv --agentic` — the conditional windows are only added at session create time.
MCP plugin routing is same-origin: frontend uses `<public-uri>/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`.
## Workspace orchestration (ws1+)
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv-agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv --agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
`sync-workspace wsN`:
1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape.
@ -63,7 +63,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## CLI surface
- `run-devenv-agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.

View File

@ -7,6 +7,7 @@
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
## HTTP and browser pool

View File

@ -0,0 +1,317 @@
# Composable component tests
A framework for systematically testing Penpot's component subsystem (synchronisation/propagation,
swaps, variant switches, nesting), plus the suite of cases built on it. Lives entirely in the
**frontend** test tree as `.cljs`; it is test-only code with a single consumer (the frontend test
suite, which runs the real app). There is nothing "common" about it — it is not under `app/common`.
## Core idea
A test is a **composition of operations** over a **situation**, plus assertions. You describe a
test as data (a setup + a sequence of operations) rather than writing bespoke imperative code, and
coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing pieces,
not a copied test. One written case stands for a whole matrix of concrete cases.
- **Situation** — the in-memory Penpot file value, plus named **roles** (meaningful shapes, e.g.
`:main-instance`/`:copy-instance`), a `:vars` map (named non-shape values), and an ordered
**applied-log** of what ran.
- **Operation** — a step, reified as a DATA record implementing `IOperation` (single method
`apply-to`; `apply` collides with core). Operations are printable, navigable, and enumerable.
Most transform the situation; some do not — hence the genus is "operation", not "transformation"
(`Test` asserts and returns the situation unchanged; `Skip` is a no-op).
- **Assertions** — inline `Test` operations placed in the sequence (assert at intermediate points)
and/or a trailing asserter (a `situation -> any` lambda calling `t/is`). The runner makes NO
judgment; it applies operations and returns the situation. Only *retrieval* helpers live outside
the test (role accessors, `has-property-of`, `applied?`).
## Principles
- **Operations are data with identity.** Each node gets a unique id at construction (`assign-id`),
records what it did under that id (`record-application`), and is interrogated by identity
(`applied?`, `get-choice`). No flat keyword-tagged log.
- **Drive the real production pipeline.** Every operation routes through the actual production
change functions (`generate-update-shapes`, `generate-component-swap`, `generate-reset-component`,
`generate-sync-file-changes`, …) — never raw field writes, or propagation would have nothing to
react to. The test exercises genuine Penpot logic, not a reimplementation.
- **The frontend runs the real app.** Synchronous file-ops apply directly to the store; event-ops
dispatch the REAL workspace events and await settlement, so the production watcher's AUTOMATIC
propagation is what's under test. Observed semantics are genuine.
- **Roles resolve to ids at setup time.** The global label→id map (`thi`) is shared and
time-varying, so a role is captured as an id when the situation is built; resolving late (across
enumerated variants) would be unsound. Absence throws a diagnostic (never silent nil).
- **Targets resolve at apply-time and may be rebound.** A target is a `(situation -> id)` FUNCTION,
else a currently-bound ROLE, else a LABEL (`target-shape-id`). So one operation targeting a role
follows that role as state-building ops re-point it — which is what lets a single operation be
swept across depth.
- **Enumeration is authored, not exhaustive.** You compose only VALID cases (every `one-of` branch
must be valid against the setup), so outcomes are just pass / fail / error — no not-applicable
cells. Adding a variation across a matrix is a one-expression edit.
- **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework
abstractions (they collide with real domain concepts). Framework vocabulary is testing concepts
only; an operation may name the domain *action* it performs (`swap`, `propagate`).
## Composition operators
- `in-sequence` — ordered application, threads the situation; enumerates to the CARTESIAN PRODUCT
of its steps' variants. Operations do not commute, so order is explicit. The workhorse.
- `one-of` — exactly one branch; applying it throws (must be enumerated); enumerates to the UNION
of branches, each wrapped in a `RecordedChoice` so `get-choice` recovers which ran.
- `optional(X)` = `one-of([X, skip])` — sweeps "with and without X" (two variants). `skip` is the
identity operation. The workhorse for adding a state-building step as an axis over a case.
- `test-that [assert-fn]` — an inline `Test`: asserts at this point in the sequence, situation
unchanged. Lets checkpoints sit at intermediate steps. Engine stays clojure.test-free (it just
calls the supplied fn).
- `applied? [situation operation]` — whether that exact node ran (identity-based). Composes with
`optional`/`one-of` for free. REQUIREMENT: bind an operation to a value ONCE and reuse it (in the
composition AND any `Test` querying it), so the id you ask about is the id that ran.
## Structure (frontend/test/frontend_tests/composable_tests/)
Two boundaries: the domain-agnostic **engine** and the **comp** subject library (about components;
naming the domain is correct there).
- `core.cljs` (ns `frontend-tests.composable-tests.core`) — the engine, one file:
- situation: `make-situation`, `file`/`with-file`, `with-aux-files`/`aux-files` (carry extra
files, e.g. a library for case H), the applied-log.
- identity & transcript: `assign-id`, `node-id`, `record-application` (also stores a `::kind`
from the record type), `node-data`, `describe-applied` (readable ordered transcript, attached
to every failure so a failing variant in a sweep is identifiable).
- roles & lookup: `role-shape` (strict, by stored id), `has-role?`, `rebind-role`/`rebind-role-id`
(re-point a role — used by state-building ops), `target-shape-id` (fn | role | label),
`shape-by-id` (read a shape whose id is held in a `:vars` object), `resolve-shape`/`-id`.
- protocols: `IOperation`(`apply-to`); `IEnumerable`(`-enumerate`) + `enumerate`.
- operators: `Sequence`/`in-sequence`; `OneOf`/`one-of`/`RecordedChoice`/`get-choice`;
`Skip`/`skip`; `optional`; `Test`/`test-that`; `applied?`; vars `set-var`/`get-var`.
- runners (clojure.test-free): `run-variant`, `run-all` (enumerate → vector, `thi/reset-idmap!`
per variant).
- per-op-interpreter helpers: `sequence-ops` (flatten a concrete variant — flattens `Sequence`,
keeps `RecordedChoice` as a unit), `recorded-choice?`, `choice-of`, `choice-one-of-id`.
- `comp/setups.cljs` — setup fns returning a situation, + role accessors (`main-instance`/
`copy-instance`/`main-root`/`copy-root`, `copy-child` 1-based): `simple-component-with-copy`,
`simple-component-with-labeled-copy`, `component-with-many-children` (E, F),
`nested-component-with-copy`, `cross-file-component-with-copy` (H: main in a linked library, copy
in the consuming file; primary `:file` = consuming, aux = library), and `empty-situation` (empty
file, no roles — the `:setup` for the sweep cases, whose first operation is `create-component`).
- `comp/nodes.cljs` — the component OPERATIONS. General edit/structure ops:
`change-property [target property value]` (`:fills`/`:opacity`) with its dual `has-property-of`
(`IPropertyCheck`); `change-attr`/`has-attr?` are aliases. `add-child`/`remove-child`/`move-child`
(with `IStructuralCheck`). `sync-from-library` (H: `generate-sync-file-changes`, libraries =
primary + aux). `undo` (I; frontend `dwu/undo`). Plus the scenario building blocks below.
- `interpreter.cljs` (ns `frontend-tests.composable-tests.interpreter`) — runs a case against the
real frontend (see "Interpreter").
- `comp/sync_test.cljs` (ns `frontend-tests.composable-tests.comp.sync-test`) — the cases;
registered in `frontend_tests/runner.cljs`.
## Scenario object model (behind the sweep cases)
Scenario ops track one or more named component LINEAGES as OBJECTS under `:vars :components` (keyed
by name, e.g. "main"). Grouping a lineage's fields into one object lets several lineages coexist
(a swap targets a DIFFERENT component) and makes each op "read object `name`, update, write back".
A lineage object has:
- `:main-component-id` — the component the next instantiate/nest uses (advances to the new OUTER
component per `make-nested-component`).
- `:remote-head`/`:remote-rect` — the FIXED deepest origin (the original component everything is
derived from). For a plain lineage this is never re-pointed; a variant nesting DOES re-point it
(see below).
- `:main-head`/`:main-rect` — the current outer main (advances per nesting).
- `:nesting-count`; `:copies`, `:copy-head`/`:copy-rect` (from `instantiate-copy`).
- `:nesting-data` — vector, one entry per level i: `{:main-head, :nested-head, :nested-rect,
:nested-head-parent}`.
- `:nested-head` is the DEEPEST instance at level i — the descendant of the level's copy head
that corresponds to `:remote-head`, found by descending the `:shape-ref` chain (at level 0 the
inner copy itself; deeper, the corresponding shape nested within the outer wrapper). This is THE
SWAP / SWITCH TARGET; it carries a `:component-id`.
- `:nested-head-parent` is the SWAP-STABLE anchor: a swap/switch replaces the head in place but
keeps its parent, so assertions re-resolve parent → current head → rect.
Accessors / targets (in nodes): `lineage-component-id`, `lineage-rect`, `lineage-copy-rect`,
`lineage-nesting`, `level-rect`/`level-rect-of` (level i's CURRENT rect via the parent anchor);
target-fns `remote-rect-of`/`main-rect-of`/`copy-rect-of` and `nested-head-of` (return a
`(situation -> id)` for use as an operation target). "Corresponds to" across layers follows the
`:shape-ref` CHAIN, matching on chain MEMBERSHIP not terminus (a copy rect refs its near-main,
which carries a further `:shape-ref`, so the terminus over-walks). Single-file setups, so the chain
resolves in the local page objects.
Scenario operations (each takes a lineage `name`):
- `create-component [name color]` — a component (frame + rect child of `color`); remote == main,
count 0.
- `make-nested-component [name]` — wrap `name`'s component in a NEW OUTER component whose main
contains a COPY of it inside a board (add-frame → instantiate inside → make-component); the OUTER
becomes `:main-component-id`; advance `:main-*`; append a `:nesting-data` entry; bump count.
ITERABLE: `×N` = board-within-board nesting with one rect at the bottom. Each level's
`:nested-head` is the deepest instance there, so swapping/switching it propagates (via the
watcher) outward to copies of it in OUTER levels.
- `instantiate-copy [name]` — instantiate `name`'s current component; track `:copy-head` and the
rect corresponding to its main rect as `:copy-rect`.
- `reset-copy-instance [name]` — reset overrides on `:copy-head` (production
`generate-reset-component`, `:validate? false` — a file-op on the frontend too: the real reset
event reads browser globals and cannot run headless).
- `swap-component [name level target & {:keys [keep-touched?]}]` — swap level `level`'s
`:nested-head` for lineage `target`'s component, via production `generate-component-swap`.
Frontend = the REAL `dwl/component-swap` event, so the watcher AUTOMATICALLY propagates the swap
to copies (incl. deeper levels). A swap replaces the head in place: Penpot keeps the head id,
rewrites it to the new component, stamps a `:swap-slot-<uuid>` touched group. `keep-touched?`
default false (discards overrides); true is the variant-switch flavour.
- Shared nesting helper `nest-in-new-outer-component [situation name op seek-rect-id seek-head-id
instantiate-inner-fn]` — the contain-outward mechanism behind BOTH nesting ops. Adds the outer
frame, calls `instantiate-inner-fn` to place the inner instance, makes the outer a component,
then computes this level's `:nested-rect`/`:nested-head` as the IMAGES (inside the new inner copy)
of `seek-rect-id`/`seek-head-id` — the FIXED deepest origin — and does the bookkeeping. A flavour
supplies only the two origin ids + the instantiate fn. Seeking the FIXED origin (not the advancing
`:main-*`) is what makes `:nested-head` land on the deepest instance at every level.
`self-or-descendant-corresponding-to` is the chain-descent that also matches the head itself
(needed at level 0, where the inner copy head IS the origin's image).
## Variant operations
A variant switch IS a keep-touched swap whose target is resolved by a property VALUE (the
production `variants-switch`/`variant-switch` reduces to `component-swap … keep-touched? true`), so
it routes through the SAME `generate-component-swap` and the watcher auto-propagates it across
nesting levels exactly like a swap.
- `make-variant-container [name members]` (sync-op) — build a variant SET synchronously, mirroring
the test-helpers' `add-variant` idiom: a container frame (`:is-variant-container`), each `members`
entry `[value color]` becoming a member component whose ROOT is a child of the container carrying
the shared `:variant-id`/`:variant-name`, then `update-component` stamps `:variant-id` +
`:variant-properties [{:name "Property 1" :value value}]` on the component. Read the container id
via `(thi/id container-label)` only AFTER adding the container frame (`thi/id` returns nil before
the shape exists). Records the set in `:vars` (`variant-set`/`variant-member`/
`variant-member-component-id` read it back).
- `make-nested-component-with-variant [name set-name value]` (sync-op) — nest a chosen member via
the shared nesting helper, AND re-point the lineage's `:remote-head`/`:remote-rect` to that
member's root/rect: nesting a variant makes the member the new deepest origin, so subsequent plain
`make-nested-component` descends to the variant's image (its `:nested-head`) at every level.
- `switch-variant [target value]` (frontend event) — switch the variant copy head bound to `target`
to the sibling member with property value `value`, via the REAL `dwv/variants-switch` event (which
DISCOVERS the sibling in the container via `find-variant-components`). `target` uses the standard
resolution (role | label | fn), so the op knows nothing about nesting; cases supply
`nested-head-of name i`.
## Interpreter (interpreter.cljs)
Drives the real app:
- `op->events [op situation]` maps each event-dispatching operation to its real workspace event(s):
`ChangeProperty``dwsh/update-shapes` (the SHARED `set-property`, target via `target-shape-id`);
`MoveChild``dwsh/relocate-shapes`; `RemoveChild``dwsh/delete-shapes`; `AddChild``dwsh/add-shape`;
`SyncFromLibrary``dwl/sync-file`; `Undo``dwu/undo`; `SwapComponent``dwl/component-swap`;
`SwitchVariant``dwv/variants-switch {:shapes [head] :pos 0 :val value}`. All real events, so the
watcher auto-propagates.
- `sync-op?` ops (`MakeNestedComponent`, `CreateComponent`, `InstantiateCopy`, `ResetCopyInstance`,
`MakeVariantContainer`, `MakeNestedComponentWithVariant`, `Skip`, `Test`) are NOT dispatched as
events: `run-sync-op` runs the shared `apply-to` against a situation whose `:file` is the live
store file, then writes back synchronously. The property under test is still exercised by the
subsequent real-event ops + the watcher.
- It installs the situation's files into the global `st/state` store (primary as current; aux files
tagged `:library-of` the current file so the library-sync machinery treats them as linked), starts
the real `watch-component-changes` and the harness `watch-undo-stack`, then folds the operations:
dispatch events → await settlement → re-read the current file into `:file` → record. Re-reading
`:file` each step is why the shared role accessors keep working.
- `watch-undo-stack` mirrors the production undo-append subscription from `initialize-workspace`
(which the harness does not run); without it `dwu/undo` has an empty stack.
- Settlement (`await-settle`): subscribe to the commit stream, resolve on the first 60ms idle gap
after a commit (captures the edit commit and the watcher's follow-up sync commit), 2000ms timeout.
Debounce-based, not a deterministic per-op stopper. After settling, `op-grace-ms` adds a per-op
grace wait (currently only `SyncFromLibrary`, ~3.2s — see the Running note).
- Thumbnail rendering is stubbed for this suite (`install-thumbnail-noop!` no-ops
`dwth/update-thumbnail`): the propagation watcher schedules thumbnail renders that reach `window`,
absent headless.
- `check [done case-map asserter]` enumerates, runs each variant via the async fold, wraps the
asserter in `describe-applied`, and calls `done`. Per-variant isolation = id-map reset + global
state re-install (the global `st/state` is a shared `defonce`).
- STORE-SWAP IMMUNITY (`original-store`/`restore-global-store!`): many plugins-suite namespaces
`set!` `st/state`/`st/stream` to isolated stores and never restore them. The `app.main.refs`
lenses (through which the watcher observes commits) are okulary lenses bound to the ORIGINAL
atom instance at load time — after such a swap, events commit to a store the watcher cannot see
and ALL propagation dies silently (assertions see base/unsynced state; no error). The interpreter
therefore captures `st/state`/`st/stream` at namespace-LOAD time (before any test runs) and
re-`set!`s them at the start of every variant, making the harness immune to run order. Diagnosed
by bisecting the runner's deterministic execution order (it is NOT the `test-namespaces` vector
order: `t/test-vars-block` groups vars by namespace, and the group-by hash order decides — same
order locally and in CI).
## Cases
Asserters are inline; no `doseq`, no count assertions.
- **B** — an override on the copy survives a later main change (override present + `:touched`
contains the fill group + `:shape-ref` present).
- **C** — attribute sweep via `one-of {fills, opacity}`: assert `(has-attr? (get-choice …) copy)`
per enumerated variant.
- **D**`add-child` to the main gives the copy a ref-integral child (`is-main-of?` +
`parent-of?` + untouched).
- **E**`remove-child` of the MIDDLE of three: survivors keep order `[child1, child3]`,
`:shape-ref` intact, untouched (middle removal is where index maintenance is tested).
- **F**`move-child` of child1 to index 2: copy mirrors `[child2, child1, child3]`, identity
preserved.
- **H** — locality (cross-file): main in a linked library, copy in the consuming (current) file,
library main diverged. The in-file watcher does not cross a library boundary; the cross-file
mechanism is the library-update action (`sync-from-library``sync-file file-id library-id`).
Setup order matters: instantiate the copy first (captures the old value), diverge the library main
after.
- **I** — undo: an edit then `undo`; copy and main return to baseline, copy untouched. A single
`dwu/undo` reverses the whole logical action — the edit AND its auto-propagation — because the two
commits share an undo-group.
- **K** — SYNC-SCENARIO SWEEP (the flagship). On `empty-situation`: `create-component` → two
`(optional make-nested-component)` (depths 0/1/2) → `instantiate-copy` → three `(optional change-*)`
over remote/main/copy → INLINE checkpoints: (1) override-precedence at the copy (copy wins, else
main, else remote — branch via `applied?`), (2) force a copy override and confirm it wins, (3)
after `reset-copy-instance`, copy reverts to main's value if main changed, else remote's. No
explicit propagate (the watcher auto-propagates at all depths, incl. chained remote→deep-copy).
- **L** — SWAP SWEEP. On `empty-situation`: `create-component` (base) + one swap-target lineage per
level → three `make-nested-component` → three `(optional (swap-component "main" i target_i))`
one `Test` asserting each level's colour. A swap at level i auto-propagates to level i and every
OUTER level until a higher swap overrides; colour at level i = applied swap at highest j<=i, else
base.
- **M** — VARIANT-SWITCH SWEEP (case L with a variant switch instead of the plain swap, driving the
REAL variant-switch machinery). On `empty-situation`: `create-component` (base lineage "main") +
`make-variant-container` (4 peer members `v0..v3`) → ONE `make-nested-component-with-variant
"main" "vset" "v0"` (introduce the variant innermost) + two plain `make-nested-component`
(progressive wraps, so each outer level CONTAINS the one below) → three
`(optional (switch-variant (nested-head-of "main" i) v_{i+1}))` → one `Test` with case L's exact
precedence asserter. Because the single variant instance has a switchable `:nested-head` at EVERY
level and the levels are progressively nested, a switch at level i propagates outward like a swap.
NOTE on structure: ONE variant + plain wraps is required for cross-level propagation (the levels
nest WITHIN each other). Three independent `make-nested-component-with-variant` would nest SIBLING
variants (none a descendant of another), so switches would not propagate between them — the right
construction for a different test (one asserting switches DON'T cross unrelated instances).
Running: `cd frontend && pnpm run build:test` then `node target/tests/test.js --focus
frontend-tests.composable-tests.comp.sync-test`. To run one case, use var-level focus, e.g.
`…/case-m-variant-switch-scenarios`. NOTE: the production `sync-file` event (case H) additionally
schedules a 3s-delayed `update-file-library-sync-status` RPC, which fails headless (no backend; a
swallowed URL-parse trace is benign). The interpreter absorbs it: `op-grace-ms` makes the run wait
~3.2s after a `SyncFromLibrary` settles, so the failure lands inside case H instead of leaking into
(and potentially destabilising) whichever test runs next.
## Frontend fidelity — read before extending
The frontend runs the REAL production logic from the dispatched event onward, so observed semantics
are genuine. But it drives a MINIMALLY-ASSEMBLED app: it installs a file into the global store and
starts only the watchers known to be needed. The real app assembles its workspace via
`initialize-workspace`, which wires many subscriptions; the harness reproduces only some.
The risk is SILENT UNDER-WIRING — a behaviour that works in the real app can be silently absent in
the harness with no error (e.g. the undo stack is empty unless `watch-undo-stack` is started).
Therefore: when adding a case needing app behaviour beyond a raw edit (undo, persistence, selection,
layout, thumbnails, library auto-detection), first check whether that behaviour lives in an
`initialize-workspace` subscription the harness has not wired — and verify by PROBING store state,
not by trusting a green assertion. The harness hand-wires two stand-ins (`install-file-event`,
`watch-undo-stack`); track them for drift. A durable fix would be to drive the real
`initialize-workspace` headlessly (not done — full init may pull in machinery that doesn't run
cleanly headless).
## Other caveats
- Cross-namespace leaks land in this suite first because it (correctly) uses the real global
store: besides the store swap above, `frontend-tests.helpers.wasm/teardown-wasm-mocks!` used to
`set!` every WASM fn to nil when run against an empty snapshot (double teardown / async misuse of
`with-wasm-mocks*`), which a leaked debounced `resize-wasm-text` event then tripped over during
our cases (a "Store error: initialized? is not a function"). The teardown is now guarded
(no-op on empty snapshot). If a new inexplicable full-run-only failure appears here, suspect
leaked global state from a preceding namespace before suspecting the framework.
- INLINE `Test` exceptions on the frontend are UNCAUGHT (they run during the async fold, not under
`check`'s try): a throwing checkpoint crashes the whole runner rather than failing one test.
- A `RecordedChoice` wrapping a `Sequence` (i.e. `(optional (in-sequence […]))`) is NOT flattened by
`sequence-ops`, so `op->events` chokes on the `Sequence`. Use independent optionals instead (as
case K does with two `(optional make-nested-component)`), or have the interpreter recurse into a
choice's composite alternative.
- The Serena symbol index / clj-kondo cache for `nodes.cljs` can go STALE and report PHANTOM symbols
or spurious "unresolved symbol" errors against code that compiles — TRUST THE BUILD (a real
`pnpm run build:test`), not the lint or the symbol overview, for this file.
- Label-after-the-fact resolution: `add-child`'s `added-shape` resolves `:new-label` via `thi/id`,
relying on the global label map still reflecting that run's setup — unsound if a structural node
is swept via `one-of`. Fix when needed by capturing the created shape's id at apply-time (as roles
already do).
## Substrate
`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`,
`mem:frontend/testing`.

View File

@ -52,6 +52,7 @@ Diagnostics and validation:
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
- Runtime crash recovery: `mem:frontend/handling-crashes`.
- Tests and live verification: `mem:frontend/testing`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
## Areas without focused memories

View File

@ -4,15 +4,18 @@ Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC
## Unit tests
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs.
Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access.
From `frontend/`:
- Full unit test run: `pnpm run test:quiet`.
- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`.
- Full unit test run (always builds, build output visible): `pnpm run test`.
- Focus a frontend CLJS test namespace: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens`.
- Focus one frontend CLJS test var: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
- Quiet `app.*` logging during a run: append `--log-level warn` (or `trace|debug|info|warn|error`).
- Build test target only: `pnpm run build:test`.
- After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
- Build test target only (no run): `pnpm run build:test`.
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
- Watch tests: `pnpm run watch:test`.
New frontend test namespaces must be required/listed in `frontend_tests/runner.cljs`; new vars in existing namespaces need no runner change.

View File

@ -7,6 +7,7 @@
- Source: `library/src/`; tests: `library/test/`; experimentation/docs: `playground/`, `docs/`; config: `shadow-cljs.edn`, `deps.edn`, `package.json`.
- From `library/`: build `pnpm run build`; bundle helper `pnpm run build:bundle` or `./scripts/build`; tests `pnpm run test`; watch `pnpm run watch` / `pnpm run watch:test`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- When changing file-format construction or export behavior in `common/`, consider whether `@penpot/library` should be tested because it constructs Penpot files outside the app UI.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
## JS API and builder state

View File

@ -85,6 +85,7 @@ From the `mcp/` directory, run
* `pnpm run build` to test the build of all packages
* `pnpm run fmt` to apply the auto-formatter
* Cross-cutting testing principles and anti-patterns: `mem:testing`.
## Devenv plugin/server wiring

View File

@ -13,6 +13,7 @@
- From `plugins/`: install `pnpm -r install`; runtime dev server `pnpm run start` or `pnpm run start:app:runtime`; sample plugin `pnpm run start:plugin:<name>`; build runtime `pnpm run build:runtime`; build plugins `pnpm run build:plugins`; lint `pnpm run lint`; format `pnpm run format:check` / `pnpm run format`; tests `pnpm run test`; e2e `pnpm run test:e2e`.
- If a change affects public Plugin API types or runtime, update `plugins/CHANGELOG.md`. Prefix type/signature entries with `**plugin-types:**`; runtime behavior entries with `**plugin-runtime:**`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- JS Plugin API behavior inside Penpot app: `mem:frontend/plugin-api-to-cljs-binding`; TS declarations are not runtime code; many API objects are CLJS proxies in `frontend/src/app/plugins/*.cljs`.
## Sandbox and global cleanup

View File

@ -26,6 +26,7 @@ From `render-wasm/`:
- Build/copy frontend artifacts: `./build`.
- Watch rebuild: `./watch`.
- Rust tests: `./test` or `cargo test <name>`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Lint: `./lint`.
- Format check: `cargo fmt --check`.

163
.serena/memories/testing.md Normal file
View File

@ -0,0 +1,163 @@
# Testing
## Overview
Tests are proof that code works. Every behavior change needs a test.
Testing in this monorepo varies by module. Each module has its own test
commands, helpers, runner registration requirements, and conventions. This
memory covers cross-cutting testing principles. For module-specific commands
and helpers, consult:
- `mem:common/testing` — CLJC unit tests (JVM + JS), test helpers, fixture
builders, production-path change helpers
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
live browser verification via nREPL
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
## When to Use
- Implementing new logic or behavior
- Fixing any bug (reproduction test required)
- Modifying existing functionality
- Adding edge case handling
**When NOT to use:** Pure configuration changes, documentation updates, or
static content changes with no behavioral impact.
## TDD: Recommended Workflow
Write a failing test before writing the code that makes it pass. For bug fixes,
reproduce the bug with a test before attempting a fix.
When TDD isn't practical (exploratory work, tight coupling to unknown APIs),
still write tests before considering the work complete.
```
RED GREEN REFACTOR
Write a test Write minimal code Clean up the
that fails ──→ to make it pass ──→ implementation ──→ (repeat)
│ │ │
▼ ▼ ▼
Test FAILS Test PASSES Tests still PASS
```
- **RED** — Write the test first. It must fail. A test that passes immediately
proves nothing.
- **GREEN** — Write the minimum code to make the test pass. Don't over-engineer.
- **REFACTOR** — With tests green, improve the code without changing behavior:
extract shared logic, improve naming, remove duplication. Run tests after
every step.
## The Prove-It Pattern (Bug Fixes)
When a bug is reported, **do not start by trying to fix it.** Start by writing
a test that reproduces it:
1. Write a test that demonstrates the bug
2. Confirm the test FAILS (proving the bug exists)
3. Implement the fix
4. Confirm the test PASSES (proving the fix works)
5. Run the full test suite for the module (no regressions)
## Core Principles
- **Test State, Not Interactions** — assert on outcomes, not method calls;
survives refactoring
- **DAMP over DRY** — tests are specifications; duplication is OK if each test
is self-contained and readable. A test should tell a complete story without
requiring the reader to trace through shared helpers.
- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock;
mock only at boundaries (network, RPC, filesystem, email)
- **Arrange-Act-Assert** — every test: setup / action / verify
- **One Assertion Per Concept** — each test verifies one behavior; split
compound assertions
- **Descriptive Test Names** — names read like specifications
## Prefer Real Implementations Over Mocks
Work down this list:
1. **Real implementation** — Test the actual code with real collaborators.
Highest confidence.
2. **Fake** — A simplified but functional in-memory implementation (e.g.
atom/dict-backed store instead of a real database).
3. **Stub** — Returns canned data. Use when the collaborator's logic is
irrelevant.
4. **Mock** — Last resort, only at boundaries. Use only when verifying
interaction with an external system that cannot be faked.
**Rule of thumb:** If you can write a fake or use the real implementation, do
that. If you find yourself asserting on call counts or invocation order, ask
whether a fake would be clearer.
## Fixtures over Manual Setup
Use fixture/`beforeEach` mechanisms for shared setup and teardown. Each test
should own its state so tests don't interfere with each other. Shorter-scope
fixtures (`:each` / per-test) are preferred; longer-scope fixtures (`:once` /
suite-level) are only for expensive, immutable shared setup.
## Parametrized Tests
Use your test framework's parametrize/table-driven mechanism to test multiple
scenarios with a single test body. Keeps tests concise and surfaces all cases
at a glance.
## Test Pyramid
```
╱╲
╲ E2E (few)
╲ Full flows, real browser/server
╱──────╲
╲ Integration (some)
╲ Cross-module, test DB
╱────────────╲
╲ Unit (most)
╲ Pure logic, fast
╱──────────────────╲
```
Prefer unit tests for pure logic. Reach for integration/E2E tests when covering
RPC handlers, database queries, or full user flows. In the frontend, Playwright
E2E tests should not be added unless explicitly requested.
## Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Testing implementation details | Breaks on refactor | Test inputs/outputs |
| Flaky tests (timing, order-dependent) | Erodes trust | Deterministic assertions, isolate state |
| Mocking everything | Tests pass, production breaks | Prefer real implementations or fakes |
| No test isolation | Pass individually, fail together | Per-test state fixtures |
| Testing framework/platform code | Wastes time | Only test YOUR code |
| Snapshot abuse | Nobody reviews, break on any change | Focused assertions |
| Skipping tests to make suite pass | Hides real failures | Fix the test or fix the code |
## Execution discipline
When running CLJS/JS tests (frontend, common):
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
When running JVM tests (backend, common):
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
- The same no-piping rule applies: use `--focus` to narrow scope.
## Verification Checklist
After completing any implementation:
- [ ] Every new behavior has a corresponding test
- [ ] All tests pass for touched modules
- [ ] Bug fixes include a reproduction test that failed before the fix
- [ ] Test names describe the behavior being verified
- [ ] No tests were skipped or disabled
- [ ] Lint/formatter passes for touched modules
- [ ] New test files registered in the module's runner/entrypoint (see module
testing memory)

View File

@ -0,0 +1,80 @@
# GitHub operations helper
`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
repository via GraphQL and REST APIs through the authenticated `gh` CLI.
## When to use
- Listing issues in a milestone (for changelog generation).
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`).
- Python 3.8+.
## Subcommands
### `issues`
List issues in a milestone, with filtering by state, labels, and project status.
```bash
# Closed issues in a milestone (default)
python3 tools/gh.py issues "2.16.0"
# All issues in a milestone
python3 tools/gh.py issues "2.16.0" --state all
# Issues with no milestone
python3 tools/gh.py issues none
python3 tools/gh.py issues none --state open
# Filter by label (include only)
python3 tools/gh.py issues "2.16.0" --label "bug"
python3 tools/gh.py issues "2.16.0" --label "bug,regression"
# Exclude by label
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
# Show only issues NOT yet in CHANGES.md
python3 tools/gh.py issues "2.16.0" --compare CHANGES.md
```
**Default filters** (override with flags):
- Issues with type "Task" are excluded (`--include-tasks` to keep them).
- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them).
**Output**: JSON array to stdout; progress to stderr.
### `prs`
Fetch PR details by number or by milestone.
```bash
# Fetch specific PRs
python3 tools/gh.py prs 9179 9204 9311
# Read PR numbers from file
python3 tools/gh.py prs --file prs.txt
# Read PR numbers from stdin
cat prs.txt | python3 tools/gh.py prs --stdin
# All PRs in a milestone (default: merged only)
python3 tools/gh.py prs --milestone "2.16.0"
# All PRs in a milestone (all states)
python3 tools/gh.py prs --milestone "2.16.0" --state all
```
**Output**: JSON array to stdout; progress to stderr.
## Key principles
- All output is JSON — pipe into `jq` or other tools for further processing.
- Milestone lookup is by exact title match.
- `issues` subcommand auto-paginates (100 items per page).
- `prs` subcommand batches PR number lookups (50 per GraphQL query).

View File

@ -0,0 +1,126 @@
# nREPL Eval
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
`tools/nrepl-eval.mjs` — a standalone CLI application.
Session state (defs, in-ns, etc.) persists across invocations via a stored
session ID, so you can build up state incrementally.
## Usage
```bash
node tools/nrepl-eval.mjs [options] [<code>]
# or
./tools/nrepl-eval.mjs [options] [<code>]
```
## Options
| Flag | Description | Default |
|------|-------------|---------|
| `--backend` | Connect to backend nREPL (port 6064) | — |
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
| `-p, --port PORT` | nREPL server port | `6064` |
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
| `--reset-session` | Discard stored session and start fresh | — |
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
| `-h, --help` | Show help message | — |
- `--backend` and `--frontend` are mutually exclusive.
- Explicit `--port` is overridden when `--backend`/`--frontend` is used.
## When to Use
1. **Evaluate Clojure code** during development — test functions, inspect
state, or run experiments against a running Clojure process.
2. **Verify that edited files compile** — require namespaces with `:reload`
to pick up changes.
3. **Inspect the last exception** after a failed evaluation — use `-e` to
print the error stored in `*e`.
## Workflow
### Session management
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
carries across calls automatically:
```bash
./tools/nrepl-eval.mjs '(def x 42)'
./tools/nrepl-eval.mjs 'x'
# => 42
```
Reset the session to start fresh:
```bash
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
```
### Evaluate code
**Single expression (inline) — uses default port 6064:**
```bash
./tools/nrepl-eval.mjs '(+ 1 2 3)'
```
**Backend nREPL (explicit):**
```bash
./tools/nrepl-eval.mjs --backend '(+ 1 2 3)'
```
**Frontend nREPL:**
```bash
./tools/nrepl-eval.mjs --frontend '(js/alert "hi")'
```
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
```bash
./tools/nrepl-eval.mjs <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Override with a different port:**
```bash
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
```
### Inspect last exception
After code throws an error, retrieve the full exception details:
```bash
./tools/nrepl-eval.mjs -e
```
## Common Patterns
**Require a namespace with reload:**
```bash
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
```
**Test a function:**
```bash
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
```
**Long-running operation with custom timeout:**
```bash
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
```
## Key Principles
- **Default port is 6064** — just pass code directly, no `-p` needed when
your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447)
as quick aliases. Use `-p <PORT>` for any other port.
- **Always use `:reload`** when requiring namespaces to pick up file changes.
- **Session is reused** across invocations — defs, in-ns, and var bindings
persist. Use `--reset-session` to clear.
- **Do not start any server** — the tool connects to an existing nREPL
server, it is not the agent's responsibility to start the nREPL server
(assume the server is already running on the specified port).

View File

@ -0,0 +1,51 @@
# PostgreSQL client wrapper
`tools/psql` is a wrapper around the `psql` command with defaults preconfigured
for the Penpot development environment.
## When to use
- Running SQL queries against the dev database (`penpot`) or test database
(`penpot_test`).
- Inspecting table structures, running migrations manually, or debugging
database state.
- Any time you need PostgreSQL access and want the correct host/user/password
without typing them each time.
## How to use
```bash
# Interactive session (penpot database)
tools/psql
# Interactive session (penpot_test database)
tools/psql --test
# Inline query (penpot)
tools/psql -c "SELECT 1"
# Inline query (penpot_test)
tools/psql --test -c "SELECT 1"
# Override defaults
tools/psql -h other-host -U other-user -d other-db
# Pipe SQL from a file
tools/psql -f some-query.sql
```
All standard `psql` flags are passed through after the wrapper's own flags.
## Defaults
| Setting | Default | Env override |
|----------|-----------|---------------------|
| Host | `postgres` | `PENPOT_DB_HOST` |
| User | `penpot` | `PENPOT_DB_USER` |
| Password | `penpot` | `PENPOT_DB_PASSWORD` |
| Database | `penpot` | `PENPOT_DB_NAME` |
## See also
`tools/db-schema` — a companion script that dumps the current DDL schema
using `pg_dump --schema-only`, with the same defaults and `--test` flag.

View File

@ -0,0 +1,44 @@
# Taiga API client
`tools/taiga.py` fetches public issues, user stories, and tasks from the
Penpot Taiga project (id 345963) without authentication.
## When to use
- Fetching details of a Taiga issue, user story, or task by URL or ref number.
- Inspecting status, assignee, tags, description, and other metadata.
- Piping structured JSON into other scripts (with `--json`).
## How to use
```bash
# Fetch by full Taiga URL
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Fetch by type and ref number
python3 tools/taiga.py issue 13714
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
# Output raw JSON instead of formatted summary
python3 tools/taiga.py --json issue 13714
python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
```
## Supported types
| Type | Description |
|------|-------------|
| `issue` | Bug reports, feature requests |
| `us` | User stories |
| `task` | Implementation tasks |
## Output
Default output is a formatted summary with title, status, assignee, author,
tags, URL, and description. Use `--json` for the raw API response.
## Prerequisites
- Python 3.8+ (standard library only, no dependencies).
- Network access to `api.taiga.io`.

View File

@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
@ -79,6 +79,8 @@ Write the body to a temp file to avoid shell quoting issues:
<version>
```
Note: do not soft-wrap paragraphs in the body. Each paragraph is a single line in the source; newlines are reserved for structural breaks (section headers, list items, code-block fences, blank-line separators). List items stay on a single line each. GitHub renders single-line paragraphs correctly, and wrapping makes diffs noisy on every small wording change. Same rule applies to PR bodies.
## Creating the Issue
```bash
@ -112,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Bug report (steps to reproduce, expected vs. actual) → Bug
- Enhancement / new feature → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task
@ -155,6 +157,199 @@ query { repository(owner: "penpot", name: "penpot") {
rm -f /tmp/issue-body.md
```
## Creating Issues from PRs
Used when the project board needs an issue as the primary changelog/release
unit and the PR describes the implementation. The issue is the **WHAT**
(user-facing), the PR is the **HOW** (implementation).
### Fetch the PR
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
```
Identify:
- **WHAT** — user-facing problem or feature. Goes into the issue.
Describe symptoms and impact, not internal mechanisms.
- **HOW** — implementation details. These belong in the PR, not the issue.
### Determine metadata
- **Title:** rewrite from user perspective using the title rules above. Strip
leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on
observable behavior.
- **Labels:** copy `community contribution` if present on the PR.
- **Milestone:** always copy what's on the PR.
```bash
gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'
```
If the PR has no milestone, create the issue without one.
- **Project:** `Main`.
- **Body:** extract the user-facing section (steps to reproduce or feature
description). Omit internal details. Use the templates above.
- **Issue Type:** use the mapping table above (also handles `:bug:` /
`:sparkles:` / `:tada:` title prefixes).
### Create the issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
### Assign to the PR author
```bash
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
```
### Set Issue Type and verify
See the **Setting the Issue Type** and **Verification** sections above — the
GraphQL mutations and `gh issue view` calls are identical regardless of how
the issue was sourced.
### Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue —
it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
### Clean up
```bash
rm -f /tmp/issue-body.md /tmp/pr-body.md
```
### Rules for this flow
- **One issue per PR** — even if a PR fixes multiple things, create a single
issue that summarizes the overall change.
- **Community attribution:** if the PR has the `community contribution`
label or the author is not a core team member, add the label to the issue.
- **Don't put implementation details in the issue body** — the issue is for
users, QA, and changelog readers.
## Creating Issues from Draft Body
Used when the user provides a draft body from elsewhere (Taiga story, user
report, discussion transcript) and there is no PR yet.
### Get the body
Read the draft body from wherever it was provided. If the user gives only a
vague one-liner, ask them to expand it (steps to reproduce, expected vs.
actual, use case) before proceeding.
### Derive the title
Apply the title rules in the **Title Derivation** section above. Distinguish
bug vs. feature from the body content:
- Steps to reproduce + expected vs. actual → bug
- "would be nice", "add support for", "allow users to" → feature / enhancement
### Choose a body template
Use the bug or enhancement template from the **Issue Body Template** section
above. Fill in placeholders with the user-provided details. If the body
doesn't fit either, ask the user which template to use.
### Determine metadata
- **Project:** `Main` (always).
- **Milestone:** ask the user if not obvious; otherwise omit.
- **Labels:** usually none for new user-reported issues. Add
`community contribution` if the user is a non-team contributor.
- **Issue Type:** use the mapping table above (bug description → Bug; feature
request → Enhancement or Feature).
### Create the issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if applicable
--milestone "<milestone>" \ # only if provided
--project "Main" \
--body-file /tmp/issue-body.md
```
### Set Issue Type and verify
Same GraphQL mutation and `gh issue view` commands as in the
**Setting the Issue Type** and **Verification** sections above.
### Clean up
```bash
rm -f /tmp/issue-body.md
```
## Retitling an Existing Issue
Used when an issue's current title is vague, prefixed, or no longer matches
the body (e.g. `[PENPOT FEEDBACK]: ...`, `feature: ...`).
### Fetch the issue
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title,body
```
### Derive a new title
Read the body (not the current title) and apply the title rules in the
**Title Derivation** section above.
### Apply the new title
```bash
gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>"
```
### Confirm
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`
- End-to-end orchestration entry point: the `create-issue` skill at
`.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
point; this memory is the canonical home for all issue-creation rules.

View File

@ -2,6 +2,20 @@
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
## Metadata
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
## Title Format
PR titles follow commit title conventions:
@ -24,7 +38,7 @@ Include concise sections covering:
PR descriptions follow this structure:
```markdown
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
**Note:** This PR was created with AI assistance.
## What
@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- Follow `mem:workflow/creating-commits` for commits
- Run the focused tests/lints appropriate to touched modules.
- Do not force-push during review unless the maintainer workflow explicitly asks for it.
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
## Creating the PR
```bash
cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
gh pr create \
--repo penpot/penpot \
--base "$TARGET" \
--head <branch> \
--title "<title>" \
--project "Main" \
--body-file /tmp/pr-body.md
rm -f /tmp/pr-body.md
```

View File

@ -1,5 +1,20 @@
# AI AGENT GUIDE
## Hard rules (always apply — no exceptions)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote).
The user pushes from their own shell. If a push is required to surface the
agent's work (e.g. force-push after an amend), state this in the response and
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
- **Never amend a commit that has been pushed** unless the user explicitly asks.
If the user pushes, treat that commit as final from the agent's side.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, AI note)
Don't infer format from the title of a previous commit/issue/PR — the memory
is the source of truth.
## CRITICAL: Read module memories BEFORE writing any code
Do this **before planning, before coding, before touching any file**:

View File

@ -64,6 +64,7 @@
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
### :bug: Bugs fixed
@ -171,6 +172,14 @@
- Fix emojis without fill but with outer stroke not being rendered [#10473](https://github.com/penpot/penpot/issues/10473) (PR: [#10519](https://github.com/penpot/penpot/pull/10519))
- Fix texts with lots of emojis not being rendered across multiple tiles [#10474](https://github.com/penpot/penpot/issues/10474) (PR: [#10504](https://github.com/penpot/penpot/pull/10504))
- Fix artifacts in texts with inner strokes [#10476](https://github.com/penpot/penpot/issues/10476) (PR: [#10509](https://github.com/penpot/penpot/pull/10509))
- Fix open overlay position control showing only center icon instead of full grid [#10176](https://github.com/penpot/penpot/issues/10176) (PR: [#10512](https://github.com/penpot/penpot/pull/10512))
- Fix wrong text color in selection size badge [#10256](https://github.com/penpot/penpot/issues/10256) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
- Fix several issues with plugins [#10394](https://github.com/penpot/penpot/issues/10394)
- Fix pixel grid rendered on top of the rulers [#10426](https://github.com/penpot/penpot/issues/10426) (PR: [#10430](https://github.com/penpot/penpot/pull/10430))
- Fix double-clicking text without fills creating a black file [#10472](https://github.com/penpot/penpot/issues/10472) (PR: [#10483](https://github.com/penpot/penpot/pull/10483))
- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492))
- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533))
- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575))
## 2.16.2

View File

@ -21,7 +21,6 @@
"scripts": {
"lint": "clj-kondo --parallel --lint ../common/src src/",
"check-fmt": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/",
"test": "clojure -M:dev:test"
"fmt": "cljfmt fix --parallel=true src/ test/"
}
}

View File

@ -331,7 +331,10 @@
This expands to a single SQL statement with placeholders for every
value being inserted. For large data sets, this may exceed the limit
of sql string size and/or number of parameters."
of sql string size and/or number of parameters.
See `insert-many-chunked!` for a safe alternative that automatically
partitions rows to stay within the parameter limit."
[ds table cols rows & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/insert-many table cols rows opts)
@ -341,6 +344,24 @@
opts (update opts :return-keys boolean)]
(jdbc/execute! conn sql opts)))
(def ^:private default-max-params
"PostgreSQL PreparedStatement parameter limit."
65535)
(defn insert-many-chunked!
"Like `insert-many!` but partitions rows into chunks that stay within
PostgreSQL's 65,535 PreparedStatement parameter limit.
The chunk size is computed as `floor(max-params / num-columns)`,
so callers do not need to calculate it. All chunks execute within
the same transaction when called inside `tx-run!`."
[ds table cols rows & {:keys [max-params] :as opts
:or {max-params default-max-params}}]
(let [chunk-size (quot max-params (count cols))
opts (dissoc opts :max-params)]
(doseq [chunk (partition-all chunk-size rows)]
(apply insert-many! ds table cols chunk (mapcat identity opts)))))
(defn update!
"A helper that build an UPDATE SQL statement and executes it.

View File

@ -246,7 +246,8 @@
(::nitrate/sso mdata true))
(fn [cfg params]
;; Resolve team/project/file from explicit keys or from :id via metadata
(let [organization-id (uuid/coerce (:organization-id params))
(let [profile-id (::profile-id params)
organization-id (uuid/coerce (:organization-id params))
id-type (::id-type mdata)
id (uuid/coerce (:id params))
team-id (or (uuid/coerce (:team-id params))
@ -255,9 +256,10 @@
(when (= id-type :project) id))
file-id (or (uuid/coerce (:file-id params))
(when (= id-type :file) id))]
(if (or organization-id team-id project-id file-id)
(if (and profile-id
(or organization-id team-id project-id file-id))
(let [cache-ref (or organization-id team-id project-id file-id)
profile-id (::profile-id params)
cache-key [profile-id cache-ref]
cached (cache/get org-sso-auth-cache cache-key)
result (if (some? cached)

View File

@ -85,9 +85,18 @@
::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))
(defn- with-nitrate-licence
[profile cfg]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(defmethod process-token :auth
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
(let [profile (profile/get-profile conn profile-id)]
(let [profile (-> (profile/get-profile conn profile-id)
(profile/strip-private-attrs)
(update :props profile/filter-props)
(with-nitrate-licence cfg))]
(assoc claims :profile profile)))
;; --- Team Invitation
@ -184,13 +193,7 @@
{:columns [:id :email :default-team-id]})
registration-disabled? (not (contains? cf/flags :registration))
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
;; Membership only makes sense for a logged-in profile; querying it for
;; an anonymous recipient would call nitrate with a nil profile-id and
;; mask the clean :invalid-token response with a generic error.
membership (when (and profile org-invitation?)
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id}))]
org-invitation? (and (contains? cf/flags :nitrate) organization-id)]
(if profile
(do
@ -201,23 +204,32 @@
:reason :email-mismatch
:hint "logged-in user does not matches the invitation"))
(when (:is-member membership)
(ex/raise :type :validation
:code :already-an-org-member
:team-id (:default-team-id membership)
:hint "the user is already a member of the organization"))
(when (and org-invitation? (not (:organization-id membership)))
(ex/raise :type :validation
:code :org-not-found
:team-id (:default-team-id profile)
:hint "the organization doesn't exist"))
(when (nil? invitation)
(ex/raise :type :validation
:code :invalid-token
:hint "no invitation associated with the token"))
:code (if organization-id :canceled-invitation :invalid-token)
:hint (if organization-id
"the invitation has been canceled"
"no invitation associated with the token")))
;; Membership only makes sense for a logged-in profile with an
;; existing invitation; querying it when the invitation is absent
;; would call nitrate needlessly and could mask the clean
;; :canceled-invitation/:invalid-token response with a generic error.
(let [membership (when org-invitation?
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id}))]
(when (:is-member membership)
(ex/raise :type :validation
:code :already-an-org-member
:team-id (:default-team-id membership)
:hint "the user is already a member of the organization"))
(when (and org-invitation? (not (:organization-id membership)))
(ex/raise :type :validation
:code :org-not-found
:team-id (:default-team-id profile)
:hint "the organization doesn't exist")))
;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the
@ -251,12 +263,17 @@
(assoc :org-team-id accepted-team-id)))))
(do
;; If the user is not logged-in and the token is invalid we throw the error
;; Taiga issue #14182
;; If the user is not logged-in and the invitation has been canceled
;; we return a specific error code so the frontend can redirect to
;; login with an appropriate message instead of showing the error page.
;; This only applies to org invitations; team invitations keep the
;; existing :invalid-token behavior.
(when (nil? invitation)
(ex/raise :type :validation
:code :invalid-token
:hint "no invitation associated with the token"))
:code (if organization-id :canceled-invitation :invalid-token)
:hint (if organization-id
"the invitation has been canceled"
"no invitation associated with the token")))
;; If we have not logged-in user, and invitation comes with member-id we
;; redirect user to login, if no member-id is present and in the invitation

View File

@ -53,7 +53,8 @@
"Authenticate the current user"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:profile}
::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile)
@ -104,30 +105,32 @@
"List teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:get-teams-result}
::sm/result schema:get-teams-result
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))
;; ---- API: upload-org-logo
;; ---- API: upload-organization-logo
(def ^:private schema:upload-org-logo
(def ^:private schema:upload-organization-logo
[:map
[:content media/schema:upload]
[:organization-id ::sm/uuid]
[:previous-id {:optional true} ::sm/uuid]])
(def ^:private schema:upload-org-logo-result
(def ^:private schema:upload-organization-logo-result
[:map [:id ::sm/uuid]])
(sv/defmethod ::upload-org-logo
(sv/defmethod ::upload-organization-logo
"Store an organization logo in penpot storage and return its ID.
Accepts an optional previous-id to mark the old logo for garbage
collection when replacing an existing one."
{::doc/added "2.17"
::sm/params schema:upload-org-logo
::sm/result schema:upload-org-logo-result}
::sm/params schema:upload-organization-logo
::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(when previous-id
(sto/touch-object! storage previous-id))
@ -195,7 +198,8 @@
"List profiles that belong to teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:managed-profile-result}
::sm/result schema:managed-profile-result
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
@ -234,7 +238,8 @@
"Get summary information for a list of teams"
{::doc/added "2.15"
::sm/params schema:get-teams-summary-params
::sm/result schema:get-teams-summary-result}
::sm/result schema:get-teams-summary-result
::nitrate/sso false}
[cfg {:keys [ids]}]
(let [;; Handle one or multiple params
ids (cond
@ -373,7 +378,8 @@ RETURNING id, deleted_at;")
"For a given user, find all owned organizations and apply the deleted-org
transfer rules to their imported Your Penpot teams."
{::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion}
::sm/params schema:notify-user-organizations-deletion
::nitrate/sso false}
[cfg {:keys [profile-id]}]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
(doseq [org owned-orgs]
@ -399,7 +405,8 @@ RETURNING id, deleted_at;")
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:email ::sm/email]]
::sm/result schema:profile}
::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [email]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
(when-not profile
@ -422,7 +429,8 @@ RETURNING id, deleted_at;")
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:id ::sm/uuid]]
::sm/result schema:profile}
::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [id]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
(when-not profile
@ -433,9 +441,9 @@ RETURNING id, deleted_at;")
(profile-to-map profile)))
;; ---- API: get-org-member-team-counts
;; ---- API: get-organization-member-team-counts
(def ^:private sql:get-org-member-team-counts
(def ^:private sql:get-organization-member-team-counts
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
FROM team_profile_rel AS tpr
JOIN team AS t ON t.id = tpr.team_id
@ -444,19 +452,19 @@ RETURNING id, deleted_at;")
AND t.is_default IS FALSE
GROUP BY tpr.profile_id;")
(def ^:private schema:get-org-member-team-counts-params
(def ^:private schema:get-organization-member-team-counts-params
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
(def ^:private schema:get-org-member-team-counts-result
(def ^:private schema:get-organization-member-team-counts-result
[:vector [:map
[:profile-id ::sm/uuid]
[:team-count ::sm/int]]])
(sv/defmethod ::get-org-member-team-counts
(sv/defmethod ::get-organization-member-team-counts
"Get the number of non-default teams each profile belongs to within a set of teams."
{::doc/added "2.15"
::sm/params schema:get-org-member-team-counts-params
::sm/result schema:get-org-member-team-counts-result
::sm/params schema:get-organization-member-team-counts-params
::sm/result schema:get-organization-member-team-counts-result
::rpc/auth false}
[cfg {:keys [team-ids]}]
(let [team-ids (cond
@ -472,29 +480,30 @@ RETURNING id, deleted_at;")
[]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:get-org-member-team-counts ids-array])))))))
(db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))
;; API: invite-to-org
;; API: invite-to-organization
(sv/defmethod ::invite-to-org
(sv/defmethod ::invite-to-organization
"Invite to organization"
{::doc/added "2.15"
::sm/params [:map
[:email ::sm/email]
[:organization schema:organization-with-avatar]]}
[:organization schema:organization-with-avatar]]
::nitrate/sso false}
[cfg params]
(db/tx-run! cfg ti/create-org-invitation params)
nil)
;; API: get-org-invitations
;; API: get-organization-invitations
(def ^:private schema:get-org-invitations-params
(def ^:private schema:get-organization-invitations-params
[:map
[:organization-id ::sm/uuid]])
(def ^:private schema:get-org-invitations-result
(def ^:private schema:get-organization-invitations-result
[:vector
[:map
[:id ::sm/uuid]
@ -505,12 +514,12 @@ RETURNING id, deleted_at;")
[:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-org-invitations
(sv/defmethod ::get-organization-invitations
"Get valid invitations for an organization, returning at most one invitation per email."
{::doc/added "2.16"
::sm/params schema:get-org-invitations-params
::sm/result schema:get-org-invitations-result
::rpc/auth false}
::sm/params schema:get-organization-invitations-params
::sm/result schema:get-organization-invitations-result
::nitrate/sso false}
[cfg {:keys [organization-id]}]
(let [team-ids (noh/get-org-team-ids cfg organization-id)]
(db/run! cfg (fn [{:keys [::db/conn]}]
@ -521,59 +530,59 @@ RETURNING id, deleted_at;")
(assoc :photo-url (files/resolve-public-uri photo-id))))))))))
;; API: delete-org-invitations
;; API: delete-organization-invitations
(def ^:private sql:delete-org-invitations
(def ^:private sql:delete-organization-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.email_to = ?
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
(def ^:private schema:delete-org-invitations-params
(def ^:private schema:delete-organization-invitations-params
[:map
[:organization-id ::sm/uuid]
[:email ::sm/email]])
(sv/defmethod ::delete-org-invitations
(sv/defmethod ::delete-organization-invitations
"Delete all invitations for one email in an organization scope (org + org teams)."
{::doc/added "2.16"
::sm/params schema:delete-org-invitations-params
::rpc/auth false}
::sm/params schema:delete-organization-invitations-params
::nitrate/sso false}
[cfg {:keys [organization-id email]}]
(let [clean-email (profile/clean-email email)
team-ids (noh/get-org-team-ids cfg organization-id)]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array]))))
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
nil))
;; API: delete-all-org-invitations
;; API: delete-all-organization-invitations
(def ^:private sql:delete-all-org-invitations
(def ^:private sql:delete-all-organization-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.org_id = ?
OR ti.team_id = ANY(?);")
(def ^:private schema:delete-all-org-invitations-params
(def ^:private schema:delete-all-organization-invitations-params
[:map
[:organization-id ::sm/uuid]])
(sv/defmethod ::delete-all-org-invitations
(sv/defmethod ::delete-all-organization-invitations
"Delete every pending invitation associated with an organization (org-level + team-level).
Called from Nitrate when an organization is about to be deleted, so users that click
their invitation token hit the existing invalid-token landing page."
{::doc/added "2.18"
::sm/params schema:delete-all-org-invitations-params
::sm/params schema:delete-all-organization-invitations-params
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [team-ids (noh/get-org-team-ids cfg organization-id)]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:delete-all-org-invitations organization-id ids-array]))))
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
nil))
;; API: remove-from-org
;; API: remove-from-organization
(def ^:private sql:get-reassign-to
"SELECT tpr.profile_id
@ -598,7 +607,7 @@ RETURNING id, deleted_at;")
(assoc team-to-transfer :reassign-to reassign-to)))
(sv/defmethod ::remove-from-org
(sv/defmethod ::remove-from-organization
"Remove an user from an organization"
{::doc/added "2.17"
::sm/params [:map
@ -606,7 +615,8 @@ RETURNING id, deleted_at;")
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]
[:default-team-id ::sm/uuid]]
::db/transaction true}
::db/transaction true
::nitrate/sso false}
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
@ -625,16 +635,16 @@ RETURNING id, deleted_at;")
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
nil))
;; API: get-remove-from-org-summary
;; API: get-remove-from-organization-summary
(def ^:private schema:get-remove-from-org-summary-result
(def ^:private schema:get-remove-from-organization-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
(sv/defmethod ::get-remove-from-org-summary
(sv/defmethod ::get-remove-from-organization-summary
"Get a summary of the teams that would be deleted, transferred, or exited
if the user were removed from the organization"
{::doc/added "2.17"
@ -642,8 +652,9 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:default-team-id ::sm/uuid]]
::sm/result schema:get-remove-from-org-summary-result
::db/transaction true}
::sm/result schema:get-remove-from-organization-summary-result
::db/transaction true
::nitrate/sso false}
[cfg {:keys [profile-id organization-id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
@ -690,8 +701,8 @@ RETURNING id, deleted_at;")
:organizations organizations}))))
nil)
;; API: exists-org-team-invitations-for-non-members /
;; delete-org-team-invitations-for-non-members
;; API: exists-organization-team-invitations-for-non-members /
;; delete-organization-team-invitations-for-non-members
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
@ -718,7 +729,7 @@ RETURNING id, deleted_at;")
[:team-ids [:vector ::sm/uuid]]
[:member-ids [:vector ::sm/uuid]]])
(def ^:private schema:exists-org-team-invitations-for-non-members-result
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
[:map [:exists ::sm/boolean]])
(defn- org-team-invitations-for-non-members-arrays
@ -740,20 +751,22 @@ RETURNING id, deleted_at;")
emails-array])
:non-member)))
(sv/defmethod ::exists-org-team-invitations-for-non-members
(sv/defmethod ::exists-organization-team-invitations-for-non-members
"Return if there are any team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params
::sm/result schema:exists-org-team-invitations-for-non-members-result}
::sm/result schema:exists-organization-team-invitations-for-non-members-result
::nitrate/sso false}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
(sv/defmethod ::delete-org-team-invitations-for-non-members
(sv/defmethod ::delete-organization-team-invitations-for-non-members
"Delete team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params
::db/transaction true}
::db/transaction true
::nitrate/sso false}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [emails-array teams-array]}
@ -869,7 +882,7 @@ RETURNING id, deleted_at;")
{::doc/added "2.20"
::sm/params schema:get-teams-detail-params
::sm/result schema:get-teams-detail-result
::rpc/auth false}
::nitrate/sso false}
[cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams org-summary))]
@ -901,8 +914,8 @@ RETURNING id, deleted_at;")
[cfg params]
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
;; ---- API: notify-org-sso-change
(sv/defmethod ::notify-org-sso-change
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
"Nitrate notifies that an organization sso values have changed"
{::doc/added "2.19"
::sm/params [:map

View File

@ -65,7 +65,6 @@
(assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd
(seq prlimit)
(into (prlimit-cmd prlimit)))
@ -74,6 +73,9 @@
_ (reduce-kv set-env env-map env)
process (.start builder)]
(when-not executor
(throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor")))
(if in
(px/run! executor
(fn []

View File

@ -369,7 +369,7 @@
(t/is (= :not-found (th/ex-type (:error ko-out))))
(t/is (= :profile-not-found (th/ex-code (:error ko-out))))))
(t/deftest get-org-invitations-returns-valid-deduped-by-email
(t/deftest get-organization-invitations-returns-valid-deduped-by-email
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
@ -377,7 +377,7 @@
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
params {::th/type :get-org-invitations
params {::th/type :get-organization-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
@ -439,12 +439,12 @@
(t/is (nil? (:role dedup)))
(t/is (nil? (:valid-until dedup))))))
(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams
(t/deftest get-organization-invitations-includes-org-level-invitations-when-no-teams
(let [profile (th/create-profile* 1 {:is-active true})
org-id (uuid/random)
org-summary {:id org-id
:teams []}
params {::th/type :get-org-invitations
params {::th/type :get-organization-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
@ -468,7 +468,7 @@
(t/is (= "org-only@example.com" (-> result first :email)))
(t/is (some? (-> result first :sent-at))))))
(t/deftest get-org-invitations-returns-existing-profile-data
(t/deftest get-organization-invitations-returns-existing-profile-data
(let [profile (th/create-profile* 1 {:is-active true})
invited (th/create-profile* 2 {:is-active true
:fullname "Invited User"})
@ -479,7 +479,7 @@
org-id (uuid/random)
org-summary {:id org-id
:teams []}
params {::th/type :get-org-invitations
params {::th/type :get-organization-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
@ -504,7 +504,7 @@
(t/is (str/ends-with? (:photo-url invitation)
(str "/assets/by-id/" photo-id))))))
(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email
(t/deftest delete-organization-invitations-removes-org-and-org-team-invitations-for-email
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
@ -514,7 +514,7 @@
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
target-email "target@example.com"
params {::th/type :delete-org-invitations
params {::th/type :delete-organization-invitations
::rpc/profile-id (:id profile)
:organization-id org-id
:email "TARGET@example.com"}]
@ -572,7 +572,7 @@
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
(t/is (= 1 (count remaining-other))))))
(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations
(t/deftest delete-all-organization-invitations-removes-org-and-org-team-invitations
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
@ -581,7 +581,7 @@
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
params {::th/type :delete-all-org-invitations
params {::th/type :delete-all-organization-invitations
:organization-id org-id}]
;; Should be deleted: org-level invitation.
@ -660,10 +660,10 @@
(t/is (present? "dan@example.com"))
(t/is (present? "erin@example.com")))))
(t/deftest delete-all-org-invitations-handles-org-with-no-teams
(t/deftest delete-all-organization-invitations-handles-org-with-no-teams
(let [profile (th/create-profile* 1 {:is-active true})
org-id (uuid/random)
params {::th/type :delete-all-org-invitations
params {::th/type :delete-all-organization-invitations
:organization-id org-id}]
;; Org-level invitation should still be deleted.
@ -686,14 +686,14 @@
(t/is (nil? (:result out)))
(t/is (empty? remaining)))))
(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete
(t/deftest exists-organization-team-invitations-for-non-members-reports-invitations-to-delete
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
base-params {::th/type :exists-org-team-invitations-for-non-members
base-params {::th/type :exists-organization-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
@ -744,14 +744,14 @@
:valid-until (ct/in-future "24h")})
(t/is (true? (exist!)))))
(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations
(t/deftest delete-organization-team-invitations-for-non-members-removes-non-member-invitations
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
params {::th/type :delete-org-team-invitations-for-non-members
params {::th/type :delete-organization-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
@ -830,7 +830,7 @@
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: remove-from-org
;; Tests: remove-from-organization
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- make-org-summary
@ -853,7 +853,7 @@
:remove-profile-from-org nil
nil)))
(t/deftest remove-from-org-happy-path-no-extra-teams
(t/deftest remove-from-organization-happy-path-no-extra-teams
;; User is only in its default team (which has files); it should be
;; kept, renamed and unset as default. A notification must be sent.
(let [org-owner (th/create-profile* 1 {:is-active true})
@ -875,7 +875,7 @@
mbus/pub! (fn [_bus & {:keys [topic message]}]
(swap! calls conj {:topic topic :message message}))]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -898,7 +898,7 @@
(t/is (= "Acme Org" (:organization-name msg)))
(t/is (= "dashboard.user-no-longer-belong-org" (:notification msg))))))
(t/deftest remove-from-org-deletes-empty-default-team
(t/deftest remove-from-organization-deletes-empty-default-team
;; When the default team has no files it should be soft-deleted.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -913,7 +913,7 @@
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -923,7 +923,7 @@
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))
(t/deftest remove-from-org-deletes-sole-owner-team
(t/deftest remove-from-organization-deletes-sole-owner-team
;; When the user is the sole member of an org team it should be deleted.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -939,7 +939,7 @@
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -949,7 +949,7 @@
(let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))
(t/deftest remove-from-org-transfers-ownership-of-multi-member-team
(t/deftest remove-from-organization-transfers-ownership-of-multi-member-team
;; When the user owns a team that has another non-owner member, ownership
;; is transferred to that member by the endpoint automatically.
(let [org-owner (th/create-profile* 1 {:is-active true})
@ -970,7 +970,7 @@
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -984,7 +984,7 @@
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})]
(t/is (true? (:is-owner rel))))))
(t/deftest remove-from-org-exits-non-owned-team
(t/deftest remove-from-organization-exits-non-owned-team
;; When the user is a non-owner member of an org team, they simply leave.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1003,7 +1003,7 @@
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1017,7 +1017,7 @@
(let [team (th/db-get :team {:id (:id extra-team)})]
(t/is (some? team)))))
(t/deftest remove-from-org-error-nobody-to-reassign
(t/deftest remove-from-organization-error-nobody-to-reassign
;; When the user owns a multi-member team but every other member is
;; also an owner, the auto-selection query finds nobody and raises.
(let [other-owner (th/create-profile* 1 {:is-active true})
@ -1041,7 +1041,7 @@
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
{::th/type :remove-from-organization
::rpc/profile-id (:id other-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1051,10 +1051,10 @@
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :nobody-to-reassign-team (th/ex-code (:error out))))))
;; Tests: get-remove-from-org-summary
;; Tests: get-remove-from-organization-summary
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest get-remove-from-org-summary-no-extra-teams
(t/deftest get-remove-from-organization-summary-no-extra-teams
;; User only has a default team — nothing to delete/transfer/exit.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1068,7 +1068,7 @@
:org-teams [])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
{::th/type :get-remove-from-organization-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1080,7 +1080,7 @@
:teams-to-detach 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-delete
(t/deftest get-remove-from-organization-summary-with-teams-to-delete
;; User owns a sole-member extra org team → 1 to delete.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1095,7 +1095,7 @@
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
{::th/type :get-remove-from-organization-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1107,7 +1107,7 @@
:teams-to-detach 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
(t/deftest get-remove-from-organization-summary-with-teams-to-transfer
;; User owns a multi-member extra org team → 1 to transfer.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1126,7 +1126,7 @@
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
{::th/type :get-remove-from-organization-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1138,7 +1138,7 @@
:teams-to-detach 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-exit
(t/deftest get-remove-from-organization-summary-with-teams-to-exit
;; User is a non-owner member of an org team → 1 to exit.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1156,7 +1156,7 @@
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
{::th/type :get-remove-from-organization-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1168,7 +1168,7 @@
:teams-to-detach 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-does-not-mutate
(t/deftest get-remove-from-organization-summary-does-not-mutate
;; Calling the summary endpoint must not modify any teams.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
@ -1183,7 +1183,7 @@
:org-teams [(:id extra-team)])
_ (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
{::th/type :get-remove-from-organization-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
@ -1201,7 +1201,7 @@
(let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
(t/is (some? rel2)))))
(t/deftest notify-org-sso-change-sends-setup-sso-email-once-per-recipient
(t/deftest notify-organization-sso-change-sends-setup-sso-email-once-per-recipient
(let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"})
member (th/create-profile* 2 {:is-active true
:fullname "Member"
@ -1216,7 +1216,7 @@
:name org-name
:teams [{:id (:id team)}]}
sent (atom [])
params {::th/type :notify-org-sso-change
params {::th/type :notify-organization-sso-change
:organization-id org-id
:updated-props false
:became-active true}]
@ -1269,9 +1269,9 @@
(t/is (= org-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest notify-org-sso-change-skips-email-when-not-active
(t/deftest notify-organization-sso-change-skips-email-when-not-active
(let [sent (atom [])
params {::th/type :notify-org-sso-change
params {::th/type :notify-organization-sso-change
:organization-id (uuid/random)
:updated-props false
:became-active false}]

View File

@ -8,12 +8,17 @@
(:require
[app.common.exceptions :as ex]
[app.util.shell :as shell]
[app.worker :as-alias wrk]
[clojure.string :as str]
[clojure.test :as t]))
[clojure.test :as t]
[promesa.exec :as px]))
(def ^:private system
{::wrk/executor (px/cached-executor)})
(t/deftest exec-normal-completes
(t/testing "normal process completes within timeout"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -21,7 +26,7 @@
(t/deftest exec-captures-stderr
(t/testing "stderr is captured separately"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo out; echo err >&2"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -30,14 +35,14 @@
(t/deftest exec-non-zero-exit
(t/testing "non-zero exit code is captured"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "exit 42"]
:timeout 10)]
(t/is (= 42 (:exit result))))))
(t/deftest exec-with-env
(t/testing "environment variables are passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo $MY_VAR"]
:env {"MY_VAR" "test-value"}
:timeout 10)]
@ -46,7 +51,7 @@
(t/deftest exec-with-input
(t/testing "stdin input is passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["cat"]
:in "hello from stdin"
:timeout 10)]
@ -57,7 +62,7 @@
(t/testing "process that exceeds timeout is killed and raises exception"
(let [start (System/currentTimeMillis)]
(try
(shell/exec! {}
(shell/exec! system
:cmd ["sleep" "60"]
:timeout 1)
(t/is false "should have thrown")
@ -72,14 +77,14 @@
(t/deftest exec-no-timeout-waits
(t/testing "without timeout, process runs to completion"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["sleep" "0.1"]
:timeout nil)]
(t/is (= 0 (:exit result))))))
(t/deftest exec-prlimit-normal
(t/testing "normal process completes within prlimit"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:prlimit {:mem 256 :cpu 10}
:timeout 10)]
@ -88,7 +93,7 @@
(t/deftest exec-prlimit-cpu
(t/testing "process exceeding CPU limit is killed"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "while true; do :; done"]
:prlimit {:cpu 2}
:timeout 10)]
@ -98,7 +103,7 @@
(t/testing "process exceeding memory limit is killed"
;; Use python3 to allocate more memory than the limit allows.
;; This test requires python3 to be available in the environment.
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["python3" "-c"
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
:prlimit {:mem 256}

View File

@ -29,8 +29,7 @@
"lint": "pnpm run lint:clj",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test:js": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:jvm": "clojure -M:dev:test"
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js"
}
}

View File

@ -1,18 +1,23 @@
import { spawnSync } from "node:child_process";
const BUILD_STEPS = [
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
];
const progress = (msg) => process.stderr.write(`${msg}\n`);
progress("Building test bundle...");
const build = spawnSync("pnpm", ["run", "build:test"], {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (build.status !== 0) {
progress("Building test bundle failed");
if (build.stdout?.length) process.stdout.write(build.stdout);
if (build.stderr?.length) process.stderr.write(build.stderr);
process.exit(build.status ?? 1);
for (const step of BUILD_STEPS) {
progress(`${step.label}...`);
const result = spawnSync(step.cmd, step.args, {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
progress(`${step.label} failed`);
if (result.stdout?.length) process.stdout.write(result.stdout);
if (result.stderr?.length) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
}
}
progress("Running tests...");
@ -21,5 +26,4 @@ const result = spawnSync(
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);

View File

@ -500,9 +500,9 @@
shape-transform (:transform shape)
shape-transform-inv (:transform-inverse shape)
shape-center (gco/shape->center shape)
{sr-width :width sr-height :height} (:selrect shape)
{sr-width :width sr-height :height} (safe-size-rect shape)
origin (cond-> (gpt/point (:selrect shape))
origin (cond-> (gpt/point (safe-size-rect shape))
(some? shape-transform)
(gmt/transform-point-center shape-center shape-transform))

View File

@ -392,7 +392,9 @@
([shape]
(convert-to-path shape {}))
([shape objects]
(-> (stp/convert-to-path shape objects)
(update :content impl/path-data))))
(let [shape' (stp/convert-to-path shape objects)]
(if (identical? shape shape')
shape'
(update shape' :content impl/path-data)))))
(dm/export impl/decode-segments)

View File

@ -347,6 +347,22 @@
(+ pad-top pad-top)
(+ pad-top pad-bottom))))
(defn padding-type-for
"`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)."
[{:keys [p1 p2 p3 p4]}]
(if (and (mth/close? (d/nilv p1 0) (d/nilv p3 0))
(mth/close? (d/nilv p2 0) (d/nilv p4 0)))
:simple
:multiple))
(defn margin-type-for
"`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)."
[{:keys [m1 m2 m3 m4]}]
(if (and (mth/close? (d/nilv m1 0) (d/nilv m3 0))
(mth/close? (d/nilv m2 0) (d/nilv m4 0)))
:simple
:multiple))
(defn child-min-width
[child]
(if (and (fill-width? child)

View File

@ -657,3 +657,35 @@
mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45)
result (ctm/apply-structure-modifiers shape mods)]
(t/is (mth/close? 45.0 (:rotation result))))))
;; ─── change-orientation-modifiers — degenerate selrect ────────────────────────
(defn- make-degenerate-shape
"Build a shape whose selrect has zero width/height, simulating a shape
decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)."
[x y selrect-width selrect-height]
(let [shape (make-shape x y 100 50)]
(assoc shape :selrect (grc/map->Rect {:x x :y y
:width selrect-width
:height selrect-height
:x1 x :y1 y
:x2 (+ x selrect-width)
:y2 (+ y selrect-height)}))))
(t/deftest change-orientation-zero-width-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-shape 0 0 0 50)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-height-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-shape 0 0 100 0)
mods (ctm/change-orientation-modifiers shape :vert)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw
(t/testing "orientation change on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-shape 0 0 0 0)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))

View File

@ -1370,6 +1370,15 @@
;; A path shape stays a path shape unchanged
(t/is (= :path (:type result)))))
(t/deftest shape-to-path-svg-raw-does-not-throw
(let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0
:selrect (make-selrect 0.0 0.0 100.0 50.0)
:content {:tag :text :attrs {:style {}}
:content [{:tag :tspan :attrs {} :content ["x"]}]}}
result (path/convert-to-path shape {})]
(t/is (= :svg-raw (:type result)))
(t/is (some? (:content result)))))
(t/deftest shape-to-path-rect-with-radius
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0
:r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0

View File

@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13
ENV OPENCODE_VERSION=1.18.1
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -197,10 +197,10 @@ services:
- penpot
environment:
<< : [*penpot-secret-key]
<< : [*penpot-secret-key, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0

View File

@ -651,6 +651,21 @@ PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061
These variables are used for generate correct nginx.conf file on container startup.
### Exporter
The exporter uses this variable:
```bash
# Exporter
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
```
- `PENPOT_INTERNAL_URI`: The URI used by the exporter's headless browser to
communicate with the frontend (internal Docker network). Defaults to
`PENPOT_PUBLIC_URI` if not set. The default value
`http://penpot-frontend:8080` used in the docker-compose is a good default and
it is recommended to keep it unchanged.
## Other flags
There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant

View File

@ -1,30 +1,22 @@
---
title: 3.11. Agentic Development Environment
title: 3.11. Agentic Dev Environment
desc: Dive into agentic Penpot development.
---
# Agentic Development Environment
# Agentic Dev Environment
The agentic DevEnv is an extension of the standard DevEnv (the
[general DevEnv instructions](/technical-guide/developer/devenv/) apply),
optimised for AI agent-based development. It adds MCP servers (Penpot,
Serena, Playwright) and supports a launcher that wires them into your AI client.
Two things to know up front:
- **Parallel workspaces are first-class.** Run several devenv instances side
by side - one per AI agent if you like - each with its own source-tree
clone, ports, and tmux session. Pass `--ws N` to target one.
- **Your existing AI-client config is preserved.** The launcher loads a
per-workspace MCP config on top of your global one.
## Quick Start
1. **Bring up one or more workspaces**[^cfg]:
```bash
./manage.sh run-devenv-agentic # ws0 (the live repo)
./manage.sh run-devenv-agentic --ws 1 # ws1 (sibling clone)
./manage.sh run-devenv --agentic --attach # ws0 (the live repo)
./manage.sh run-devenv --agentic --ws 1 # ws1 (sibling clone)
```
Add `--ws 2`, `--ws 3`, … for more parallel workspaces.
@ -47,20 +39,20 @@ Two things to know up front:
"MCP Server" on. The agentic DevEnv runs the MCP server in single-user
mode - the key and proxied URL shown in the UI are not needed.
4. **Launch your AI client** against the workspace you want it to drive:
4. **Launch your AI client**. Either use your manually configured client
(as described below) or conveniently launch an explicitly supported client
against the workspace you want it to drive:
```bash
./manage.sh start-coding-agent claude # ws0
./manage.sh start-coding-agent claude # ws0 (main)
./manage.sh start-coding-agent claude --ws 1 # ws1
```
Supported clients: `claude` | `opencode` | `vscode` | `codex`.
5. **Attach to the tmux session** for the workspace (optional):
Note: The launcher loads a per-workspace MCP config on top of your global one (if any).
```bash
./manage.sh attach-devenv # ws0
./manage.sh attach-devenv --ws 1 # ws1
```
5. **Work within your client**, which is now equipped with extended capabilities
for Penpot development (see below for details).
6. **Shut down workspaces** with `./manage.sh stop-devenv`, either one by one or all at once.
You cannot shut down `ws0` if any other workspace is still running, since it's the worker-bearer.
@ -113,10 +105,10 @@ var penpotFlags = "enable-mcp";
```
The file is gitignored and lives in the live repo only. On every
`run-devenv-agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is
`run-devenv --agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is
copied into the workspace clone on the **initial** sync only - subsequent
`--sync` passes leave the workspace's copy alone so per-workspace
customisations survive. `run-devenv-agentic` refuses to start if the file is
customisations survive. `run-devenv --agentic` refuses to start if the file is
missing.
**Browser remote debugging.** The Playwright MCP server drives a real
@ -148,13 +140,13 @@ devenv image itself (add a tool, change a base layer):
./manage.sh build-devenv --local
```
The default `run-devenv-agentic` flow pulls the published image
The default `run-devenv --agentic` flow pulls the published image
automatically, so regular users never run this.
### Bringing up workspaces
```bash
./manage.sh run-devenv-agentic \
./manage.sh run-devenv --agentic \
[--ws N] [--sync] [--serena-context CTX] \
[--git-user-name NAME] [--git-user-email EMAIL]
```
@ -171,7 +163,7 @@ every bring-up so you don't compute offsets by hand. See the
semantics, and stop ordering.
**Git identity for agent commits.** Coding agents typically need to commit
inside the devenv, so `run-devenv-agentic` wires a Git identity into the
inside the devenv, so `run-devenv --agentic` wires a Git identity into the
container's global config on every bring-up. By default it propagates the
host's effective `git config user.{name,email}` (local repo override wins
over `~/.gitconfig`, matching what `git commit` on the host would record).
@ -189,7 +181,7 @@ the full mechanics.
>
> ```bash
> ./manage.sh stop-devenv
> ./manage.sh run-devenv-agentic
> ./manage.sh run-devenv --agentic
> ```
### Launching an AI client
@ -198,7 +190,7 @@ The agentic environment supports any AI client, one just needs to set the right
see [manual configuration](#manual-ai-client-configuration) below. For some popular clients, the `manage.sh`
CLI offers direct support through the following mechanism:
Every `run-devenv-agentic` regenerates three MCP-client config files with
Every `run-devenv --agentic` regenerates three MCP-client config files with
the workspace's ports baked in; Codex is wired up at launch instead (see
below):

View File

@ -45,31 +45,38 @@ This is an incomplete list of devenv related subcommands found on
manage.sh script:
```bash
./manage.sh build-devenv --local # builds the local devenv docker image
./manage.sh start-devenv # brings up the shared infra + ws0 in background
./manage.sh run-devenv # ws0 with non-agentic tmux, attached (legacy alias)
./manage.sh run-devenv-agentic # one agentic instance; --ws to target ws1+; see below
./manage.sh attach-devenv # re-attaches to the tmux session of a running instance
./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last
./manage.sh drop-devenv # removes containers (data volumes preserved)
./manage.sh build-devenv --local # builds the local devenv docker image
./manage.sh start-devenv # brings up the shared infra + ws0 in background
./manage.sh run-devenv --attach # bring up main devenv instance and attach to its tmux session
./manage.sh run-devenv --agentic --attach # bring up main devenv instance in agentic mode and attach tmux
./manage.sh attach-devenv # re-attaches to the tmux session of a running instance
./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last
./manage.sh drop-devenv # removes containers (data volumes preserved)
```
### Agentic Mode
The `--agentic` flag enables additional features for AI-assisted development.
See the dedicated section [Agentic Dev Environment](../agentic-devenv/) for details.
### Parallel workspaces
The devenv runs as separate compose projects: shared infra (`penpotdev-infra`:
Postgres, MinIO, mailer, LDAP) plus one `penpotdev-wsN` project per runtime
instance. `ws0` (a.k.a. `main`) binds the live repo; `ws1+` bind clones the
developer maintains explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/`
(default `~/.penpot/penpot_workspaces/`).
The devenv runs as separate compose projects:
* shared infra (`penpotdev-infra`: Postgres, MinIO, mailer, LDAP)
* `penpotdev-wsN` project per runtime instance.
- `ws0` (a.k.a. `main`) is the current state of your repo;
- `ws1` and up are clones that you maintain explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/`
(default `~/.penpot/penpot_workspaces/`). You can explicitly sync them
with the `--sync` flag (automatic on first start).
Each call to `run-devenv-agentic` brings up one instance, and ws0 is always
Each call to `run-devenv` brings up one instance, and ws0 is always
running whenever any ws1+ is — `--ws N` (N≥1) auto-starts ws0 first if it
isn't already up:
```bash
./manage.sh run-devenv-agentic # main (ws0)
./manage.sh run-devenv-agentic --ws 1 # ws0 if needed, then ws1
./manage.sh run-devenv-agentic --ws 2 --sync # ws2, re-seeding from the live repo
./manage.sh run-devenv # main (ws0)
./manage.sh run-devenv --ws 1 # ws0 if needed, then ws1
./manage.sh run-devenv --ws 2 --sync # ws2, re-seeding from the live repo
```
Starting an instance that is already running is an error. `--sync` is only
@ -102,12 +109,12 @@ Host ports are offset by `10000 × N`:
| Serena MCP | `http://localhost:14181` | `http://localhost:24181` | `http://localhost:34181` |
Container-internal ports stay fixed. Target a specific instance with
`--ws N` on `attach-devenv`, `run-devenv-agentic`, `stop-devenv`,
`--ws N` on `attach-devenv`, `run-devenv`, `stop-devenv`,
`start-coding-agent`, `run-devenv-shell`, and `isolated-shell`. `--ws`
accepts a **non-negative integer only**`--ws main` or `--ws ws1` is
rejected, keeping the flag shape uniform across commands. `run-devenv` is
ws0-only and takes no workspace flag. `run-devenv-agentic` also accepts
`--serena-context CTX` and `--git-user-name NAME` / `--git-user-email
ws0-only and takes no workspace flag. `run-devenv` also accepts
`--serena-context CTX` (used together with `--agentic`) and `--git-user-name NAME` / `--git-user-email
EMAIL` (see below).
Configuration lives in one tracked file, `docker/devenv/defaults.env` (the
@ -116,7 +123,7 @@ derived and injected automatically, so there is no per-instance file to edit.
### Git identity inside the container
`run-devenv-agentic` wires a Git author identity into the container's
`run-devenv` wires a Git author identity into the container's
**global** git config (`git config --global user.{name,email}`) so commits
made from inside the devenv carry a real author/committer. Without this,
the container would commit as the unconfigured `penpot@<container>`
@ -130,7 +137,7 @@ returns at the working directory `manage.sh` is invoked from — local
`git commit` on the host would record. If neither is available the script
prints a warning and continues — commits will fail inside the container
until you set an identity. The values are applied every time
`run-devenv-agentic` brings an instance up (idempotent), so re-running
`run-devenv` brings an instance up (idempotent), so re-running
with different flags is the way to change the in-container identity.
### Shared state and workers
@ -185,11 +192,10 @@ docker rm penpotdev-postgres-1 penpotdev-minio-1 penpotdev-minio-setup-1 \
docker network rm penpotdev_default 2>/dev/null
# Bring up infra + ws0 under the new project layout.
./manage.sh run-devenv-agentic
./manage.sh run-devenv
```
After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` /
`run-devenv-agentic` commands work against the new layout. The legacy
After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` work against the new layout. The legacy
`penpotdev` compose project is no longer used.
Having the container running and tmux opened inside the container,

View File

@ -24,11 +24,11 @@ desc: Master responsive web design with Penpot's flexible and grid layouts! Lear
<h3 id="layouts-flex-add">Add Flex Layout</h3>
<p>You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout Flex is added the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:</p>
<p>You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout is added, the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:</p>
<ul>
<li>From the Design panel at the right sidebar.</li>
<li>From the option at the selection menu (right click button).</li>
<li>Pressing <kbd>Ctrl/⌘</kbd> + <kbd>A</kbd>.</li>
<li>Pressing <kbd>Shift/⇧</kbd> + <kbd>A</kbd>.</li>
</ul>
<figure><img src="/img/flexible-layouts/layouts-add.webp" alt="Adding Layouts" /></figure>

View File

@ -22,6 +22,7 @@
(def ^:private defaults
{:public-uri "http://localhost:3449"
:internal-uri nil
:tenant "default"
:host "localhost"
:http-server-port 6061
@ -33,6 +34,7 @@
[:map {:title "config"}
[:secret-key :string]
[:public-uri {:optional true} ::sm/uri]
[:internal-uri {:optional true} ::sm/uri]
[:exporter-shared-key {:optional true} :string]
[:host {:optional true} :string]
[:tenant {:optional true} :string]
@ -100,6 +102,12 @@
([key default]
(c/get config key default)))
(defn get-internal-uri
"Returns internal-uri if set, otherwise falls back to public-uri."
[]
(or (c/get config :internal-uri)
(c/get config :public-uri)))
(def management-key
(let [key (or (c/get config :exporter-shared-key)
(let [secret-key (c/get config :secret-key)

View File

@ -21,6 +21,7 @@
[& _]
(l/info :msg "initializing"
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(p/do!
(bwr/init)

View File

@ -79,7 +79,7 @@
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]

View File

@ -62,7 +62,7 @@
:skip-children skip-children
:wasm (when is-wasm "true")
:scale scale}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))]

View File

@ -77,7 +77,7 @@
(on-object (assoc object :path path))
(p/recur (rest objects))))))]
(let [base-uri (-> (cf/get :public-uri)
(let [base-uri (-> (cf/get-internal-uri)
(u/ensure-path-slash))]
(bw/exec! (prepare-options base-uri)
(partial render base-uri)))))

View File

@ -108,6 +108,18 @@
{:width width
:height height}))
(defn- replace-internal-uris
"Replaces internal-uri references with public-uri in SVG output.
This ensures that font URLs and other resource references in the
exported SVG use the public-facing URI accessible to end users."
[svg-content]
(let [internal-uri (str (cf/get-internal-uri))
public-uri (str (cf/get :public-uri))]
(if (and (not= internal-uri public-uri)
(str/includes? svg-content internal-uri))
(str/replace svg-content internal-uri public-uri)
svg-content)))
(defn render
[{:keys [page-id file-id share-id objects token scale type]} on-object]
(letfn [(convert-to-ppm [pngpath]
@ -320,7 +332,9 @@
result (if (contains? cf/flags :exporter-svgo)
(svgo/optimize result svgo/defaultOptions)
result)]
result)
result (replace-internal-uris result)]
;; (println "------- ORIGIN:")
;; (cljs.pprint/pprint (xml->clj xmldata))
@ -349,7 +363,7 @@
:render-embed true
:object-id (mapv :id objects)
:route "objects"}
uri (-> (cf/get :public-uri)
uri (-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "render.html")
(assoc :query (u/map->query-string params)))]

View File

@ -15,6 +15,7 @@
},
"scripts": {
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:fonts-preview": "node ./scripts/build-fonts-preview.js",
"build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
@ -33,7 +34,7 @@
"lint:js": "exit 0",
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
"test": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js",
"test": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:storybook": "vitest run --project=storybook",
"watch:test": "mkdir -p target/tests && concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests --exec 'node target/tests/test.js'\"",

View File

@ -8,7 +8,7 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35",
"immutable": "^5.1.9"
},
"peerDependencies": {

View File

@ -1,449 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
immutable:
specifier: ^5.1.4
version: 5.1.4
react:
specifier: '>=0.17.0'
version: 19.2.3
react-dom:
specifier: '>=0.17.0'
version: 19.2.3(react@19.2.3)
devDependencies:
esbuild:
specifier: ^0.27.2
version: 0.27.2
packages:
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
react-dom: '>=0.14.0'
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
fbjs-css-vars@1.0.2:
resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==}
fbjs@3.0.5:
resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==}
immutable@3.7.6:
resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
engines: {node: '>=0.8.0'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
asap@2.0.6: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
fbjs: 3.0.5
immutable: 3.7.6
object-assign: 4.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- encoding
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
fbjs-css-vars@1.0.2: {}
fbjs@3.0.5:
dependencies:
cross-fetch: 3.2.0
fbjs-css-vars: 1.0.2
loose-envify: 1.4.0
object-assign: 4.1.1
promise: 7.3.1
setimmediate: 1.0.5
ua-parser-js: 1.0.41
transitivePeerDependencies:
- encoding
immutable@3.7.6: {}
immutable@5.1.4: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-assign@4.1.1: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react@19.2.3: {}
scheduler@0.27.0: {}
setimmediate@1.0.5: {}
tr46@0.0.3: {}
ua-parser-js@1.0.41: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1

View File

@ -28,6 +28,92 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => {
);
});
test("Submenu closes when hovering a menu option without submenu", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("About Penpot").hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
await dashboardPage.userProfileOption.hover();
await expect(changelogSubmenuItem).toBeHidden();
});
test("Submenu stays open while moving the pointer into it", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
const aboutPenpotItem = page.getByText("About Penpot");
await aboutPenpotItem.hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
// Walk the pointer from the parent option into the submenu the way a
// real user does — gradually, crossing the gap between the two menus.
const from = await aboutPenpotItem.boundingBox();
const to = await changelogSubmenuItem.boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
steps: 20,
});
await expect(changelogSubmenuItem).toBeVisible();
});
test("Submenu closes when the pointer leaves the menu entirely", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("About Penpot").hover();
const changelogSubmenuItem = page.getByText("Penpot Changelog");
await expect(changelogSubmenuItem).toBeVisible();
await dashboardPage.mainHeading.hover();
await expect(changelogSubmenuItem).toBeHidden();
});
test("Hovering another expandable option switches submenus", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await page.getByText("Help & Learning").hover();
const helpCenterSubmenuItem = page.getByText("Help Center");
await expect(helpCenterSubmenuItem).toBeVisible();
await page.getByText("About Penpot").hover();
await expect(page.getByText("Penpot Changelog")).toBeVisible();
await expect(helpCenterSubmenuItem).toBeHidden();
});
test("Submenu opens with keyboard navigation", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.openProfileMenu();
await dashboardPage.sidebarMenu
.getByRole("menuitem", { name: "Help & Learning" })
.press("Enter");
await expect(page.getByText("Help Center")).toBeVisible();
});
test("Opens release notes from current version from profile menu", async ({
page,
}) => {

View File

@ -129,3 +129,27 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({
await workspace.waitForSelectedShapeName("hello world");
});
test("BUG 10531 - Entering the editor auto-selects the whole text", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
// Select the existing text shape and enter edit mode via Enter
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
// Copying while editing exports only the selected text as raw text.
// Since we just entered the editor, the whole text should be selected.
await workspace.copy("keyboard");
// Assert the text was copied correctly
const copiedText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(copiedText).toBe("Lorem ipsum");
});

View File

@ -263,8 +263,8 @@ importers:
packages/draft-js:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35
version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
immutable:
specifier: ^5.1.9
version: 5.1.9
@ -2886,8 +2886,8 @@ packages:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35:
resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
@ -8117,7 +8117,7 @@ snapshots:
domelementtype: 3.0.0
domhandler: 6.0.1
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
fbjs: 3.0.5(encoding@0.1.13)
immutable: 3.8.3

View File

@ -15,6 +15,8 @@ import pLimit from "p-limit";
import ppt from "pretty-time";
import wpool from "workerpool";
import { buildFontsPreviewSprite } from "./build-fonts-preview.js";
function getCoreCount() {
return os.cpus().length;
}
@ -48,8 +50,9 @@ async function findFiles(basePath, predicate, options = {}) {
return files;
}
function syncDirs(originPath, destPath) {
const command = `rsync -ar --delete ${originPath} ${destPath}`;
function syncDirs(originPath, destPath, excludes = []) {
const excludeArgs = excludes.map((p) => `--exclude=${p}`).join(" ");
const command = `rsync -ar --delete ${excludeArgs} ${originPath} ${destPath}`;
return new Promise((resolve, reject) => {
proc.exec(command, (cause, stdout) => {
@ -540,6 +543,36 @@ export async function compileSvgSprites() {
}
}
export async function compileFontsPreviewSprite() {
const start = process.hrtime();
log.info("init: compile fonts preview sprite");
let error = false;
let result;
try {
result = await buildFontsPreviewSprite();
} catch (cause) {
error = cause;
}
const end = process.hrtime(start);
if (error) {
log.error("error: compile fonts preview sprite", `(${ppt(end)})`);
console.error(error);
} else if (result.skipped) {
log.info(
"done: compile fonts preview sprite (up-to-date, skipped)",
`(${ppt(end)})`,
);
} else {
log.info(
`done: compile fonts preview sprite (${result.ok} ok, ${result.failed} fallback)`,
`(${ppt(end)})`,
);
}
}
export async function compileTemplates() {
const start = process.hrtime();
let error = false;
@ -584,7 +617,11 @@ export async function copyAssets() {
log.info("init: copy assets");
await syncDirs("resources/images/", "resources/public/images/");
await syncDirs("resources/fonts/", "resources/public/fonts/");
// The font preview sprite is generated into public/fonts/ (not committed), so
// exclude it from --delete to keep it across builds (see compileFontsPreviewSprite).
await syncDirs("resources/fonts/", "resources/public/fonts/", [
"fonts-preview-sprite.svg",
]);
const end = process.hrtime(start);
log.info("done: copy assets", `(${ppt(end)})`);

View File

@ -3,6 +3,7 @@ import * as h from "./_helpers.js";
await h.ensureDirectories();
await h.compileStyles();
await h.copyAssets();
await h.compileFontsPreviewSprite();
await h.copyWasmPlayground();
await h.compileSvgSprites();
await h.compileTranslations();

View File

@ -0,0 +1,384 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
// Builds one SVG sprite previewing every catalog (built-in + Google) font name,
// outlined in its own typeface, so the picker loads it once instead of one
// request per font. Custom uploads aren't baked in and use the runtime fallback.
//
// Part of the asset build (compileFontsPreviewSprite in _helpers.js):
// regenerates only when missing or older than the gfonts catalog. Run directly
// to force a rebuild. See the technical-guide doc for the full design.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import ph from "node:path";
import url from "node:url";
import opentype from "opentype.js";
// Coordinates are emitted in CSS px (1 user unit == 1px), so the UI sizes rows
// without per-font metrics. Each name is laid out on its baseline then fitted
// into a fixed BOX_HEIGHT box (see buildSymbol). See the technical-guide doc.
const FONT_SIZE = 18; // visual cap/x-height target, matches the label typography
const BOX_HEIGHT = 28; // display box height in px; keep in sync with the scss
const VPAD = 1; // px of breathing room top/bottom before a name is scaled to fit
// Decimals of precision. 1 (≈0.1px grid) keeps glyphs smooth; 0 makes them
// wobbly. Sprite is ~2.3MB gzip at 1 with the relative encoding (serializePath).
const PATH_PRECISION = 1;
const CACHE_DIR = ph.join(os.tmpdir(), "penpot-fonts-preview-cache");
const CONCURRENCY = 24;
// Written straight to the served dir (like the SVG sprite), and excluded from
// copyAssets' `rsync --delete` so it survives builds — see the doc / _helpers.js.
export const OUTPUT = "resources/public/fonts/fonts-preview-sprite.svg";
// Color fonts (COLR/SVG glyph tables) make opentype.js take a browser-only
// DOMParser path that rejects a promise and crashes Node. We only need vector
// outlines, so swallow those rejections and the COLR warning during generation
// (restored afterwards so other build steps are unaffected).
function installFontParsingGuards() {
const onRejection = () => {};
process.on("unhandledRejection", onRejection);
const origWarn = console.warn;
console.warn = (msg, ...rest) => {
if (typeof msg === "string" && msg.includes("COLR")) return;
origWarn(msg, ...rest);
};
return () => {
process.off("unhandledRejection", onRejection);
console.warn = origWarn;
};
}
// Mirror of cuerdas.core/slug as used by the `parse-gfont` macro in
// src/app/main/fonts.clj so that ids match `(str "gfont-" (str/slug family))`.
function slug(value) {
return value
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function findGfontsJson() {
const dir = "resources/fonts";
const entries = await fs.readdir(dir);
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
if (matches.length === 0) {
throw new Error(`no gfonts.*.json found in ${dir}`);
}
return ph.join(dir, matches[matches.length - 1]);
}
async function readCatalog() {
const builtin = [
{
id: "sourcesanspro",
name: "Source Sans Pro",
source: {
type: "file",
path: "resources/fonts/sourcesanspro-regular.ttf",
},
},
];
const gfontsPath = await findGfontsJson();
const raw = JSON.parse(await fs.readFile(gfontsPath, "utf-8"));
const google = (raw.items || []).map((item) => {
const family = item.family;
// Prefer the "menu" subset: a tiny TTF Google ships containing exactly the
// glyphs needed to render the family name in a font picker.
const url =
item.menu ||
(item.files && (item.files.regular || Object.values(item.files)[0]));
return {
id: `gfont-${slug(family)}`,
name: family,
source: { type: "url", url },
};
});
return [...builtin, ...google];
}
async function fetchWithCache(url) {
const key = createHash("sha1").update(url).digest("hex") + ".ttf";
const cached = ph.join(CACHE_DIR, key);
try {
return await fs.readFile(cached);
} catch {
// not cached yet
}
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await fs.writeFile(cached, buf);
return buf;
} catch (err) {
lastErr = err;
}
}
throw lastErr;
}
async function loadFontBuffer(source) {
if (source.type === "file") return fs.readFile(source.path);
if (!source.url) throw new Error("missing font url");
return fetchWithCache(source.url);
}
// Lay the name out glyph-by-glyph with sanitized advances, NOT via
// `font.getPath`: some menu subsets have a broken kern/NaN advance that emits
// `NaN` into the path, which makes browsers stop parsing mid-path (only the
// first glyph renders). Baseline at y=0; buildSymbol re-centers afterwards.
// `hasTofu` flags glyphs the subset lacks (.notdef → "tofu" boxes), dropping the
// font to the runtime fallback.
function renderName(font, text) {
const scale = FONT_SIZE / font.unitsPerEm;
const path = new opentype.Path();
let x = 0;
let hasTofu = false;
for (const ch of text) {
const glyph = font.charToGlyph(ch);
if (glyph.index === 0 && ch.trim() !== "") hasTofu = true;
path.extend(glyph.getPath(x, 0, FONT_SIZE));
const advance = glyph.advanceWidth;
x += (Number.isFinite(advance) ? advance : font.unitsPerEm * 0.5) * scale;
}
return { path, hasTofu };
}
// Round to PATH_PRECISION decimals (normalizing -0). Done BEFORE deltas are
// taken (serializePath) so the relative encoding never drifts.
function roundGrid(value) {
return Number(value.toFixed(PATH_PRECISION)) + 0;
}
// Format an (already-rounded) number compactly: drop the integer "0" from
// "0.x"/"-0.x" to save bytes (".x"/"-.x" are valid SVG numbers).
function fmt(n) {
let s = n.toString();
if (s.startsWith("0.")) s = s.slice(1);
else if (s.startsWith("-0.")) s = "-" + s.slice(2);
return s;
}
// Serialize paths ourselves (opentype's `toPathData` separates numbers with
// spaces only — ambiguous enough that browsers bail mid-path). Format: comma
// WITHIN a pair, space BETWEEN pairs. Commands are RELATIVE (lowercase m/l/q/c)
// for much smaller, better-compressing output (~6.2MB → ~2.3MB gzip), with no
// geometry change. Curve control points are relative to the pen before the
// command, and `z` returns the pen to the subpath start — both tracked below.
// Coordinates are baked through the affine fit (scale `s`, translate tx/ty).
function serializePath(commands, s, tx, ty) {
const ax = (v) => roundGrid(v * s + tx);
const ay = (v) => roundGrid(v * s + ty);
let out = "";
// px/py: current pen (rounded, absolute). sx/sy: start of the current subpath,
// which the pen snaps back to on `z`.
let px = 0,
py = 0,
sx = 0,
sy = 0;
// Re-round the delta: subtracting two grid values reintroduces float noise
// (13.5 - 10.9 === 2.6000000000000014). Drift-free, since the pen tracks the
// exact rounded absolute (x/y), not the emitted delta.
const d = (to, from) => fmt(roundGrid(to - from));
for (const c of commands) {
switch (c.type) {
case "M": {
const x = ax(c.x),
y = ay(c.y);
out += `m${d(x, px)},${d(y, py)}`;
px = sx = x;
py = sy = y;
break;
}
case "L": {
const x = ax(c.x),
y = ay(c.y);
out += `l${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "Q": {
const x1 = ax(c.x1),
y1 = ay(c.y1),
x = ax(c.x),
y = ay(c.y);
out += `q${d(x1, px)},${d(y1, py)} ${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "C": {
const x1 = ax(c.x1),
y1 = ay(c.y1),
x2 = ax(c.x2),
y2 = ay(c.y2),
x = ax(c.x),
y = ay(c.y);
out +=
`c${d(x1, px)},${d(y1, py)} ` +
`${d(x2, px)},${d(y2, py)} ${d(x, px)},${d(y, py)}`;
px = x;
py = y;
break;
}
case "Z":
out += "z";
px = sx;
py = sy;
break;
}
}
return out;
}
function buildSymbol({ id, name }, buffer) {
const ab = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
);
const font = opentype.parse(ab);
// Drop the SVG color-glyph table to force outline rendering (see above).
font.tables.svg = undefined;
const { path, hasTofu } = renderName(font, name);
// Drop fonts whose name needs glyphs the subset lacks — they would render as
// .notdef boxes; the runtime fallback loads the real font instead.
if (hasTofu) throw new Error("missing glyphs (tofu)");
const bb = path.getBoundingBox();
if (!Number.isFinite(bb.x1) || bb.x2 <= bb.x1) throw new Error("empty path");
// Fit into BOX_HEIGHT, centered by bounding box so tall ascenders/descenders
// aren't clipped; names taller than the box are scaled down. Left-aligned at 0.
const usable = BOX_HEIGHT - 2 * VPAD;
const height = bb.y2 - bb.y1;
const s = height > usable ? usable / height : 1;
const tx = -bb.x1 * s;
const ty = BOX_HEIGHT / 2 - ((bb.y1 + bb.y2) / 2) * s;
const d = serializePath(path.commands, s, tx, ty);
if (!d || d.length === 0) throw new Error("empty path");
// A stray NaN (non-finite outline point) would truncate the glyph in the
// browser; drop the font so it cleanly falls back to the runtime loader.
if (d.includes("NaN")) throw new Error("non-finite path");
// No `fill`: the UI's <use> provides `currentColor` so it follows the theme.
return `<g id="font-preview-${id}"><path d="${d}"/></g>`;
}
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index], index);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker),
);
return results;
}
// True when the sprite exists and is at least as new as the gfonts catalog, so
// the build can skip the expensive, network-bound regeneration.
export async function isSpriteUpToDate(outputPath = OUTPUT) {
try {
const gfontsPath = await findGfontsJson();
const [outStat, gfontsStat] = await Promise.all([
fs.stat(outputPath),
fs.stat(gfontsPath),
]);
return outStat.mtimeMs >= gfontsStat.mtimeMs;
} catch {
// output missing (or no catalog) → not up to date
return false;
}
}
// Regenerate the sprite into `outputPath`. Skips (returns `{ skipped: true }`)
// when the output is already up to date, unless `force` is set. On a real
// rebuild it returns `{ skipped:false, ok, failed, bytes }`.
export async function buildFontsPreviewSprite({
outputPath = OUTPUT,
force = false,
} = {}) {
if (!force && (await isSpriteUpToDate(outputPath))) {
return { skipped: true };
}
const restoreGuards = installFontParsingGuards();
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
const catalog = await readCatalog();
console.log(`building font preview sprite for ${catalog.length} fonts…`);
let ok = 0;
const failed = [];
const symbols = await mapLimit(catalog, CONCURRENCY, async (font) => {
try {
const buffer = await loadFontBuffer(font.source);
const symbol = buildSymbol(font, buffer);
ok++;
if (ok % 200 === 0) console.log(`${ok}/${catalog.length}`);
return symbol;
} catch (err) {
failed.push({ id: font.id, reason: err.message });
return null;
}
});
const body = symbols.filter(Boolean).join("");
const sprite =
`<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` +
body +
`</svg>\n`;
await fs.mkdir(ph.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, sprite);
if (failed.length) {
console.log(
`font preview sprite: ${failed.length} fonts will use the runtime fallback:`,
);
for (const f of failed.slice(0, 40))
console.log(` - ${f.id}: ${f.reason}`);
if (failed.length > 40) console.log(` …and ${failed.length - 40} more`);
}
return { skipped: false, ok, failed: failed.length, bytes: sprite.length };
} finally {
restoreGuards();
}
}
// When invoked directly (`node scripts/build-fonts-preview.js`), force a full
// rebuild regardless of the up-to-date check.
const isDirectRun =
process.argv[1] &&
import.meta.url === url.pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const res = await buildFontsPreviewSprite({ force: true });
const mb = (res.bytes / 1024 / 1024).toFixed(1);
console.log(
`done: ${res.ok} ok, ${res.failed} failed → ${OUTPUT} (${mb} MB, served gzipped)`,
);
}

View File

@ -63,6 +63,7 @@ async function compileSass(path) {
await h.ensureDirectories();
await compileSassAll();
await h.copyAssets();
await h.compileFontsPreviewSprite();
await h.copyWasmPlayground();
await h.compileTranslations();
await h.compileSvgSprites();

View File

@ -196,6 +196,11 @@
(get :path)
(str "?version=" version-tag)))
(def fonts-preview-sprite-uri
(-> public-uri
(u/join "fonts/fonts-preview-sprite.svg")
(str)))
(defn external-feature-flag
[flag value]
(let [f (obj/get global "externalFeatureFlag")]

View File

@ -503,7 +503,7 @@
(-> state
(update :comments-local assoc :open id)
(update :comments-local assoc :options nil)
(update :comments-local dissoc :draft)))))
(update :comments-local dissoc :draft :expanded)))))
(defn close-thread
[]
@ -511,8 +511,26 @@
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local dissoc :open :draft :options :expanded)))))
(defn expand-comment-group
"Temporarily mark a proximity cluster of threads as expanded so its bubbles
can be laid out visually without altering their stored positions."
[thread-ids]
(ptk/reify ::expand-comment-group
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local assoc :expanded (set thread-ids))
(update :comments-local dissoc :open :draft :options)))))
(defn collapse-comment-group
[]
(ptk/reify ::collapse-comment-group
ptk/UpdateEvent
(update [_ state]
(update state :comments-local dissoc :expanded))))
(defn update-filters
[{:keys [mode show list] :as params}]
(ptk/reify ::update-filters

View File

@ -59,7 +59,7 @@
(st/emit! (rt/nav-raw :href "/admin-console/")))
([{:keys [organization-id organization-slug]}]
(if (and organization-id organization-slug)
(let [href (dm/str "/admin-console/org/"
(let [href (dm/str "/admin-console/organization/"
(u/percent-encode organization-slug)
"/"
(u/percent-encode (str organization-id))
@ -67,9 +67,9 @@
(st/emit! (rt/nav-raw :href href)))
(st/emit! (rt/nav-raw :href "/admin-console/")))))
(defn go-to-nitrate-ac-create-org
(defn go-to-nitrate-ac-create-organization
[]
(st/emit! (rt/nav-raw :href "/admin-console/?action=create-org")))
(st/emit! (rt/nav-raw :href "/admin-console/?action=create-organization")))
(defn can-send-invitations?
[{:keys [organization profile-id team-permissions]}]

View File

@ -65,6 +65,7 @@
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwva]
[app.main.data.workspace.viewport :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.data.workspace.zoom :as dwz]
[app.main.errors]
[app.main.features :as features]
@ -441,6 +442,14 @@
;; Keep comment thread positions in sync on undo/redo
(rx/of (dwcm/watch-comment-thread-position-changes stoper-s))
;; Resize auto-grow text shapes whose selrect does not match
;; the WASM text layout once their fonts finish loading.
(->> stream
(rx/filter (ptk/type? :app.render-wasm.api/stale-text-selrects))
(rx/map deref)
(rx/map (fn [{:keys [ids]}]
(dwwt/resize-wasm-text-all ids))))
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
@ -1559,6 +1568,7 @@
(dm/export dwcp/paste-shapes)
(dm/export dwcp/paste-data-valid?)
(dm/export dwcp/copy-link-to-clipboard)
(dm/export dwcp/copy-id-to-clipboard)
(dm/export dwcp/copy-as-image)
;; Drawing

View File

@ -1138,6 +1138,15 @@
(watch [_ _ _]
(clipboard/to-clipboard (rt/get-current-href)))))
(defn copy-id-to-clipboard
[id]
(ptk/reify ::copy-id-to-clipboard
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from (clipboard/to-clipboard id))
(rx/map (fn [_]
(ntf/info "The id has been copied to the clipboard")))))))
(defn copy-as-image
[]
(ptk/reify ::copy-as-image

View File

@ -24,7 +24,7 @@
[app.main.data.workspace.drawing :as dwd]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz]
[app.main.repo :as rp]
[app.main.router :as rt]
@ -71,7 +71,7 @@
(rx/take-until stopper-s))))))
(defn- handle-interrupt
(defn handle-interrupt
[]
(ptk/reify ::handle-interrupt
ptk/WatchEvent
@ -79,15 +79,15 @@
(let [local (:comments-local state)
comments-mode? (= :comments (get-in state [:workspace-drawing :tool]))]
(cond
(:draft local) (rx/of (dcmt/close-thread))
(:open local) (rx/of (dcmt/close-thread))
(:draft local) (rx/of (dcmt/close-thread))
(:open local) (rx/of (dcmt/close-thread))
(:expanded local) (rx/of (dcmt/collapse-comment-group))
;; Only clear edition / deselect on interrupt while the comments
;; tool is active. When comments are merely visible during design,
;; `select-shape` emits `:interrupt` and this would otherwise wipe
;; the freshly selected shape, breaking click selection.
comments-mode? (rx/of (dwe/clear-edition-mode)
(dws/deselect-all true))
:else (rx/empty))))))
comments-mode? (rx/of (dwe/clear-edition-mode))
:else (rx/empty))))))
;; Event responsible of the what should be executed when user clicked
;; on the comments layer. An option can be create a new draft thread,
@ -98,17 +98,28 @@
(ptk/reify ::handle-comment-layer-click
ptk/WatchEvent
(watch [_ state _]
(if (not= :comments (get-in state [:workspace-drawing :tool]))
(rx/empty)
(let [local (:comments-local state)]
(if (some? (:open local))
(rx/of (dcmt/close-thread))
(let [page-id (:current-page-id state)
file-id (:current-file-id state)
params {:position position
:page-id page-id
:file-id file-id}]
(rx/of (dcmt/create-draft params)))))))))
(let [local (:comments-local state)
comments-mode? (= :comments (get-in state [:workspace-drawing :tool]))]
(cond
;; A click anywhere collapses a temporarily separated cluster,
;; regardless of the active tool. Opening a thread clears :expanded,
;; so this never fires while a comment is open.
(some? (:expanded local))
(rx/of (dcmt/collapse-comment-group))
(not comments-mode?)
(rx/empty)
(some? (:open local))
(rx/of (dcmt/close-thread))
:else
(let [page-id (:current-page-id state)
file-id (:current-file-id state)
params {:position position
:page-id page-id
:file-id file-id}]
(rx/of (dcmt/create-draft params))))))))
(defn center-to-comment-thread
[{:keys [position] :as thread}]
@ -127,7 +138,11 @@
nh (- (/ (:height vbox) 2) ph)
nx (- (:x position) nw)
ny (- (:y position) nh)]
(update local :vbox assoc :x nx :y ny)))))))
(update local :vbox assoc :x nx :y ny)))))
ptk/EffectEvent
(effect [_ state _]
(dwvw/maybe-sync-workspace-local-viewport! state))))
(defn- set-comment-thread
"Stores the comment thread in the workspace state so its bubble re-renders."
@ -305,6 +320,25 @@
distance-overlap 32]
(< distance-zoom distance-overlap)))
(defn group-bubbles
"Group bubbles into vectors by proximity: each group holds threads whose
bubbles overlap at the given `zoom`."
[zoom circles]
(letfn [(overlaps-group? [current group]
(some #(overlap-bubbles? zoom current %) group))
(find-overlapping-group [groups current]
(some #(when (overlaps-group? current %) %) groups))
(add-to-group [groups target current]
(map #(if (= % target) (cons current %) %) groups))
(assign [groups current]
(if-let [group (find-overlapping-group groups current)]
(add-to-group groups group current)
(cons [current] groups)))]
(reduce assign [] circles)))
(defn- calculate-zoom-scale-to-ungroup-current-bubble
"Calculate the minimum zoom scale needed to keep the current bubble ungrouped from the rest"
[zoom thread threads]
@ -373,7 +407,7 @@
(rx/empty))
(->> (rx/of
(dwd/select-for-drawing :comments)
(set-zoom-to-separate-grouped-bubbles thread)
;; Center on the comment (no zoom) and open its thread.
(center-to-comment-thread thread)
(with-meta (dcmt/open-thread thread) {::ev/origin "workspace"}))
(rx/observe-on :async))))))

View File

@ -53,7 +53,7 @@
:add #{:tokens}}})
(def valid-options-mode
#{:design :prototype :inspect})
#{:design :prototype :inspect :debug})
(def default-layout
#{:sitemap

View File

@ -256,8 +256,8 @@
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
(gpt/point (:selrect shape)))
(let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape))
(gpt/point (ctm/safe-size-rect shape)))
position-data
(-> position-data
(gsh/move-position-data deltav))]
@ -838,8 +838,13 @@
(dwsh/update-shapes ids update-shape options)
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true)))
;; needs to have the updated children info.
;; `update-layout? false`: recalculating a bool path can never change
;; `:hidden`, and the layout check would recompute the whole boolean
;; path in WASM once per bool shape just to find that out.
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
:with-objects? true
:update-layout? false)))
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))

View File

@ -154,12 +154,14 @@
([ids update-fn
{:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?]
ignore-touched undo-group with-objects? changed-sub-attr translation?
update-layout?]
:or {reg-objects? false
save-undo? true
stack-undo? false
ignore-touched false
with-objects? false}}]
with-objects? false
update-layout? true}}]
(assert (every? uuid? ids) "expect a coll of uuid for `ids`")
(assert (fn? update-fn) "the `update-fn` should be a valid function")
@ -181,8 +183,17 @@
(filter #(some update-layout-attr? (pcb/changed-attrs % objects update-fn {:attrs attrs :with-objects? with-objects?})))
(map :id))
;; `changed-attrs` runs `update-fn` in full for every shape, which
;; can be expensive (e.g. `update-bool-shape` recalculates the whole
;; boolean path in WASM). Skip the pass entirely when we can prove it
;; cannot match: when the caller declares `attrs`, `changed-attrs`
;; filters its result to that set, so if no layout attr is present
;; the check is always empty.
update-layout-ids
(when-not translation?
(when-not (or translation?
(not update-layout?)
(and (some? attrs)
(not (some update-layout-attr? attrs))))
(->> (into [] xf-update-layout ids)
(not-empty)))

View File

@ -48,12 +48,14 @@
(wasm.api/set-shape-text-images id content))
(let [dimension (when (not= :fixed grow-type)
(wasm.api/get-text-dimensions))]
{:width (if (#{:fixed :auto-height} grow-type)
(:width selrect)
(:width dimension))
:height (if (= :fixed grow-type)
(:height selrect)
(:height dimension))}))))
;; nil dimension = shape not present in WASM state; skip the resize.
(when (or (= :fixed grow-type) (some? dimension))
{:width (if (#{:fixed :auto-height} grow-type)
(:width selrect)
(:width dimension))
:height (if (= :fixed grow-type)
(:height selrect)
(:height dimension))})))))
(defn resize-wasm-text-modifiers
([shape]

View File

@ -108,6 +108,108 @@
;; only know if the font is needed or not
(defonce ^:dynamic loaded-hints (l/atom #{}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREVIEW SPRITE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A prebuilt SVG sprite (generated by scripts/build-fonts-preview.js) holds every
;; built-in + Google font name outlined in its own typeface, so the picker can
;; preview the whole catalog with no per-font requests. Fonts not in it (custom
;; uploads, ones that fail to bake) use the runtime fallback.
;;
;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched
;; markup is cached here as a string (`:svg`) and the nodes are materialized only
;; while the picker is open (attach/detach below). `:ids` are the font ids it
;; covers, so the UI can pick sprite vs fallback.
(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil}))
;; Id prefix shared with the generator and the UI's `<use href>`; referenced here
;; rather than re-declared so the contract stays in one place.
(def preview-sprite-prefix "font-preview-")
(defn- collect-preview-ids
"Set of font ids present in the sprite, read from the injected `container`
element's `<g id=\"font-preview-…\">` groups (prefix stripped)."
[^js container]
(let [nodes (dom/query-all container (dm/str "g[id^=\"" preview-sprite-prefix "\"]"))
plen (count preview-sprite-prefix)]
(persistent!
(reduce (fn [acc node]
(conj! acc (subs (dom/get-attribute node "id") plen)))
(transient #{})
(array-seq nodes)))))
(defn- reset-preview-sprite-error!
[]
;; :error → the UI shows plain names (no previews, no per-font load storm); a
;; later `prefetch-preview-sprite!` call can retry.
(reset! preview-sprite {:status :error :ids #{} :svg nil}))
(defn- parse-sprite-svg
"Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection
surface). Returns the root `<svg>` element, or nil if it isn't valid SVG."
[text]
(let [doc (.parseFromString (js/DOMParser.) text "image/svg+xml")
root (.-documentElement doc)]
;; A malformed document yields a <parsererror> root instead of <svg>.
(when (and (= "svg" (.-tagName ^js root))
(nil? (dom/query doc "parsererror")))
root)))
(defn prefetch-preview-sprite!
"Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see
`attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet
(`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or
`:ready`."
[]
(when (and (globals/browser?)
(contains? #{:idle :error} (:status @preview-sprite)))
(swap! preview-sprite assoc :status :loading)
(->> (http/send! {:method :get
:uri cf/fonts-preview-sprite-uri
:response-type :text
:mode :cors})
(rx/subs!
(fn [response]
;; http/send! doesn't reject on non-2xx; guard so an error body isn't
;; cached as the sprite.
(if (http/success? response)
(swap! preview-sprite assoc :status :ready :svg (:body response))
(do
(log/wrn :hint "cannot load font preview sprite" :status (:status response))
(reset-preview-sprite-error!))))
(fn [cause]
(log/wrn :hint "cannot load font preview sprite" :cause cause)
(reset-preview-sprite-error!))))))
(defn attach-preview-sprite!
"Materialize the cached sprite into the DOM (hidden) so rows can reference its
glyph groups via `<use>`, and record the covered font ids. Returns the injected
node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the
markup is invalid. Parsing happens here, not on prefetch, so the cost is paid
only while the picker is open."
[]
(let [{:keys [status svg]} @preview-sprite]
(when (and (globals/browser?) (= :ready status) (some? svg))
(if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))]
;; The node already carries display:none + aria-hidden from the generator.
(do
(dom/set-attribute! node "id" "font-preview-sprite")
(when-let [body-el (unchecked-get globals/document "body")]
(dom/append-child! body-el node))
(swap! preview-sprite assoc :ids (collect-preview-ids node))
node)
(do
(log/wrn :hint "cannot parse font preview sprite")
(reset-preview-sprite-error!)
nil)))))
(defn detach-preview-sprite!
"Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The
cached markup and `:ids` stay, so reopening re-attaches without a refetch."
[node]
(dom/remove! node))
(defn- add-font-css!
"Creates a style element and attaches it to the dom."
[id css]
@ -158,7 +260,7 @@
(defmethod load-font :google
[{:keys [id ::on-loaded] :as font}]
(when (exists? js/window)
(when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "google")
(let [url (generate-gfonts-url font)]
(->> (fetch-gfont-css url)
@ -200,7 +302,7 @@
(defmethod load-font :custom
[{:keys [id ::on-loaded] :as font}]
(when (exists? js/window)
(when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "custom")
(let [css (generate-custom-font-css font)]
(add-font-css! id css)
@ -215,7 +317,7 @@
([font-id] (ensure-loaded! font-id nil))
([font-id variant-id]
(log/dbg :action "try-ensure-loaded!" :font-id font-id :variant-id variant-id)
(if-not (exists? js/window)
(if-not (globals/browser?)
;; If we are in the worker environment, we just mark it as loaded
;; without really loading it.
(do

View File

@ -96,6 +96,17 @@
(rt/nav :dashboard-recent {:team-id team-id})
(ntf/error (tr "errors.org-not-found")))
(= :canceled-invitation code)
(let [profile (:profile @st/state)
authenticated? (da/is-authenticated? profile)]
(if authenticated?
(st/emit!
(dcm/go-to-dashboard-recent :team-id :default)
(ntf/warn (tr "notifications.invitation-canceled")))
(st/emit!
(rt/nav :auth-login)
(ntf/warn (tr "notifications.invitation-canceled")))))
(or (= :validation type)
(= :invalid-token code)
(= :token-expired reason))

View File

@ -18,8 +18,6 @@
[app.main.data.comments :as dcm]
[app.main.data.modal :as modal]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.viewport :as dwv]
[app.main.data.workspace.zoom :as dwz]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
@ -704,15 +702,21 @@
(mf/defc comment-reply-form*
{::mf/private true}
[{:keys [on-submit]}]
[{:keys [on-submit on-cancel]}]
(let [content (mf/use-state "")
disabled? (or (blank-content? @content)
(exceeds-length? @content))
;; Contexts without a global interrupt handler (e.g. the viewer) pass an
;; explicit cancel; otherwise fall back to the interrupt cycle.
on-cancel
(mf/use-fn
#(st/emit! :interrupt))
(mf/deps on-cancel)
(fn []
(if (fn? on-cancel)
(on-cancel)
(st/emit! :interrupt))))
on-change
(mf/use-fn
@ -799,8 +803,10 @@
(some? position-modifier)
(gpt/transform position-modifier))
content (:content draft)
bubble-margin (gpt/point 0 0)
;; Keep the draft bubble centered on the comment position (matching a
;; created bubble) while the input box is offset to the side.
bubble-margin (gpt/point 24 24)
pos (offset-position position viewport zoom bubble-margin)
margin-x (* (:x bubble-margin) (if (= (:h-dir pos) :left) -1 1))
@ -808,6 +814,9 @@
pos-x (+ (* (:x pos) zoom) margin-x)
pos-y (- (* (:y pos) zoom) margin-y)
bubble-x (floor (* (:x position) zoom))
bubble-y (floor (* (:y position) zoom))
disabled? (or (blank-content? content)
(exceeds-length? content))
@ -833,39 +842,36 @@
(on-submit draft)))]
[:> (mf/provider mentions-context) {:value mentions-s}
[:div {:class (stl/css-case :floating-thread-draft-wrapper true
[:div {:class (stl/css :floating-preview-wrapper :floating-preview-bubble)
:data-testid "floating-thread-bubble"
:style {:top (dm/str bubble-y "px")
:left (dm/str bubble-x "px")}
:on-click dom/stop-propagation}
[:> comment-avatar* {:class (stl/css :avatar-lg)
:image (cfg/resolve-profile-photo-url profile)}]]
[:div {:class (stl/css-case :floating-thread-draft-inner-wrapper true
:cursor-auto true
:left (= (:h-dir pos) :left)
:top (= (:v-dir pos) :top))
:style {:top (str pos-y "px")
:left (str pos-x "px")}}
[:div
{:data-testid "floating-thread-bubble"
:style {:top (str pos-y "px")
:left (str pos-x "px")}
:on-click dom/stop-propagation}
[:> comment-avatar* {:class (stl/css :avatar-lg)
:image (cfg/resolve-profile-photo-url profile)}]]
[:div {:class (stl/css :floating-thread-draft-inner-wrapper
:cursor-auto)
:style {:top (str (- pos-y 24) "px")
:left (str (+ pos-x 28) "px")}
:on-click dom/stop-propagation}
[:div {:class (stl/css :form)}
[:> comment-input*
{:placeholder (tr "labels.write-new-comment")
:value (or content "")
:autofocus true
:on-esc on-esc
:on-change on-change
:on-ctrl-enter on-submit*}]
(when (exceeds-length? content)
[:div {:class (stl/css :error-text)}
(tr "errors.character-limit-exceeded")])
[:> comment-form-buttons* {:on-submit on-submit*
:on-cancel on-esc
:is-disabled disabled?}]]
[:> mentions-panel*]]]]))
:left (str pos-x "px")}
:on-click dom/stop-propagation}
[:div {:class (stl/css :form)}
[:> comment-input*
{:placeholder (tr "labels.write-new-comment")
:value (or content "")
:autofocus true
:on-esc on-esc
:on-change on-change
:on-ctrl-enter on-submit*}]
(when (exceeds-length? content)
[:div {:class (stl/css :error-text)}
(tr "errors.character-limit-exceeded")])
[:> comment-form-buttons* {:on-submit on-submit*
:on-cancel on-esc
:is-disabled disabled?}]]
[:> mentions-panel*]]]))
(mf/defc comment-floating-thread-header*
{::mf/private true}
@ -1057,7 +1063,10 @@
(mf/use-fn
(mf/deps thread)
(fn [content]
(st/emit! (dcm/add-comment thread content))))]
(st/emit! (dcm/add-comment thread content))))
on-cancel
(mf/use-fn #(st/emit! (dcm/close-thread)))]
(mf/with-effect [thread-id]
(st/emit! (dcm/retrieve-comments thread-id)))
@ -1092,60 +1101,65 @@
[:* {:key (dm/str (:id item))}
[:> comment-floating-thread-item* {:comment item}]])]
[:> comment-reply-form* {:on-submit on-submit}]
[:> comment-reply-form* {:on-submit on-submit
:on-cancel (when (= origin :viewer) on-cancel)}]
[:> mentions-panel*]])]))
(defn group-bubbles
"Group bubbles in different vectors by proximity"
([zoom circles]
(group-bubbles zoom circles [] []))
;; Screen-space gap (px) between concentric rings of an expanded cluster.
(def ^:private expanded-ring-gap 44)
([zoom circles visited groups]
(if (empty? circles)
groups
(let [current (first circles)
remaining (rest circles)
overlapping-group (some (fn [group]
(when (some (partial dwcm/overlap-bubbles? zoom current) group) group))
groups)]
(if overlapping-group
(group-bubbles zoom remaining visited (map (fn [group]
(if (= group overlapping-group)
(cons current group)
group))
groups))
(group-bubbles zoom remaining visited (cons [current] groups)))))))
;; Number of bubbles that fit in the innermost ring; each further ring
;; grows its capacity proportionally to its circumference.
(def ^:private expanded-ring-base 6)
(defn- inside-vbox?
"Checks if a bubble or a bubble group is inside a viewbox"
[thread-group wl]
(let [vbox (:vbox wl)
positions (mapv :position thread-group)
position (gpt/center-points positions)
pos-x (:x position)
pos-y (:y position)
x1 (:x vbox)
y1 (:y vbox)
x2 (+ x1 (:width vbox))
y2 (+ y1 (:height vbox))]
(and (> x2 pos-x x1) (> y2 pos-y y1))))
(defn- expanded-ring-slot
"Return [ring slot capacity] placing the bubble at index `i` (0-based) into
concentric rings, filling the innermost ring first."
[i]
(loop [ring 1
i i]
(let [capacity (* expanded-ring-base ring)]
(if (< i capacity)
[ring i capacity]
(recur (inc ring) (- i capacity))))))
(defn- calculate-zoom-scale
"Calculates the zoom level needed to ungroup the largest number of bubbles while
keeping them all visible in the viewbox."
[position zoom threads wl]
(let [num-threads (count threads)
grouped-threads (group-bubbles zoom threads)
num-grouped-threads (count grouped-threads)
zoom-scale-step 1.75
scaled-zoom (* zoom zoom-scale-step)
zoomed-wl (dwz/impl-update-zoom wl position scaled-zoom)
outside-vbox? (complement inside-vbox?)]
(if (or (= num-threads num-grouped-threads)
(some #(outside-vbox? % zoomed-wl) grouped-threads))
zoom
(calculate-zoom-scale position scaled-zoom threads zoomed-wl))))
(defn- expanded-ring-vector
"Pure ring displacement (px) around the cluster center for the bubble at index
`i` (0-based), laid out into concentric rings, innermost first."
[i]
(let [[ring slot capacity] (expanded-ring-slot i)
;; Start each ring at the top and stagger alternate rings by half a
;; slot so bubbles don't line up radially.
angle (+ (* (/ slot capacity) 2 mth/PI)
(- (/ mth/PI 2))
(if (even? ring) (/ mth/PI capacity) 0))
radius (* ring expanded-ring-gap)]
(gpt/point (* radius (mth/cos angle))
(* radius (mth/sin angle)))))
(defn expanded-group-center
"Cluster center point (stored coordinates) shared by all bubbles of a group."
[thread-group]
(gpt/center-points (mapv :position thread-group)))
(defn expanded-group-offsets
"Return a seq of [thread offset ring] triples laying each thread of
`thread-group` into a centered concentric-ring layout, leaving stored
positions untouched. `offset` is the total screen-space displacement from the
bubble's own position; `ring` is just the displacement from the cluster
center (used to animate the fan-out)."
[zoom thread-group]
(let [threads (sort-by :seqn thread-group)
center (expanded-group-center threads)]
(map-indexed
(fn [i thread]
(let [position (:position thread)
ring (expanded-ring-vector i)
base (gpt/point (* (- (:x center) (:x position)) zoom)
(* (- (:y center) (:y position)) zoom))]
[thread (gpt/add base ring) ring]))
threads)))
(mf/defc comment-floating-group*
{::mf/wrap [mf/memo]}
@ -1167,16 +1181,15 @@
;; Click-through while transforming a shape, so it doesn't capture the drag
dragging? (some? (mf/deref refs/current-transform))
thread-ids (mf/with-memo [thread-group]
(into #{} (map :id) thread-group))
on-click
(mf/use-fn
(mf/deps thread-group position zoom)
(fn []
(let [wl (deref refs/workspace-local)
centered-wl (dwv/calculate-centered-viewbox wl position)
updated-zoom (calculate-zoom-scale position zoom thread-group centered-wl)
scale-zoom (/ updated-zoom zoom)]
(st/emit! (dwv/update-viewport-position-center position)
(dwz/set-zoom position scale-zoom)))))]
(mf/deps thread-ids)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/expand-comment-group thread-ids))))]
[:div {:style {:top (dm/str pos-y "px")
:left (dm/str pos-x "px")
@ -1189,9 +1202,31 @@
:data-testid (dm/str "floating-thread-bubble-" test-id)}
num-threads]]))
(mf/defc comment-floating-ghost*
"Dashed, semi-transparent placeholder shown at the cluster center while its
bubbles are fanned out into the expanded ring."
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [thread-group zoom position-modifier]}]
(let [center (expanded-group-center thread-group)
center (cond-> center
(some? position-modifier)
(gpt/transform position-modifier))
pos-x (floor (* (:x center) zoom))
pos-y (floor (* (:y center) zoom))
num-threads (str (count thread-group))]
[:div {:style {:top (dm/str pos-y "px")
:left (dm/str pos-x "px")
:pointer-events "none"}
:class (stl/css :floating-preview-wrapper :floating-preview-bubble :floating-preview-ghost)}
[:> comment-avatar*
{:class (stl/css :avatar-lg)
:variant "read"}
num-threads]]))
(mf/defc comment-floating-bubble*
{::mf/wrap [mf/memo]}
[{:keys [thread zoom is-open on-click origin position-modifier]}]
[{:keys [thread zoom is-open on-click origin position-modifier offset ring]}]
(let [owner (mf/with-memo [thread]
(dcm/get-owner thread))
@ -1202,6 +1237,10 @@
frame-id (:frame-id thread)
;; A bubble with `offset` is one of the fanned-out members of an
;; expanded cluster.
expanded? (some? offset)
;; Click-through while transforming a shape, so it doesn't capture the drag
dragging? (some? (mf/deref refs/current-transform))
@ -1212,8 +1251,31 @@
:new-position-y nil
:new-frame-id frame-id}))
pos-x (floor (* (or (:new-position-x @state) (:x position)) zoom))
pos-y (floor (* (or (:new-position-y @state) (:y position)) zoom))
new-x (:new-position-x @state)
new-y (:new-position-y @state)
;; While dragging, the new position already accounts for the ring offset;
;; otherwise an expanded bubble sits at its stored position plus the
;; screen-space ring offset.
pos-x (if (some? new-x)
(floor (* new-x zoom))
(+ (floor (* (:x position) zoom))
(if expanded? (:x offset) 0)))
pos-y (if (some? new-y)
(floor (* new-y zoom))
(+ (floor (* (:y position) zoom))
(if expanded? (:y offset) 0)))
;; World-space anchor a drag starts from: an expanded bubble is shown at
;; its ring position, so dragging must begin there, not at its stored
;; position.
drag-base-x (if expanded? (+ (:x position) (/ (:x offset) zoom)) (:x position))
drag-base-y (if expanded? (+ (:y position) (/ (:y offset) zoom)) (:y position))
;; CSS custom properties fed to the fan-out animation; nil for regular
;; (non-expanded) bubbles.
ring-x (when (and expanded? (some? ring)) (dm/str (- (:x ring)) "px"))
ring-y (when (and expanded? (some? ring)) (dm/str (- (:y ring)) "px"))
drag? (mf/use-ref nil)
was-open? (mf/use-ref nil)
@ -1237,7 +1299,7 @@
on-pointer-up
(mf/use-fn
(mf/deps origin thread (select-keys @state [:new-position-x :new-position-y :new-frame-id]))
(mf/deps origin thread expanded? (select-keys @state [:new-position-x :new-position-y :new-frame-id]))
(fn [event]
(when (not= origin :viewer)
(swap! state assoc :is-grabbing false)
@ -1250,13 +1312,16 @@
(some? (:new-position-y @state)))
(st/emit! (dwcm/update-comment-thread-position thread [(:new-position-x @state)
(:new-position-y @state)]))
;; Dropping a fanned-out bubble commits its new spot and closes
;; the temporary cluster expansion.
(when expanded? (st/emit! (dcm/collapse-comment-group)))
(swap! state assoc
:new-position-x nil
:new-position-y nil)))))
on-pointer-move
(mf/use-fn
(mf/deps origin drag? position zoom)
(mf/deps origin drag? drag-base-x drag-base-y zoom)
(fn [event]
(when (not= origin :viewer)
(mf/set-ref-val! drag? true)
@ -1267,8 +1332,8 @@
delta-x (/ (- (:x current-pt) (:x start-pt)) zoom)
delta-y (/ (- (:y current-pt) (:y start-pt)) zoom)]
(swap! state assoc
:new-position-x (+ (:x position) delta-x)
:new-position-y (+ (:y position) delta-y)))))))
:new-position-x (+ drag-base-x delta-x)
:new-position-y (+ drag-base-y delta-y)))))))
on-pointer-enter
(mf/use-fn
@ -1286,7 +1351,7 @@
on-click*
(mf/use-fn
(mf/deps origin thread on-click was-open? drag? (select-keys @state [:is-hover]))
(mf/deps origin thread on-click was-open? drag?)
(fn [event]
(dom/stop-propagation event)
(when (or (and (mf/ref-val was-open?) (mf/ref-val drag?))
@ -1298,34 +1363,42 @@
[:div {:style {:top (dm/str pos-y "px")
:left (dm/str pos-x "px")
:pointer-events (when dragging? "none")}
:on-pointer-down on-pointer-down
:on-pointer-up on-pointer-up
:on-pointer-move on-pointer-move
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave
:on-click on-click*
:pointer-events (when dragging? "none")
"--comment-ring-x" ring-x
"--comment-ring-y" ring-y}
:class (stl/css-case :floating-preview-wrapper true
:floating-preview-bubble (false? (:is-hover @state)))}
:floating-preview-bubble (false? (:is-hover @state))
:floating-preview-expanded expanded?
:floating-preview-hovered (:is-hover @state))}
(if (:is-hover @state)
[:div {:class (stl/css-case :floating-thread-wrapper true
:floating-preview-displacement true
:cursor-pointer (false? (:is-grabbing @state))
:cursor-grabbing (true? (:is-grabbing @state)))}
;; The avatar circle is the only pointer target: it drives hover, drag and
;; click, so hovering the preview card below never keeps the tooltip open.
[:div {:on-pointer-down on-pointer-down
:on-pointer-up on-pointer-up
:on-pointer-move on-pointer-move
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave
:on-click on-click*
:class (stl/css-case :floating-preview-avatar true
:cursor-pointer (false? (:is-grabbing @state))
:cursor-grabbing (true? (:is-grabbing @state)))}
[:> comment-avatar*
{:image (cfg/resolve-profile-photo-url owner)
:class (stl/css :avatar-lg)
:data-testid (dm/str "floating-thread-bubble-" (:seqn thread))
:variant (cond
(:is-resolved thread) "solved"
(pos? (:count-unread-comments thread)) "unread"
:else "read")}]]
(when (:is-hover @state)
[:div {:class (stl/css :floating-thread-wrapper
:floating-preview-displacement
:floating-preview-hover-card)}
[:div {:class (stl/css :floating-thread-item-wrapper)}
[:div {:class (stl/css :floating-thread-item)}
[:> comment-info* {:item thread
:profile owner}]]]]
[:> comment-avatar*
{:image (cfg/resolve-profile-photo-url owner)
:class (stl/css :avatar-lg)
:data-testid (dm/str "floating-thread-bubble-" (:seqn thread))
:variant (cond
(:is-resolved thread) "solved"
(pos? (:count-unread-comments thread)) "unread"
:else "read")}])]))
:profile owner}]]]])]))
(mf/defc comment-sidebar-thread-item*
{::mf/private true}

View File

@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_sizes.scss" as *;
.cursor-grabbing {
cursor: grabbing;
@ -104,6 +105,7 @@
justify-content: center;
font-size: deprecated.$fs-12;
background-color: var(--color-background-quaternary);
color: var(--color-foreground-quaternary);
}
.avatar-mask {
@ -167,31 +169,68 @@
z-index: initial;
}
.floating-thread-draft-wrapper {
position: absolute;
// Sole pointer target of a floating bubble.
.floating-preview-avatar {
display: flex;
flex-direction: row;
column-gap: deprecated.$s-12;
height: $sz-32;
width: $sz-32;
}
--translate-x: 0%;
--translate-y: 0%;
// Expanded-cluster bubble: sits above regular bubbles and fans out on appearance.
.floating-preview-expanded {
z-index: 2;
animation: comment-bubble-fan-out 0.18s ease-out;
}
transform: translate(var(--translate-x), var(--translate-y));
&.left {
--translate-x: -100%;
flex-direction: row-reverse;
@keyframes comment-bubble-fan-out {
from {
translate: var(--comment-ring-x, 0) var(--comment-ring-y, 0);
opacity: 0;
}
&.top {
--translate-y: -100%;
align-items: flex-end;
to {
translate: 0 0;
opacity: 1;
}
}
// Hovered bubble's preview card floats above every other bubble.
.floating-preview-hovered {
z-index: 10;
}
// Placeholder shown at the cluster center while its bubbles are fanned out.
.floating-preview-ghost {
z-index: 1;
opacity: 0.6;
pointer-events: none;
animation: comment-ghost-appear 0.18s ease-out;
.avatar {
border-style: dashed;
background-color: var(--comment-modal-background-color);
}
}
@keyframes comment-ghost-appear {
from {
opacity: 0;
}
to {
opacity: 0.6;
}
}
// Anchored to the wrapper origin and click-through, so hover stays bound to the avatar.
.floating-thread-wrapper.floating-preview-hover-card {
top: 0;
left: 0;
pointer-events: none;
}
.floating-thread-draft-inner-wrapper {
position: absolute;
display: flex;
flex-direction: column;
gap: deprecated.$s-12;
@ -202,6 +241,19 @@
border: deprecated.$s-2 solid var(--modal-border-color);
background-color: var(--comment-modal-background-color);
max-height: var(--comment-height);
--translate-x: 0%;
--translate-y: 0%;
transform: translate(var(--translate-x), var(--translate-y));
&.left {
--translate-x: -100%;
}
&.top {
--translate-y: -100%;
}
}
.floating-preview-displacement {

View File

@ -27,7 +27,7 @@
(mf/defc internal-dropdown-menu*
{::mf/private true}
[{:keys [on-close children class id]}]
[{:keys [on-close children class id on-pointer-enter on-pointer-leave]}]
(assert (fn? on-close) "missing `on-close` prop")
@ -106,7 +106,12 @@
#(doseq [key keys]
(events/unlistenByKey key))))
[:ul {:class class :role "menu" :ref container} children]))
[:ul {:class class
:role "menu"
:ref container
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
children]))
(mf/defc dropdown-menu*
[{:keys [show] :as props}]

View File

@ -150,7 +150,7 @@
.separator {
margin: 0;
block-size: $sz-12;
border-block-start: $b-1 solid var(--color-background-primary);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
&[data-direction="up"] {

View File

@ -315,8 +315,14 @@
(mf/use-fn
(mf/deps profile)
(fn []
(if (dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-org)
(cond
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
(dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-organization)
:else
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))
on-go-to-cc-click
@ -755,8 +761,14 @@
(mf/use-fn
(mf/deps profile)
(fn []
(if (dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-org)
(cond
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
(dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-organization)
:else
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
(if show-dropdown?
[:div {:class (stl/css :sidebar-org-switch)}
@ -801,7 +813,12 @@
:class (stl/css :dropdown :teams-dropdown)
:organization current-org
:profile profile
:organizations (vals orgs)}]])
:organizations (->> (vals orgs)
(sort-by (juxt (fn [o] (str/lower (:name o "")))
:id)))}]])
orgs-portal-container)))
;; Orgs options
[:> org-options-dropdown* {:show show-org-options-menu?
@ -1128,7 +1145,7 @@
(mf/defc help-learning-menu*
{::mf/private true}
[{:keys [on-close on-click]}]
[{:keys [on-close on-click on-pointer-enter on-pointer-leave]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
@ -1145,7 +1162,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :help-learning)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://help.penpot.app"
@ -1172,7 +1191,7 @@
(mf/defc community-contributions-menu*
{::mf/private true}
[{:keys [on-close]}]
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
@ -1186,7 +1205,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :community)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://github.com/penpot/penpot"
@ -1202,7 +1223,7 @@
(mf/defc about-penpot-menu*
{::mf/private true}
[{:keys [on-close]}]
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
(let [version cf/version
show-release-notes
(mf/use-fn
@ -1225,7 +1246,9 @@
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :about)
:on-close on-close}
:on-close on-close
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-release-notes}
@ -1250,6 +1273,11 @@
show-profile-menu? (deref show-profile-menu*)
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
;; Tracks whether the pointer is over an expandable option or
;; its floating submenu, so the submenu survives the gap
;; between them while the pointer travels across.
hovering?* (mf/use-ref false)
version (:base cf/version)
close-sub-menu
@ -1315,6 +1343,24 @@
(keyword))]
(reset! sub-menu* menu))))
on-menu-pointer-enter
(mf/use-fn
(fn [event]
(mf/set-ref-val! hovering?* true)
(on-menu-click event)))
on-menu-pointer-leave
(mf/use-fn
(fn [_]
(mf/set-ref-val! hovering?* false)
(ts/schedule 200 #(when-not (mf/ref-val hovering?*)
(reset! sub-menu* nil)))))
on-sub-menu-pointer-enter
(mf/use-fn
(fn [_]
(mf/set-ref-val! hovering?* true)))
on-power-up-click
(mf/use-fn
(fn []
@ -1388,7 +1434,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "help-learning"
:id "help-learning"}
[:span {:class (stl/css :item-name)} (tr "labels.help-learning")]
@ -1399,7 +1446,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "community-contributions"
:id "community-contributions"}
[:span {:class (stl/css :item-name)} (tr "labels.community-contributions")]
@ -1410,7 +1458,8 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:on-pointer-enter on-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave
:data-testid "about-penpot"
:id "about-penpot"}
@ -1435,13 +1484,20 @@
(when show-profile-menu?
(case sub-menu
:help-learning
[:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}]
[:> help-learning-menu* {:on-close close-sub-menu
:on-click on-click
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
:community-contributions
[:> community-contributions-menu* {:on-close close-sub-menu}]
[:> community-contributions-menu* {:on-close close-sub-menu
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
:about-penpot
[:> about-penpot-menu* {:on-close close-sub-menu}]
[:> about-penpot-menu* {:on-close close-sub-menu
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
nil))]))
(mf/defc sidebar*

View File

@ -162,14 +162,14 @@
handle-click
(mf/use-fn
(mf/deps nitrate-license subscription-type)
(mf/deps subscription-type)
(fn []
(if (= subscription-type "unlimited")
(st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true}))
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))
(st/emit! (dnt/show-nitrate-popup :nitrate-form
(when (= subscription-type "unlimited")
{:show-contact-sales-option true})))))
handle-go-to-cc
(mf/use-fn dnt/go-to-nitrate-ac-create-org)
(mf/use-fn dnt/go-to-nitrate-ac-create-organization)
handle-open-renew-modal
(mf/use-fn #(st/emit! (modal/show :nitrate-code-activation {:renew? true})))]
@ -200,7 +200,7 @@
[:div {:class (stl/css :nitrate-content)}
[:span {:class (stl/css :nitrate-title)} (tr "subscription.dashboard.banner.unlock-features")]]
[:div {:class (stl/css :nitrate-content)}
[:span {:class (stl/css :nitrate-info)} (tr "subscription.dashboard.banner.unlock-features-description")]
[:span {:class (stl/css :nitrate-info)} (tr "subscription.dashboard.banner.unlock-features-description-text")]
[:> button* {:variant "primary"
:type "button"
:class (stl/css :nitrate-bottom-button)

View File

@ -225,7 +225,7 @@
border-radius: var(--sp-s);
flex-direction: column;
margin: var(--sp-m) var(--sp-m) 0;
background: var(--color-background-quaternary);
background: var(--color-accent-select);
border: $b-1 solid var(--color-accent-primary-muted);
padding: var(--sp-l);
}

View File

@ -1538,8 +1538,9 @@
can-change-organization? (mf/with-memo [all-organizations]
(> (count all-organizations) 1))
can-add-to-organization? (mf/with-memo [organizations all-organizations]
(and (pos? (count all-organizations))
can-add-to-organization? (mf/with-memo [organizations all-organizations permissions]
(and (:is-owner permissions)
(pos? (count all-organizations))
(not (:is-default team))))
show-org-options-menu*

Some files were not shown because too many files have changed in this diff Show More