mirror of
https://github.com/penpot/penpot.git
synced 2026-07-22 05:57:56 +00:00
Merge branch 'develop' into niwinz-performance-tests
This commit is contained in:
commit
501284169c
@ -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
|
||||
|
||||
2
.github/workflows/auto-label.yml
vendored
2
.github/workflows/auto-label.yml
vendored
@ -3,7 +3,7 @@ name: Auto Label and Add to Project
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/build-docker-devenv.yml
vendored
2
.github/workflows/build-docker-devenv.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/build-tag.yml
vendored
2
.github/workflows/build-tag.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/plugins-deploy-package.yml
vendored
2
.github/workflows/plugins-deploy-package.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -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
3
.gitignore
vendored
@ -100,6 +100,9 @@ opencode.json
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.opencode/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
/performance/results/
|
||||
@ -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.
|
||||
|
||||
43
.opencode/commands/implement-plan.md
Normal file
43
.opencode/commands/implement-plan.md
Normal 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.
|
||||
21
.opencode/commands/review.md
Normal file
21
.opencode/commands/review.md
Normal 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.
|
||||
397
.opencode/skills/code-review-and-quality/SKILL.md
Normal file
397
.opencode/skills/code-review-and-quality/SKILL.md
Normal 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.
|
||||
27
.opencode/skills/create-issue/SKILL.md
Normal file
27
.opencode/skills/create-issue/SKILL.md
Normal 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.
|
||||
@ -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.
|
||||
@ -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".
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
457
.opencode/skills/security-and-hardening/SKILL.md
Normal file
457
.opencode/skills/security-and-hardening/SKILL.md
Normal 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)
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
317
.serena/memories/frontend/composable-component-tests.md
Normal file
317
.serena/memories/frontend/composable-component-tests.md
Normal 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`.
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
163
.serena/memories/testing.md
Normal 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)
|
||||
80
.serena/memories/tools/gh.md
Normal file
80
.serena/memories/tools/gh.md
Normal 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).
|
||||
126
.serena/memories/tools/nrepl-eval.md
Normal file
126
.serena/memories/tools/nrepl-eval.md
Normal 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).
|
||||
51
.serena/memories/tools/psql.md
Normal file
51
.serena/memories/tools/psql.md
Normal 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.
|
||||
44
.serena/memories/tools/taiga.md
Normal file
44
.serena/memories/tools/taiga.md
Normal 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`.
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@ -1,5 +1,20 @@
|
||||
# AI AGENT GUIDE
|
||||
|
||||
## Hard rules (always apply — no exceptions)
|
||||
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote).
|
||||
The user pushes from their own shell. If a push is required to surface the
|
||||
agent's work (e.g. force-push after an amend), state this in the response and
|
||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||
If the user pushes, treat that commit as final from the agent's side.
|
||||
- **Read the workflow memory BEFORE the corresponding action**:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, AI note)
|
||||
Don't infer format from the title of a previous commit/issue/PR — the memory
|
||||
is the source of truth.
|
||||
|
||||
## CRITICAL: Read module memories BEFORE writing any code
|
||||
|
||||
Do this **before planning, before coding, before touching any file**:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)"; \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))]
|
||||
|
||||
@ -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)))]
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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)))]
|
||||
|
||||
@ -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'\"",
|
||||
|
||||
@ -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": {
|
||||
|
||||
449
frontend/packages/draft-js/pnpm-lock.yaml
generated
449
frontend/packages/draft-js/pnpm-lock.yaml
generated
@ -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
|
||||
@ -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,
|
||||
}) => {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
10
frontend/pnpm-lock.yaml
generated
10
frontend/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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)})`);
|
||||
|
||||
@ -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();
|
||||
|
||||
384
frontend/scripts/build-fonts-preview.js
Normal file
384
frontend/scripts/build-fonts-preview.js
Normal 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)`,
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
:add #{:tokens}}})
|
||||
|
||||
(def valid-options-mode
|
||||
#{:design :prototype :inspect})
|
||||
#{:design :prototype :inspect :debug})
|
||||
|
||||
(def default-layout
|
||||
#{:sitemap
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)))
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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"] {
|
||||
|
||||
@ -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*
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user