diff --git a/.gitignore b/.gitignore index 5bc58cd0b2..7c3309ac20 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md index 202e4fde04..395fe6416c 100644 --- a/.opencode/agents/commiter.md +++ b/.opencode/agents/commiter.md @@ -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 ` 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 "" -m ""` (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: +``` + +- 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 `` 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. diff --git a/.opencode/agents/engineer.md b/.opencode/agents/engineer.md deleted file mode 100644 index 9857ba9d2a..0000000000 --- a/.opencode/agents/engineer.md +++ /dev/null @@ -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. diff --git a/.opencode/agents/planner.md b/.opencode/agents/planner.md deleted file mode 100644 index 8ced366d34..0000000000 --- a/.opencode/agents/planner.md +++ /dev/null @@ -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-.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. - - diff --git a/.opencode/agents/prompt-assistant.md b/.opencode/agents/prompt-assistant.md deleted file mode 100644 index dddcbd1c3f..0000000000 --- a/.opencode/agents/prompt-assistant.md +++ /dev/null @@ -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-.md for future use. diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md new file mode 100644 index 0000000000..7886dda85f --- /dev/null +++ b/.opencode/skills/planner/SKILL.md @@ -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-.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. diff --git a/.opencode/skills/refine-prompt/SKILL.md b/.opencode/skills/refine-prompt/SKILL.md new file mode 100644 index 0000000000..8cc7473619 --- /dev/null +++ b/.opencode/skills/refine-prompt/SKILL.md @@ -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 1–4 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 (3–7 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. diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index a996e4d2f1..1b854a48cb 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -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 diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index ed497021f6..5097ab010e 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -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 diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index 24c31a0061..51f407c1e8 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -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(); diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs index f838005d48..42e6a02e32 100644 --- a/frontend/src/app/main/constants.cljs +++ b/frontend/src/app/main/constants.cljs @@ -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) diff --git a/frontend/src/app/main/ui/flex_controls/common.cljs b/frontend/src/app/main/ui/flex_controls/common.cljs index f9f5e15ab1..1aa28a5289 100644 --- a/frontend/src/app/main/ui/flex_controls/common.cljs +++ b/frontend/src/app/main/ui/flex_controls/common.cljs @@ -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))]]) diff --git a/frontend/src/app/main/ui/flex_controls/gap.cljs b/frontend/src/app/main/ui/flex_controls/gap.cljs index 710b89882f..38926c9887 100644 --- a/frontend/src/app/main/ui/flex_controls/gap.cljs +++ b/frontend/src/app/main/ui/flex_controls/gap.cljs @@ -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}])])) diff --git a/frontend/src/app/main/ui/flex_controls/margin.cljs b/frontend/src/app/main/ui/flex_controls/margin.cljs index 20f486fdeb..d8ad044355 100644 --- a/frontend/src/app/main/ui/flex_controls/margin.cljs +++ b/frontend/src/app/main/ui/flex_controls/margin.cljs @@ -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) diff --git a/frontend/src/app/main/ui/flex_controls/padding.cljs b/frontend/src/app/main/ui/flex_controls/padding.cljs index b751c565d3..c10f83cd31 100644 --- a/frontend/src/app/main/ui/flex_controls/padding.cljs +++ b/frontend/src/app/main/ui/flex_controls/padding.cljs @@ -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}])])) diff --git a/frontend/src/app/main/ui/inspect/selection_feedback.cljs b/frontend/src/app/main/ui/inspect/selection_feedback.cljs index 5e3f57f169..3679be0283 100644 --- a/frontend/src/app/main/ui/inspect/selection_feedback.cljs +++ b/frontend/src/app/main/ui/inspect/selection_feedback.cljs @@ -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 diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index aca38aa5d8..b42a1a18af 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -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)}}])]) diff --git a/frontend/src/app/main/ui/measurements.scss b/frontend/src/app/main/ui/measurements.scss index 660f9e745f..4babfe43dc 100644 --- a/frontend/src/app/main/ui/measurements.scss +++ b/frontend/src/app/main/ui/measurements.scss @@ -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)); } diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 5941f1301e..6841784f44 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -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? diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 7ae291414d..ad42bd12f3 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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?