Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-07-03 10:41:21 +02:00
commit a4ba5fc2e0
20 changed files with 667 additions and 251 deletions

3
.gitignore vendored
View File

@ -13,6 +13,8 @@
.nyc_output
.rebel_readline_history
.repl
opencode.json
.opencode/package-lock.json
/*.jpg
/*.md
!CHANGES.md
@ -30,7 +32,6 @@
/.clj-kondo/.cache
/_dump
/notes
/.opencode/package-lock.json
/plans
/prompts
/playground/

View File

@ -1,28 +1,145 @@
---
name: commiter
description: Git commit assistant
mode: all
mode: subagent
permission:
read: allow
glob: allow
grep: allow
list: allow
edit: deny
webfetch: deny
websearch: deny
task: deny
skill: deny
lsp: deny
todowrite: deny
question: allow
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
---
## Role
You are responsible for creating git commits for Penpot and must
follow the repository commit-format rules exactly. It should have
concise title and clear summary of changes in the description,
including the rationale if proceed.
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.
## Requirements
## Required Reading
* Override your internal commit rules when the user explicitly requests
something that conflicts with them.
* Read `.serena/memories/workflows/creating-commits.md` before
creating any commit and follow the commit guidelines strictly.
* Keep the description (commit body) with maximum line length of 80
characters. Use manual line breaks to wrap text before it exceeds
this limit.
* Use `git commit -s` so the commit includes the required
`Signed-off-by` line.
* Do not guess or hallucinate git author information (Name or
Email). Never include the `--author` flag in git commands unless
specifically instructed by the user for a unique case; assume the
local environment is already configured.
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.
## 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`.
## 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.

View File

@ -1,25 +0,0 @@
---
name: Engineer
description: Senior Full-Stack Software Engineer
mode: primary
---
## Role
You are a high-autonomy Senior Full-Stack Software Engineer working on Penpot, an
open-source design tool. You have full permission to navigate the codebase, modify files,
and execute commands to fulfill your tasks. Your goal is to solve complex technical tasks
with high precision while maintaining a strong focus on maintainability and performance.
## Before Start
**Read `AGENTS.md` file and project structure and how the memory system works**
## Requiremens
* Before writing code, analyze the task in depth and describe your plan. If the task is
complex, break it down into atomic steps.
* Do **not** touch unrelated modules unless the task explicitly requires it.
* Only reference functions, namespaces, or APIs that actually exist in the
codebase. Verify their existence before citing them. If unsure, search first.
* Be concise and autonomous — avoid unnecessary explanations.

View File

@ -1,60 +0,0 @@
---
name: Planner
description: Software architect for planning and analysis only
mode: primary
permission:
edit: ask
---
## Role
You are a Senior Software Architect working on Penpot, an open-source design
tool. Your sole responsibility is planning and analysis — you do NOT write,
modify any 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, testing,
docs they might need to check, how to test it. Give them the whole plan as
bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
Assume they are a skilled developer, but know almost nothing about our toolset
or problem domain. Assume they don't know good test design very well.
## Requirements
* Analyze the codebase architecture and identify affected modules.
* Read `AGENTS.md` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* Break down complex features or bugs into atomic, actionable steps.
* Propose solutions with clear rationale, trade-offs, and sequencing.
* Identify risks, edge cases, and testing considerations.
Save plans to: plans/YYYY-MM-DD-<plan-one-line-title>.md
## Constraints
* You are **read-only** — never create, edit, or delete files.
* 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`).
* Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Output format
When producing a plan, structure it as:
1. **Context** — What is the problem or feature request?
2. **Affected modules** — Which parts of the codebase are involved?
3. **Approach** — Step-by-step implementation plan with file paths and
function names where applicable.
4. **Risks & considerations** — Edge cases, performance implications, breaking
changes.
5. **Testing strategy** — How to verify the implementation works correctly.

View File

@ -1,56 +0,0 @@
---
name: Prompt Assistant
description: Refines and improves prompts for maximum clarity and effectiveness
mode: all
---
## Role
You are an expert Prompt Engineer with strong knowledge of
penpot. Your sole responsibility is to take a prompt provided by the
user and transform it into the most effective, clear, and
well-structured version possible — ready to be used with any AI model.
## Requirements
* You do NOT execute tasks. You do NOT write code. You only design and refine prompts
* Read `AGENTS.md` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* Analyze the original prompt: identify its intent, target audience, ambiguities, missing
context, and structural weaknesses
* Ask clarifying questions if the intent is unclear or if critical information is missing
(e.g. target model, expected output format, tone, constraints). Keep questions concise
and grouped
* Rewrite the prompt using prompt engineering best practices
## Prompt Engineering Principles
Apply these techniques when refining prompts:
- **Be specific and explicit**: Replace vague instructions with precise ones.
- **Set the context**: Include background information the model needs to
perform well.
- **Specify the output format**: State the desired structure, length, tone,
or format (e.g. bullet list, JSON, step-by-step).
- **Add constraints**: Include what the model should avoid or not do.
- **Use examples** (few-shot): When applicable, suggest adding examples to
anchor the model's behaviour.
- **Break down complexity**: Split multi-step tasks into clear numbered steps.
- **Avoid ambiguity**: Remove pronouns and references that could be
misinterpreted.
- **Chain of thought**: For reasoning tasks, include "Think step by step."
## Constraints
- Do NOT execute the prompt yourself.
- Do NOT answer the question inside the prompt.
- Do NOT add unnecessary verbosity — prompts should be as short as they can
be while remaining complete.
- Always preserve the user's original intent.
## Output
Refined Prompt: The improved, ready-to-use prompt. Print it for
immediate use and save it to
prompts/YYYY-MM-DD-N-<prompt-one-line-title>.md for future use.

View File

@ -0,0 +1,135 @@
---
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.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans that engineers or other agents can execute. Never writes
or modifies code.
## When to Use
- The user asks for a plan, design, or analysis of a feature or bug.
- The user wants to understand which parts of the codebase a task will touch.
- The user needs a step-by-step implementation plan with file paths, function
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).
Do **not** use this skill to actually implement anything — it is read-only.
## Role
You are a Senior Software Architect working on Penpot, an open-source design
tool. Your sole responsibility is planning and analysis — you do NOT write or
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.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
## Required Reading Before Planning
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.
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.
Skipping this step is the #1 cause of incorrect or incomplete plans.
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read `AGENTS.md` and the memory system conventions before drafting.
- 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.
## 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.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Plan Output
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:
- **Has write permission** (e.g. `build`, `general`, `engineer`): in addition
to the in-response plan, save the plan to:
```
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 `plans/`
directory if it does not exist.
- **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.
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).
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.
## Output Format
Structure the plan as:
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.
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.
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.

View File

@ -0,0 +1,114 @@
---
name: refine-prompt
description: Refine and improve a user-supplied prompt for maximum clarity and effectiveness using prompt-engineering best practices and Penpot project context. Outputs a rewritten prompt (and brief rationale); never executes the prompt.
---
# Refine Prompt
Expert prompt-engineering pass on a user-supplied prompt. Takes a draft prompt
and returns a clearer, more effective, well-structured version — ready to be
used with any AI model. Never executes the prompt itself.
## When to Use
- The user shares a prompt and asks to improve, refine, polish, or rewrite it.
- The user asks "make this prompt better" or "can you clean this up?".
- The user wants to add structure, constraints, examples, or output format to
a vague prompt.
- The user wants a prompt adapted for a specific target model, audience, or
task type.
Do **not** use this skill to actually answer the prompt or do the task — it
only rewrites the prompt.
## Role
You are an expert Prompt Engineer with strong knowledge of Penpot. Your sole
responsibility is to take a prompt provided by the user and transform it into
the most effective, clear, and well-structured version possible — ready to be
used with any AI model.
You do **not** execute tasks. You do **not** write code. You only design and
refine prompts.
## Required Reading Before Refining
Before rewriting, internalize the project context the prompt will likely run
against:
1. Read `AGENTS.md` (root) for the project-level rules and conventions.
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
understand the module layout (`frontend`, `backend`, `common`,
`render-wasm`, `exporter`, `mcp`, `plugins`, `library`).
3. Skim the relevant module's core memory (`mem:frontend/core`,
`mem:backend/core`, etc.) when the prompt targets a specific module — this
lets you inject precise vocabulary, file conventions, and test commands
into the refined prompt.
This step matters most when the user is preparing a prompt *about* the
Penpot codebase. For generic prompts, focus on prompt-engineering principles
and only weave in Penpot context when it is clearly relevant.
## Requirements
- Analyze the original prompt: identify its intent, target audience,
ambiguities, missing context, and structural weaknesses.
- Ask clarifying questions if the intent is unclear or if critical information
is missing (e.g. target model, expected output format, tone, constraints).
Keep questions concise and grouped. Prefer to ask 14 questions at once
rather than one at a time.
- Rewrite the prompt using prompt-engineering best practices (see below).
- Preserve the user's original intent — do not change the underlying task.
- When the user provides Penpot project context, weave in the relevant
conventions, module paths, and tooling.
## Prompt Engineering Principles
Apply these techniques when refining prompts:
- **Be specific and explicit**: Replace vague instructions with precise ones.
- **Set the context**: Include background information the model needs to
perform well.
- **Specify the output format**: State the desired structure, length, tone,
or format (e.g. bullet list, JSON, step-by-step).
- **Add constraints**: Include what the model should avoid or not do.
- **Use examples** (few-shot): When applicable, suggest adding examples to
anchor the model's behaviour.
- **Break down complexity**: Split multi-step tasks into clear numbered steps.
- **Avoid ambiguity**: Remove pronouns and references that could be
misinterpreted.
- **Chain of thought**: For reasoning tasks, include "Think step by step."
- **Role framing**: When helpful, give the model a clear role
("You are a senior backend engineer...").
- **Tool awareness**: When the prompt targets an agentic model, mention
relevant tools (`grep`, `glob`, `read`, `bash`, etc.) so the model uses the
right surface.
## Constraints
- Do **not** execute the prompt yourself.
- Do **not** answer the question inside the prompt.
- Do **not** add unnecessary verbosity — prompts should be as short as they
can be while remaining complete.
- Always preserve the user's original intent.
- If the user provides Penpot project context, prefer Penpot-specific
vocabulary over generic terms (e.g. name actual modules and `mem:`
references instead of "the codebase").
## Output Format
Deliver the result in the response as two clearly separated blocks:
1. **Refined prompt** — a single fenced code block (markdown ```) containing
the rewritten prompt, ready to copy and use.
2. **What changed (brief)** — a short bulleted list of the most important
changes you made and why (37 bullets max). Skip the rationale if the
changes are trivial.
If you asked clarifying questions, list them in a separate **Clarifying
questions** section above the refined prompt and stop — do not produce a
refined prompt until the user answers. If the user explicitly told you to
proceed without questions (e.g. "just rewrite it"), make reasonable
assumptions and note them under **Assumptions made** in the rationale block.
No file persistence — the refined prompt lives entirely in the response.

View File

@ -60,6 +60,33 @@ RUN set -eux; \
corepack enable; \
rm -rf /tmp/nodejs.tar.gz;
################################################################################
## OPENCODE SETUP
################################################################################
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; exit 1; \
;; \
esac; \
curl -fsSL ${BINARY_URL} -o /tmp/opencode.tar.gz; \
mkdir -p /tmp/opencode; \
tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode; \
chmod +x /tmp/opencode/opencode; \
rm -f /tmp/opencode.tar.gz;
################################################################################
## CADDYSERVER SETUP
@ -67,7 +94,7 @@ RUN set -eux; \
FROM base AS setup-caddy
ENV CADDY_VERSION=2.11.2
ENV CADDY_VERSION=2.11.4
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -99,7 +126,7 @@ RUN set -eux; \
FROM base AS setup-jvm
# https://clojure.org/releases/tools
ENV CLOJURE_VERSION=1.12.4.1618
ENV CLOJURE_VERSION=1.12.5.1654
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -181,11 +208,11 @@ RUN set -eux; \
FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.04.15 \
BABASHKA_VERSION=1.12.208 \
ENV CLJKONDO_VERSION=2026.05.25 \
BABASHKA_VERSION=1.12.218 \
CLJFMT_VERSION=0.16.4 \
PIXI_VERSION=0.67.2 \
GITHUB_CLI_VERSION=2.91.0 \
GITHUB_CLI_VERSION=2.96.0 \
UV_VERSION=0.11.9 \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/opt/utils/bin \
@ -471,6 +498,7 @@ COPY --from=setup-rust /opt/cargo /opt/cargo
COPY --from=setup-rust /opt/rustup /opt/rustup
COPY --from=setup-rust /opt/emsdk /opt/emsdk
COPY --from=setup-caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=setup-opencode /tmp/opencode/opencode /opt/utils/bin/opencode
COPY files/nginx.conf /etc/nginx/nginx.conf
COPY files/nginx-mime.types /etc/nginx/mime.types

View File

@ -23,3 +23,11 @@ alias lsf='ls -h *(.)'
if [ -f "$HOME/.bashrc.local" ]; then
. "$HOME/.bashrc.local"
fi
# pnpm
export PNPM_HOME="/home/penpot/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
# pnpm end

View File

@ -104,6 +104,60 @@ test("Selection size badge appears on selection and hides on deselect", async ({
await expect(badge).toHaveCount(0);
});
test("Selection size badge uses component color for component selection", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("components/get-file-13267.json");
await workspacePage.goToWorkspace({
fileId: "e9c84e12-dd29-80fc-8007-86d559dced7f",
pageId: "e9c84e12-dd29-80fc-8007-86d559dced80",
});
await workspacePage.clickLeafLayer("A Component");
const badge = page.locator(".selection-size-badge");
await expect(badge).toBeVisible();
await expect(badge.locator("rect")).toHaveCSS("fill", "rgb(187, 151, 216)");
await expect(badge.locator("text")).toHaveCSS("fill", "rgb(255, 255, 255)");
});
test("Selection size badge shows unrotated dimensions for rotated single selection", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-not-empty.json",
);
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspacePage.goToWorkspace({
fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374",
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspacePage.clickLeafLayer("Rectangle");
const badgeText = page.locator(".selection-size-badge text");
await expect(badgeText).toHaveText("126 x 134");
const rotationInput = workspacePage.rightSidebar.getByRole("textbox", {
name: "Rotation",
});
await rotationInput.fill("45");
await rotationInput.press("Enter");
await expect(rotationInput).toHaveValue("45");
await expect(badgeText).toHaveText("126 x 134");
});
test("User makes a group", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();

View File

@ -308,3 +308,24 @@
normal progress becomes tagged as slow if no event received in the
specified amount of time"
1000)
;; ------------------------------------------------
;; Typography
;; ------------------------------------------------
(def ^:const font-size 11)
;; ------------------------------------------------
;; Colors (CSS custom properties)
;; ------------------------------------------------
(def ^:const select-color "var(--color-accent-tertiary)")
(def ^:const distance-color "var(--color-accent-quaternary)")
(def ^:const distance-text-color "var(--app-white)")
;; ------------------------------------------------
;; Selection rectangle & guides
;; ------------------------------------------------
(def ^:const selection-rect-width 1)

View File

@ -1,5 +1,6 @@
(ns app.main.ui.flex-controls.common
(:require
[app.main.constants :as mconst]
[app.main.ui.formats :as fmt]
[rumext.v2 :as mf]))
@ -7,10 +8,8 @@
;; CONSTANTS
;; ------------------------------------------------
(def font-size 11)
(def distance-color "var(--color-accent-quaternary)")
(def distance-text-color "var(--app-white)")
(def warning-color "var(--status-color-warning-500)")
(def flex-display-pill-width 40)
(def flex-display-pill-height 20)
(def flex-display-pill-border-radius 4)
@ -30,6 +29,6 @@
:y (+ y (/ height 2))
:text-anchor "middle"
:dominant-baseline "central"
:style {:fill distance-text-color
:style {:fill mconst/distance-text-color
:font-size font-size}}
(fmt/format-number (or value 0))]])

View File

@ -15,6 +15,7 @@
[app.common.geom.shapes.points :as gpo]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.main.constants :as mconst]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
@ -109,7 +110,7 @@
:on-pointer-down on-move-selected
:on-context-menu on-context-menu
:style {:fill (if (or is-hover is-selected) fcc/distance-color "none")
:style {:fill (if (or is-hover is-selected) mconst/distance-color "none")
:opacity (if is-selected 0.5 0.25)}}]
(let [handle-width
@ -134,7 +135,7 @@
:on-context-menu on-context-menu
:class (when (or is-hover is-selected)
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90)))
:style {:fill (if (or is-hover is-selected) fcc/distance-color "none")
:style {:fill (if (or is-hover is-selected) mconst/distance-color "none")
:opacity (if is-selected 0 1)}}])]))
(mf/defc gap-rects*
@ -341,9 +342,9 @@
[:& fcc/flex-display-pill
{:height pill-height
:width pill-width
:font-size (/ fcc/font-size zoom)
:font-size (/ mconst/font-size zoom)
:border-radius (/ fcc/flex-display-pill-border-radius zoom)
:color fcc/distance-color
:color mconst/distance-color
:x (:x @mouse-pos)
:y (- (:y @mouse-pos) pill-width)
:value @hover-value}])]))

View File

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.constants :as mconst]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
@ -224,7 +225,7 @@
[:& fcc/flex-display-pill
{:height pill-height
:width pill-width
:font-size (/ fcc/font-size zoom)
:font-size (/ mconst/font-size zoom)
:border-radius (/ fcc/flex-display-pill-border-radius zoom)
:color fcc/warning-color
:x (:x @mouse-pos)

View File

@ -9,6 +9,7 @@
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.constants :as mconst]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features]
@ -115,7 +116,7 @@
:on-pointer-move on-pointer-move
:on-pointer-down on-move-selected
:on-context-menu on-context-menu
:style {:fill (if (or is-hover is-selected) fcc/distance-color "none")
:style {:fill (if (or is-hover is-selected) mconst/distance-color "none")
:opacity (if is-selected 0.5 0.25)}}]
(let [handle-width
@ -145,7 +146,7 @@
(cur/get-dynamic "resize-ew" 90)))
:style
{:fill (if (or is-hover is-selected) fcc/distance-color "none")
{:fill (if (or is-hover is-selected) mconst/distance-color "none")
:opacity (if is-selected 0 1)}}])]))
(mf/defc padding-rects*
@ -267,9 +268,9 @@
[:& fcc/flex-display-pill
{:height pill-height
:width pill-width
:font-size (/ fcc/font-size zoom)
:font-size (/ mconst/font-size zoom)
:border-radius (/ fcc/flex-display-pill-border-radius zoom)
:color fcc/distance-color
:color mconst/distance-color
:x (:x @mouse-pos)
:y (- (:y @mouse-pos) pill-width)
:value @hover-value}])]))

View File

@ -8,18 +8,10 @@
(:require
[app.common.data :as d]
[app.common.geom.shapes :as gsh]
[app.main.constants :as mconst]
[app.main.ui.measurements :refer [size-display* measurement*]]
[rumext.v2 :as mf]))
;; ------------------------------------------------
;; CONSTANTS
;; ------------------------------------------------
(def select-color "var(--color-accent-tertiary)")
(def selection-rect-width 1)
(def select-guide-width 1)
(def select-guide-dasharray 5)
(defn resolve-shapes
[objects ids]
(let [resolve-shape (d/getf objects)]
@ -41,14 +33,14 @@
(mf/defc selection-rect [{:keys [selrect zoom]}]
(let [{:keys [x y width height]} selrect
selection-rect-width (/ selection-rect-width zoom)]
selection-rect-width (/ mconst/selection-rect-width zoom)]
[:g.selection-rect
[:rect {:x x
:y y
:width width
:height height
:style {:fill "none"
:stroke select-color
:stroke mconst/select-color
:stroke-width selection-rect-width}}]]))
(mf/defc selection-feedback

View File

@ -13,7 +13,9 @@
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.constants :as mconst]
[app.main.ui.formats :as fmt]
[rumext.v2 :as mf]))
@ -21,36 +23,30 @@
;; CONSTANTS
;; ------------------------------------------------
(def font-size 11)
(def selection-rect-width 1)
(def ^:private ^:const size-display-color "var(--app-white)")
(def ^:private ^:const size-display-opacity 0.7)
(def ^:private ^:const size-display-text-color "var(--app-black)")
(def ^:private ^:const size-display-width-min 50)
(def ^:private ^:const size-display-width-max 75)
(def ^:private ^:const size-display-height 16)
(def select-color "var(--color-accent-tertiary)")
(def select-guide-width 1)
(def select-guide-dasharray 5)
(def hover-color "var(--color-accent-quaternary)")
(def size-display-color "var(--app-white)")
(def size-display-opacity 0.7)
(def size-display-text-color "var(--app-black)")
(def size-display-width-min 50)
(def size-display-width-max 75)
(def size-display-height 16)
(def distance-color "var(--color-accent-quaternary)")
(def distance-text-color "var(--app-white)")
(def distance-border-radius 2)
(def distance-pill-width 50)
(def distance-pill-height 16)
(def distance-line-stroke 1)
(def ^:private ^:const distance-border-radius 2)
(def ^:private ^:const distance-pill-width 50)
(def ^:private ^:const distance-pill-height 16)
(def ^:private ^:const distance-line-stroke 1)
(def ^:private ^:const selection-badge-bg-color "var(--color-accent-tertiary)")
(def ^:private ^:const selection-badge-bg-color-component "var(--color-accent-secondary)")
(def ^:private ^:const selection-badge-height 16)
(def ^:private ^:const selection-badge-padding-x 6)
(def ^:private ^:const selection-badge-vertical-gap 8)
(def ^:private ^:const selection-badge-border-radius 2)
(def ^:private ^:const selection-badge-char-width 6.5)
(def ^:private ^:const select-guide-width 1)
(def ^:private ^:const select-guide-dasharray 5)
(def ^:private ^:const hover-color "var(--color-accent-quaternary)")
;; ------------------------------------------------
;; HELPERS
@ -128,13 +124,13 @@
:height rect-height
:text-anchor "middle"
:style {:fill size-display-text-color
:font-size (/ font-size zoom)}}
:font-size (/ mconst/font-size zoom)}}
size-label]]))
(mf/defc distance-display-pill* [{:keys [x y zoom distance bounds]}]
(let [distance-pill-width (/ distance-pill-width zoom)
distance-pill-height (/ distance-pill-height zoom)
font-size (/ font-size zoom)
font-size (/ mconst/font-size zoom)
text-padding (/ 3 zoom)
distance-border-radius (/ distance-border-radius zoom)
@ -162,7 +158,7 @@
:ry distance-border-radius
:width distance-pill-width
:height distance-pill-height
:style {:fill distance-color}}]
:style {:fill mconst/distance-color}}]
[:text {:x (+ text-x offset-x)
:y (+ text-y offset-y)
@ -171,13 +167,13 @@
:text-anchor "middle"
:width distance-pill-width
:height distance-pill-height
:style {:fill distance-text-color
:style {:fill mconst/distance-text-color
:font-size font-size}}
(fmt/format-pixels distance)]]))
(mf/defc selection-rect* [{:keys [selrect zoom]}]
(let [{:keys [x y width height]} selrect
selection-rect-width (/ selection-rect-width zoom)]
selection-rect-width (/ mconst/selection-rect-width zoom)]
[:g.selection-rect
[:rect {:x x
:y y
@ -187,34 +183,124 @@
:stroke hover-color
:stroke-width selection-rect-width}}]]))
(defn- get-edge-for-badge
"For each rotation range, select the 'most horizontal' edge at the bottom,
as seen from the user's perspective."
[rotation]
(let [rot (mod rotation 360)]
(cond
(or (< rot 45) (>= rot 315)) :bottom
(< rot 135) :right
(< rot 225) :top
:else :left)))
(defn- get-edge-points
"Get the points that define the selected side of a rectangle"
[points edge]
(let [[p0 p1 p2 p3] points]
(case edge
:bottom [p2 p3]
:right [p1 p2]
:top [p0 p1]
:left [p3 p0])))
(mf/defc selection-size-badge*
[{:keys [selrect zoom]}]
(let [{:keys [x y width height]} selrect
size-label (dm/str (fmt/format-number width) " x " (fmt/format-number height))
badge-height (/ selection-badge-height zoom)
padding-x (/ selection-badge-padding-x zoom)
gap (/ selection-badge-vertical-gap zoom)
radius (/ selection-badge-border-radius zoom)
text-width (* (count size-label) (/ selection-badge-char-width zoom))
badge-width (+ text-width (* 2 padding-x))
center-x (+ x (/ width 2))
badge-x (- center-x (/ badge-width 2))
badge-y (+ y height gap)
text-y (+ badge-y (/ badge-height 2))]
[:g.selection-size-badge {:pointer-events "none"}
[:rect {:x badge-x
:y badge-y
:width badge-width
:height badge-height
:rx radius
:ry radius
:style {:fill selection-badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x center-x
:y text-y
:text-anchor "middle"
:dominant-baseline "middle"}
size-label]]))
[{:keys [zoom shapes]}]
(let [badge-height (/ selection-badge-height zoom)
badge-padding-x (/ selection-badge-padding-x zoom)
badge-gap (/ selection-badge-vertical-gap zoom)
badge-radius (/ selection-badge-border-radius zoom)
badge-char-width (/ selection-badge-char-width zoom)
single-shape (and (= (count shapes) 1) (first shapes))
component-color? (if single-shape
(ctk/instance-head? single-shape)
(every? ctk/instance-head? shapes))
badge-bg-color (if component-color?
selection-badge-bg-color-component
selection-badge-bg-color)
rotation (when single-shape (dm/get-prop single-shape :rotation))
has-rotation? (and rotation (not (mth/almost-zero? rotation)))
;; Always compute the selrect from :points via shapes->rect.
;; This gives the correct bounding box for all shape types,
;; including component instances and shapes with transforms.
selrect (gsh/shapes->rect shapes)
;; For single shapes we show the original dimensions,
;; for multiple shapes show the bounding box
shape-width (if single-shape
(dm/get-prop single-shape :width)
(:width selrect))
shape-height (if single-shape
(dm/get-prop single-shape :height)
(:height selrect))
text (dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height))
text-width (* (count text) badge-char-width)
badge-width (+ text-width (* 2 badge-padding-x))]
(if has-rotation?
(let [edge (get-edge-for-badge rotation)
points (dm/get-prop single-shape :points)
[ep1 ep2] (get-edge-points points edge)
mid-point (gpt/lerp ep1 ep2 0.5)
normal (gpt/normal-right (gpt/subtract ep2 ep1))
rot-offset (case edge
:bottom 0
:right 270
:top 180
:left 90)
badge-rot (+ rotation rot-offset)
offset (+ badge-gap (/ badge-height 2))
badge-x (- (/ badge-width 2))
badge-y (- (/ badge-height 2))
badge-cx (+ (:x mid-point) (* (:x normal) offset))
badge-cy (+ (:y mid-point) (* (:y normal) offset))]
[:g.selection-size-badge {:pointer-events "none"
:transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")}
[:rect {:x badge-x
:y badge-y
:width badge-width
:height badge-height
:rx badge-radius
:ry badge-radius
:style {:fill badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x 0
:y 0
:text-anchor "middle"
:dominant-baseline "middle"}
text]])
(let [badge-x (- (/ badge-width 2))
badge-y (- (/ badge-height 2))
badge-cx (+ (:x selrect) (/ (:width selrect) 2))
badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))]
[:g.selection-size-badge {:pointer-events "none"
:transform (dm/str "translate(" badge-cx "," badge-cy ")")}
[:rect {:x badge-x
:y badge-y
:width badge-width
:height badge-height
:rx badge-radius
:ry badge-radius
:style {:fill badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x 0
:y 0
:text-anchor "middle"
:dominant-baseline "middle"}
text]]))))
(mf/defc distance-display* [{:keys [from to zoom bounds]}]
(let [fixed-x (if (gsh/fully-contained? from to)
@ -245,7 +331,7 @@
:y1 y1
:x2 x2
:y2 y2
:style {:stroke distance-color
:style {:stroke mconst/distance-color
:stroke-width distance-line-stroke}}]
[:> distance-display-pill*
@ -263,7 +349,7 @@
:y1 y1
:x2 x2
:y2 y2
:style {:stroke select-color
:style {:stroke mconst/select-color
:stroke-width (/ select-guide-width zoom)
:stroke-dasharray (/ select-guide-dasharray zoom)}}])])

View File

@ -4,10 +4,9 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
.badge-text {
fill: var(--app-black);
font-size: calc(deprecated.$fs-12 / var(--zoom));
font-family: "worksans", "vazirmatn", sans-serif;
fill: var(--color-static-white);
font-size: calc(px2rem(12) / var(--zoom));
}

View File

@ -510,7 +510,7 @@
(not edition)
(not mode-inspect?))
[:> msr/selection-size-badge*
{:selrect (gsh/shapes->rect selected-shapes)
{:shapes selected-shapes
:zoom zoom}])
(when show-measures?

View File

@ -781,7 +781,7 @@
(not mode-inspect?)
(not page-transition?))
[:> msr/selection-size-badge*
{:selrect (gsh/shapes->rect selected-shapes)
{:shapes selected-shapes
:zoom zoom}])
(when show-measures?