Compare commits

...
23 Commits
Author SHA1 Message Date
Andrey Antukh 37f7ba4833 📚 Note general subagent delegation in flows intro
Delegating plan and review to a subagent (engineer-* or the
builtin general) starts a clean context instead of growing the
main session. Delegating to general keeps the same model.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-11 15:44:15 +00:00
Andrey Antukh ed4367a782 📎 Add missing optional system prompt file for enginer agent 2026-09-11 17:39:57 +02:00
Andrey Antukh 74fd3ac8c3 📚 Restructure .agents README as agentic devenv guide
Turn the skills-only file into a full intro to opencode inside
plain devenv: setup, providers, models, opencode.json example,
gh auth, flows, and a skills summary at the end.

Provider, model, and flow sections follow Andrey's own setup
notes; the FAQ stays as a stub for later.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-11 15:34:29 +00:00
Eva Marco c0221a9bf8 🐛 Hide "Create typography style" button for shapes with missing fonts (#11527)
The button let users convert a text shape's inline styles into a
typography asset even when the shape's font-id couldn't be resolved
(e.g. a custom/team font that was removed or isn't loaded), silently
baking a missing font into the new typography asset.

Guard the button on the font actually resolving via
app.main.fonts/fontsdb, in addition to the existing checks (no
typography or token already applied, single selection).

Added e2e coverage for all four conditions that must independently
hide the button: missing font, applied typography asset, multiple
selection with differing values, and applied typography token.

AI-assisted-by: claude-sonnet-5
2026-09-11 13:41:11 +02:00
Alonso Torres b598d7d72e 🐛 Fix problem in plugins api when removing interactions (#11621) 2026-09-11 13:19:55 +02:00
Andrey Antukh 06239844b1 🐛 Fix chunked upload storage amplification and cap chunk size (#11635)
* 🐛 Reject duplicate chunk index in chunked uploads

Repeat uploads of the same chunk index each stored a new
object because upload-chunk only checked index bounds. Run the
handler in a transaction, lock the session row and reject an
already-stored index with :duplicate-chunk-index.

Also harden assemble-chunks to require exactly indices 0..n-1
 so gaps or duplicates fail instead of assembling a corrupt
file. Covers media, fonts and binfile through the shared
helper.

Closes #11634

AI-assisted-by: muse-spark-1.3-contributor

*  Cap upload chunk size at 30 MiB by default

Chunks were only bounded by the 350 MiB HTTP body limit while the
30 MiB caps applied to the assembled file. Add :upload-max-chunk-size
(default 30 MiB, tunable via env) and reject oversize chunks in
upload-chunk with :validation/:chunk-too-large before anything is
stored. App clients slice at 25/10 MiB, so no frontend change needed.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix tx-run! call and storage resolve in upload-chunk

Pass cfg as first arg to db/tx-run!, which expects [system f & params; without it every chunk upload raised invalid system/cfg provided and no chunk was stored, breaking assemble with missing-chunks. Also resolve storage without reuse-conn: put-object! writes to the backend outside any transaction, so reusing the tx connection gives no atomicity. Media, font and storage suites green, lint and format clean. AI-assisted-by: muse-spark-1.3-contributor
2026-09-11 12:10:57 +02:00
Andrey Antukh 09736aa4c9 Enforce commit body line wrapping
Add a body line-length validator to scripts/check-commit. It
fails when a body line exceeds 76 characters, exempting
trailers, URLs, and unbreakable tokens. The 76 limit leaves
room for git log's four-space indent in an 80-column
terminal.

Align the subject limit with the documented 70 characters;
the checker allowed 90 before.

Document the rule as a hard, verifiable requirement in
AGENTS.md, CONTRIBUTING.md, the create-commit skill, and
the workflow memory, and point at scripts/check-commit.

Add tests for the validator and the subject length rule.

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

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

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

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

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

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

Closes #11084

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

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

* ♻️ Use buttons from DS

* ♻️ Use new SCSS guidelines

* ♻️ Use a base stylesheet for all version files

* ♻️ Use new SCSS guidelines

*  Add new images and wording

---------

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

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 11:45:00 +02:00
Pablo Alba d263c23a58 🐛 Add ssrf check for nitrate sso and add timeouts to http client (#11576) 2026-09-09 11:23:53 +02:00
113 changed files with 3354 additions and 2443 deletions

No files matched your search

+389 -17
View File
@@ -1,19 +1,391 @@
# Agent skills
# Agentic development with opencode inside devenv
This folder is the single home for the skills our coding agents use.
Each skill is a folder with a `SKILL.md` inside — a short instruction
manual that an agent loads only when it needs it.
This doc shows how to run AI-assisted development for Penpot inside the
devenv with [opencode](https://opencode.ai). It covers the setup once,
then points to the skills that drive daily work.
One copy serves every tool:
Full reference lives in the technical guide:
- **opencode** reads this folder directly.
- **Claude Code** reads it through the `.claude/skills` symlink.
- **Codex** reads it directly.
- [Dev environment](../docs/technical-guide/developer/devenv.md)
To change how the agents behave, edit the `SKILL.md` here. There is no
second copy to keep in sync.
This file does not repeat those guides. It gives the short path and
leaves room for notes we add step by step.
## How the skills are organized
## TL;DR
```bash
./manage.sh pull-devenv
./manage.sh run-devenv --ws 0 --attach
```
Then open a shell in the container tmux session, run `opencode` inside
~/penpot directory.
## 1. Introduction
The LLM client — opencode, Claude Code, or Codex — runs in a shell
inside the plain devenv container, with the repo mounted and the
skills in this folder driving the work. One client session per
workspace (`ws0` is the live repo, `ws1+` are sibling clones).
This doc is written around opencode, but Claude Code runs the same
way inside devenv and follows exactly the same flows. This is not
the "agentic devenv" (`--agentic`) from the technical guide, which
runs the client outside devenv and wires it in over MCP — here the
client lives inside the sandboxed devenv docker.
Unlike the agentic devenv, running the client inside the devenv
docker gives it full access to the live environment: every
dependency already resolved by the image, so the agent can write
and run tests directly, query the running PostgreSQL, and reach
the backend and frontend through nREPL — no proxies, no round
trips outside the container.
And if you later want vision, it is one MCP entry away — a
headless Playwright server in your `opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp", "--headless"],
"enabled": true,
"env": {}
}
}
}
```
## 2. Quickstart: bring up devenv and run opencode inside
Pull once, then bring up the workspace you want (add `--ws 1`,
`--ws 2`, … for more):
```bash
./manage.sh pull-devenv
./manage.sh run-devenv --ws 0 --attach # ws0 (the live repo)
```
This attaches to the container tmux session. Open a new shell there
(`Ctrl+b c`), `cd` to the repo, and run opencode directly:
```bash
cd penpot;
opencode
```
One session drives exactly one workspace — for N parallel workspaces,
open one container shell and one opencode session per workspace.
Stop with `./manage.sh stop-devenv [--ws N | --all]`. Shared infra
stops when the last workspace stops.
## 3. Connecting providers
### Starting point: Zen free models, no login needed
The easiest way to try the setup is Zen's free models — they need no
login and no key. Just run opencode, pick a free model, and work.
That said, creating a Zen account and connecting with your API key is
still worth it from day one: it unlocks the full model list, spend
limits, auto-reload, and the Go-overflow fallback below. Connect
through the TUI:
- Run `/connect`, pick a provider, paste the key.
- Run `/models` to see what that provider offers.
### OpenCode Go (subscription, best value for daily use)
$10/month subscription with generous included usage — up to $60/month
of model consumption at published per-token rates, depending on the
model. Best value if you work mostly with open coding models
(GLM, Kimi, Qwen, DeepSeek, MiniMax, LongCat…).
The strong part: when you hit a model's monthly limit, Go can fall
back to your Zen balance instead of blocking (enable *Use balance* in
the console). So the setup many of us use is Go + Zen credit on top —
subscription first, pay-as-you-go overflow after.
### OpenCode Zen (pay per use)
Zen is the opencode team's gateway: curated models tested for coding
agents, fair prices, no markups, stable latency. You top up credit
and pay per request, with monthly spend limits and auto-reload.
Free usage is generous: the free models carry limits good enough for
real work, not just a quick taste. Worth knowing: brand-new,
unannounced models often show up on Zen first with a very generous
free quota so people try them — e.g. `0x Alpha`, which later turned
out to be GLM-5.3-Flash. Keep an eye on the free list; the newest
entry is often the best deal.
Two reasons to have a Zen account even with Go:
- It absorbs Go overflow (see above).
- Its free models let you try the whole setup before paying.
### OpenRouter (widest catalog)
If you already have an account, connect it: the widest model range in
one place. Trade-off is latency and occasional instability versus Zen,
which is tuned for coding agents.
Beyond code: OpenRouter also serves image, video, and audio models.
opencode itself cannot call those directly — it is built for code —
but a cheap model can quickly build you a small tool or script that
talks to them through the OpenRouter API. So if you also generate
content other than code and text, having OpenRouter connected is
worth it: the agent wires the plumbing for you.
### OpenAI (subscription or API key)
If you have an OpenAI subscription or API access, connect it — it
works very well as a daily driver alongside (or instead of) Go/Zen.
### Suggested combos
| Profile | Connect |
|---|---|
| Try it out | Nothing (Zen free models, no login) |
| Try it out, properly | Zen account + API key (free models + limits) |
| Daily use, best value | Go + Zen credit (overflow) |
| Widest model choice | Add OpenRouter |
| Already pay OpenAI | Add OpenAI account |
## 4. Recommended models
Personal picks from Andrey, current as of September 2026. Models come
and go, so treat this as a snapshot — the shape (one cheap solver,
one reviewer/planner, one explorer) matters more than the names.
| Model | Role | How often |
|---|---|---|
| Muse Spark 1.3 (`high`) | Main solver: plan, review, develop. Sharp and cheap — covers ~70% of coding tasks. | Daily |
| GLM-5.3-Flash | Reasoning all-rounder, now mostly code/plan reviewer and planner. | Daily |
| DeepSeek V4.1 Flash (`high`) | Explorer: code and idea exploration, sometimes development. Especially good at small bash/node utilities for repo chores and changelog updates. | Daily |
| LongCat 2.0 | Backup solver, occasional stand-in for Muse Spark 1.3. | Weekly |
| GPT-5.6 Luna | Alternative to DeepSeek Flash; pricier, unclear the extra cost pays off. | Rarely |
| Qwen3.8 Flash | As strong as the top three; used in rotation to avoid hammering one model. Less Go subsidy than the top picks, so mostly in overflow mode. | Overflow |
| MiMo-V2.5-Pro | Former main model; slightly pricier now next to Muse Spark / GLM-Flash / LongCat, and less Go subsidy — used in overflow. | Overflow |
| Kimi K3 | Heavy reasoning for hard reviews and plans. Expensive, ~1% of tasks. | Rarely |
| GLM-5.3 | Same slot as Kimi K3: hard reviews and plans only. | Rarely |
**TL;DR:** the first three (Muse Spark 1.3, GLM-5.3-Flash, DeepSeek
V4.1 Flash) are a good starting point.
## 5. Customizing your `opencode.json`
opencode merges config in this order (later wins):
1. Global: `~/.config/opencode/opencode.json` (on host, or the dir
mounted with `--opencode-config-dir` inside devenv —
see §9 Advanced usage).
2. Project: `opencode.json` at the repo root (gitignored on purpose —
use it to override the global entries for one workspace).
Below is a full working example of my personal config at the date of
writing this. It is only an example: define whatever subagents you
need, with whatever models you like or work with.
Copy it to `opencode.json` on the root of the repo:
```json
{
"$schema": "https://opencode.ai/config.json",
"disabled_providers": ["amazon-bedrock"],
"subagent_depth": 2,
"agent": {
"compaction": {
"model": "opencode-go/deepseek-flash",
"variant": "high"
},
"title": {
"model": "opencode-go/deepseek-flash",
"variant": "low"
},
"explore": {
"model": "opencode-go/deepseek-flash",
"variant": "high"
},
"build": {
"prompt": "{file:.agents/prompts/engineer-agent-prompt.md}",
"permission": {
"external_directory": {
"/tmp/**": "allow"
}
}
},
"general": {
"prompt": "{file:.agents/prompts/engineer-agent-prompt.md}",
"permission": {
"external_directory": {
"/tmp/**": "allow"
}
}
},
"engineer-glm": {
"mode": "subagent",
"model": "opencode-go/glm-5.3-flash",
"variant": "high",
"prompt": "{file:.agents/prompts/engineer-agent-prompt.md}",
"permission": {
"*": "allow",
"task": {
"*": "allow"
}
}
},
"engineer-kimi": {
"mode": "subagent",
"model": "opencode/kimi-k3",
"variant": "high",
"prompt": "{file:.agents/prompts/engineer-agent-prompt.md}",
"permission": {
"*": "allow",
"task": {
"*": "allow"
}
}
},
"engineer-qwen": {
"mode": "subagent",
"model": "opencode-go/qwen3.7-plus",
"variant": "high",
"prompt": "{file:.agents/prompts/engineer-agent-prompt.md}",
"permission": {
"*": "allow",
"task": {
"*": "allow"
}
}
}
}
}
```
What the blocks mean:
- `compaction` / `title` / `explore`: cheap background agents. Keep
them on a fast model; `title` uses the `low` variant on purpose.
- `build` / `general`: the main agents. They load the shared prompt
`{file:.agents/prompts/engineer-agent-prompt.md}` and may only touch
`/tmp/**` outside the repo without asking for explicit permision.
- `engineer-*`: one subagent per model family, all with the same
prompt and full permissions (`"*": "allow"`). They purpose are
specially for delegate work to them because are defined to be used
only as subagents.
- `disabled_providers` / `subagent_depth`: global guards. Keep
`"$schema"` — opencode refuses to start if any field is wrong.
How the `engineer-*` subagents are actually used — delegating work to
them to keep the main context clean — is covered in §6 Common agentic
flows.
Note this is opencode-only: other clients have their own way of
defining subagents or helpers — or none at all.
## 6. Common agentic flows
Work happens two ways: directly in your session, or delegated to a
subagent. Besides the `engineer-*` subagents from §5 there is a
builtin `general` subagent. Delegating planning and review to a
subagent starts a fresh, clean context with a clean prompt instead
of growing the main session — the main lever for keeping context
small. To delegate without switching models, delegate to `general`.
### Issue / error report flow
1. **Frame the problem.** Enter Plan mode (TAB in opencode) and paste the
report with your intent: "investigate this and find the possible cause",
"investigate and tell me where this points", or "does this still apply?".
Explore until you and the agent roughly agree on the problem.
2. **Write the plan.** Run `/make-a-plan` — it executes in Build mode.
If you need to step in and answer something yourself, press TAB to
leave Build mode. Use Plan mode only when you want a hard guarantee
that the agent modifies no file under any circumstance. If you
explored with a weaker model but want a stronger one to write the
plan, switch models first or delegate:
`/make-a-plan delegate to @engineer-glm`.
3. **Iterate on the plan.** The plan is saved to `.agents/plans/`, so you
never depend on LLM memory: read the file directly, or run `/review-plan`
for a second opinion (delegation works here too). Complex plans deserve a
review; simple ones can skip it.
4. **Execute.** Run `/implement-plan`. It first prints the full picture —
whether it will create an issue and a branch, the execution style, and a
task checklist — and waits for your go-ahead. Say "step by step" to stop
after each task (one commit per task) so you can verify as it goes;
the default runs all tasks with one final commit.
5. **Land the work.** When it finishes, either push yourself and run
`/create-pr`, or loop `/review-code``/make-a-plan`
`/implement-plan` until the findings are addressed, then push and
`/create-pr`. Nothing pushes for you — you always push from your shell.
> Note: `/implement-plan` checks the current branch. On a base branch
> (`main`, `develop`, `staging`) it creates a GitHub issue and a branch
> `issue-NNNN`; on an existing feature branch it continues there and
> creates nothing. The pre-run summary tells you which applies. Read the
> skill at `.agents/skills/implement-plan/SKILL.md` — it is
> self-explanatory.
### Big feature with multiple plans
When the work is too large for a single plan, tell `/make-a-plan`
up front: produce a high-level roadmap where each task will get its
own execution plan, and the roadmap doubles as the progress tracker.
From there the flow mirrors the issue flow above, one level down:
take each roadmap task in turn, write its own plan (`/make-a-plan`,
delegating when it helps), review it when the task is complex
(`/review-plan`), implement it (`/implement-plan`), and mark progress
on the roadmap as you land each piece.
## 7. Connecting `gh` CLI with a token
The `create-issue` and `create-pr` flows need an authenticated `gh`
so they can run on their own. Create a fine-grained token with the
minimum scopes:
1. GitHub → Settings → Developer settings → Personal access tokens →
Fine-grained tokens → Generate new token.
2. Under Organization permissions, grant access to **Projects**.
3. Under Repository permissions, grant at least **Issues** and
**Pull requests**.
Then authenticate the CLI and follow the prompts:
```bash
gh auth login
```
Verify with `gh auth status` (token lives in
`~/.config/gh/hosts.yml`). You still push from your own shell — the
agents only read and open issues and PRs.
## 8. Troubleshooting / FAQ
> TBD — filled in step by step as issues come up.
## 9. Advanced usage
### Personal agents and prompts without committing them here
Bind-mount a host dir over the container's `~/.config/opencode`:
```bash
./manage.sh run-devenv --ws 0 --opencode-config-dir ../penpot-opencode
```
It applies at container creation, so changing it needs a stop + rerun
of that instance.
## Summary of available skills
### How the skills are organized
**Flows** are the six skills you invoke by name. Each one covers one step
in the life of a change: plan it, review the plan, implement it, review
@@ -28,7 +400,7 @@ issue, a commit. Flows call them, but they also work on their own.
**Utilities** are small helpers for everyday work: search, file lookup,
JSON, REPL access, and so on.
## Flows
### Flows
| Skill | What it does | When you would say |
|---|---|---|
@@ -39,14 +411,14 @@ JSON, REPL access, and so on.
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
## References
### References
| Skill | What it holds |
|---|---|
| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. |
| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. |
## Procedures
### Procedures
| Skill | What it does |
|---|---|
@@ -54,7 +426,7 @@ JSON, REPL access, and so on.
| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. |
| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". |
## Utilities
### Utilities
| Skill | What it does |
|---|---|
@@ -71,7 +443,7 @@ JSON, REPL access, and so on.
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
## A typical round
### A typical round
1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`.
2. `/review-plan` — a second opinion; approve or request changes.
@@ -82,7 +454,7 @@ JSON, REPL access, and so on.
Every step also works on its own, and you can always say what you want
in plain words — the agents pick the right skill from what you say.
## Adding or changing a skill
### Adding or changing a skill
Create a folder here with a `SKILL.md` inside. The file needs `name` and
`description` in its frontmatter, and a clear "When to use" section so
+15
View File
@@ -0,0 +1,15 @@
Act as a senior full-stack software engineer for this project.
## Instructions
1. Read `AGENTS.md` first and follow its memory-reading rules: read `mem:critical-info`, then the core memory of every module your work touches, plus any deeper memories they reference.
2. Work autonomously: explore the codebase first, follow existing patterns and conventions, apply DRY/KISS.
3. Verify before finishing: run tests, lint and fm, fix anything you broke. Never report done with failing checks.
4. Before finishing, review the affected memories and documentation against the implementation. If the change introduces behavior, contracts, decisions, or constraints that are not documented, or makes existing documentation inaccurate, update the relevant memories and docs in the same change.
## Strong Rules
1. All new functionality ships with tests. No exceptions.
2. Do not touch unrelated modules.
3. Never `git push`, force-push, or modify remotes. Only create commits when the
command or the user explicitly instructs it.
+20 -2
View File
@@ -20,6 +20,17 @@ Before drafting any commit, read `mem:workflow/creating-commits` 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.
## Iron Rules (non-negotiable)
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
lines carrying a URL. This is the rule agents skip most often.
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
3. **Blank line between subject and body.**
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
checks rules 13. A non-zero exit is a hard blocker: fix the message and
re-commit. Never report the commit as done with a failing checker.
## Workflow
1. **Stage the files** specified by the calling context. Do not ask for
@@ -29,12 +40,18 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
that does not match the stated intent, **STOP** and tell the user before
committing.
3. Draft the message following the format in the memory doc, wrapping the body
at 72 characters per line, and run:
at 76 characters per line, and run:
```bash
git commit -m "<subject>" -m "<body>"
```
(or `git commit -F -` if the body has unusual characters).
4. The `AI-assisted-by` trailer value is provided by the calling context — use
4. **Verify the message with the checker**:
```bash
./scripts/check-commit
```
If it fails, amend the message (`git commit --amend`) until it passes. Do
not finish with a failing checker.
5. The `AI-assisted-by` trailer value is provided by the calling context — use
it verbatim.
## Constraints
@@ -45,3 +62,4 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
- Do not amend a commit you did not create in this session, unless explicitly asked.
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
- Do not add untracked files that were not created in this session.
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
+57 -232
View File
@@ -1,13 +1,11 @@
---
name: planner
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
description: Read-only planning and architecture analysis — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan, suggested save path and the next steps.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans with task breakdowns that engineers or other agents can
execute. Never writes or modifies code.
Produce a plan that another engineer or agent can execute without guessing.
## When to Use
@@ -21,24 +19,7 @@ execute. Never writes or modifies code.
- A task feels too large or vague to start.
- Work needs to be parallelized across multiple agents or sessions.
Do **not** use this skill to actually implement anything — it is read-only.
**When NOT to use:** Single-file changes with obvious scope, or when the spec
already contains well-defined tasks.
## Role
You help users understand the Penpot codebase, design solutions, and produce
implementation plans that other agents or developers can execute. The plan
tells them what to build and how to verify it, task by task.
The implementer reads the project's agent docs (`AGENTS.md`, project memories
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
before working. Reference those memories instead of re-explaining tooling,
conventions, or test design — explain in the plan only what they do not cover.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the implementer's responsibility.
Do not use for a small change with obvious scope or an existing executable plan.
## CRITICAL: Required Reading Before Planning
@@ -55,67 +36,36 @@ Before drafting any plan, work through the project's own guidance:
Skipping this step is the #1 cause of incorrect or incomplete plans.
---
## Constraints
## The Planning Process
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
### Phase 1: Architecture Analysis
## Planning Process
1. Read the spec, requirements, or feature request.
2. Analyze the codebase architecture and identify affected modules.
3. Read project conventions (starting with `critical-info` and module core
memories) before drafting.
4. Map dependencies between components (see the dependency graph in
`critical-info`).
5. Identify risks, edge cases, performance implications, and breaking changes.
1. Define the problem, desired outcome, constraints, and exclusions.
2. Trace the current behavior through the affected modules.
3. Map dependencies and choose an implementation order that builds foundations
before their consumers.
4. Identify open product or architecture decisions. Resolve implementation
details from existing conventions when they do not affect public behavior.
5. Identify edge cases, security and data risks, performance bounds, breaking
changes, and external dependencies.
6. Split the work into small, ordered tasks. Prefer complete testable slices
over unrelated layer-wide batches. Apply DRY and KISS to the proposed
implementation.
7. Define exact acceptance criteria and verification for every task.
8. Add a checkpoint after every two or three tasks in a longer plan.
9. State which tasks can run in parallel and which must remain sequential.
### Phase 2: Task Breakdown
#### Identify the Dependency Graph
Map what depends on what, following the monorepo's module dependency graph:
```
common (shared types, schemas — no deps)
├── backend (depends common)
│ ├── RPC handlers
│ └── persistence / migrations
├── frontend (depends common, render-wasm)
│ ├── UI components
│ └── state / API integration
├── exporter (depends common)
└── render-wasm (consumed by frontend)
```
Implementation order follows the dependency graph bottom-up: build shared
foundations first, then layer consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
**Bad (horizontal slicing):**
```
Task 1: Build all common types
Task 2: Build all backend handlers
Task 3: Build all frontend components
```
**Good (vertical slicing):**
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
Task 3: frontend UI component + API integration
```
Each vertical slice delivers working, testable functionality.
#### Write Tasks
## Task Format
Each task follows this structure:
@@ -152,17 +102,16 @@ implementation. Omit when the task is mechanical.
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
```
Replace "module-specific test command" with the actual commands for the module
(e.g. `clojure -M:dev:test` for backend/common,
`npx shadow-cljs compile test && npx karma start` for frontend, or the
commands noted in the module's core memory).
Use commands from `mem:testing` and affected module memories. Never substitute
generic text such as "run the tests" when the project documents an exact
command.
When possible, design each task with TDD in mind: acceptance criteria double
as a test list, and the natural first step of the task is writing those tests
before the implementation. Some tasks resist this (config, migrations, pure
wiring) — for those, keep the usual verification steps.
When possible, design each task with TDD in mind: acceptance criteria double as a test
list, and the natural first step of the task is writing those tests before the
implementation. Some tasks resist this (config, migrations, pure wiring) — for those, keep
the usual verification steps.
#### Estimate Scope
## Task Sizing
| Size | Files | Scope | Example |
|------|-------|-------|---------|
@@ -172,16 +121,11 @@ wiring) — for those, keep the usual verification steps.
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
| **XL** | 8+ | **Too large — break it down further** | — |
If a task is XL, it should be broken into smaller tasks. Agents perform best
on S and M tasks.
Split a task when it contains independent outcomes, spans unrelated systems, or cannot be
completed and verified in one focused session (if a task is XL, it should be broken into
smaller tasks; agents perform best on S and M tasks).
**When to break a task down further:**
- It would take more than one focused session
- You cannot describe the acceptance criteria in 3 or fewer bullet points
- It touches two or more independent subsystems
- You find yourself writing "and" in the task title (a sign it is two tasks)
#### Order and Checkpoints
## Task order and checkpoints
Arrange tasks so that:
@@ -197,153 +141,43 @@ Add explicit checkpoints with the relevant module commands:
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
```
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read project conventions before drafting (start with `critical-info` and
affected module core memories).
- Break down complex features or bugs into atomic, actionable steps.
- Propose solutions with clear rationale, trade-offs, and sequencing.
- Identify risks, edge cases, performance implications, and breaking changes.
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
- Checkpoints must exist after every 2-3 tasks.
## Constraints
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Output Format
The plan is always delivered in the response so the user sees it regardless
of which agent is running the skill. By default you never write the plan file;
announce the path instead. Write the file only when the command or user
explicitly instructs you to save it — and then only that file.
of which agent is running the skill. File writes follow `Constraints`
by default announce the path instead of writing.
Announce the suggested save path:
```
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
```
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
provides a target file path, announce that path instead of the default.
Announce the save path `.agents/plans/YYYY-MM-DD-<slug>.md` (today's date,
lowercase hyphen-separated slug, e.g. `2026-09-10-add-batch-get-profiles`;
an explicit user path wins).
End the response by suggesting the next steps: `/review-plan` to get a second
opinion on the plan and `/implement-plan` to execute it.
### Plan Document Template
### Plan Structure
Use this document shape:
```markdown
# Plan: [Feature/Project Name]
# Plan: Title
## Context
[One paragraph: what is the problem or feature request? Why is it needed?]
## Affected Modules
[Which modules of the monorepo are involved? Reference module paths and any
`mem:` memories that were consulted.]
## Architecture Decisions
- [Key decision 1 and rationale]
- [Key decision 2 and rationale]
## Risks & Considerations
[Edge cases, performance implications, breaking changes, migration concerns,
security implications.]
## Risks and Considerations
## Approach
[A short strategy summary: 3-5 sentences describing the overall approach and
the shape of the dependency graph (what depends on what, what gets built
first). High-level only — the task-by-task detail lives in the Task List.]
## Task List
Each task uses the full task structure defined in
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
verification, dependencies, files, estimated scope, and optional code sketch.
Never reduce a task to a one-line checkbox; the plan must be self-contained
and executable without other context.
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
into phases, milestones, or sprints; ordering and dependencies are already
captured per task. Insert a checkpoint after every 2-3 tasks.
## Task 1: [Short descriptive title]
**Description:** [What this task accomplishes.]
**Rationale:** [Why this approach over the alternatives.]
**Acceptance criteria:**
- [ ] [Specific, testable condition]
**Verification:**
- [ ] Relevant tests pass (module-specific command).
**Dependencies:** None
**Files likely touched:**
- `path/to/file`
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
**Code sketch (optional):** [Short contract-level example, only if the shape
is non-obvious.]
## Task 2: [Short descriptive title]
[Same structure as Task 1.]
## Task 3: [Short descriptive title]
[Same structure as Task 1.]
### Checkpoint: After Tasks 1-3
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
## Task 4: [Short descriptive title]
[Same structure as Task 1.]
## Task 5: [Short descriptive title]
[Same structure as Task 1.]
## Verification & Testing
[How to verify each task and the whole plan: the project's real test, lint,
build, and run commands (extracted during Required Reading), coverage
expectations, and manual checks. Consult each module's core memory for the
exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
modules, tests for already-implemented features, documentation
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
## Verification and Testing
## Parallelization
## Open Questions
- [Question needing human input]
```
Omit empty sections only when they do not apply. Every implementation task
still requires acceptance criteria, verification, dependencies, likely files,
and scope.
When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** and **Task List** sections and
lead with **Findings** instead, keeping the rest of the structure.
@@ -357,15 +191,6 @@ lead with **Findings** instead, keeping the rest of the structure.
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
## Red Flags
- Delivering prose without a task breakdown
- Tasks that say "implement the feature" without acceptance criteria
- No verification steps in the plan
- All tasks are XL-sized
- No checkpoints between tasks
- Dependency order isn't considered
## Verification Checklist
Before delivering the plan, confirm:
+44
View File
@@ -0,0 +1,44 @@
name: _ADHOC
run-name: >-
_ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Branch/ref to build in penpot/penpot'
type: string
required: true
nitrate_ref:
description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
type: string
required: false
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: ${{ inputs.gh_ref }}
force: ${{ inputs.force }}
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: ${{ inputs.gh_ref }}
force: ${{ inputs.force }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
force: ${{ inputs.force }}
+43 -5
View File
@@ -9,6 +9,11 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this version already exists in S3'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -16,20 +21,31 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this version already exists in S3'
type: boolean
required: false
default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
# by the same caller into a single shared group, and left a manual dispatch of
# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
group: build-bundle-${{ inputs.gh_ref }}
cancel-in-progress: true
jobs:
# ── 1. Decide whether there is anything to build ───────────────────────
check:
name: Check current bundle
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 10
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -44,10 +60,12 @@ jobs:
run: |
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
# The uploaded zip carries its version as S3 metadata. If the
# existing object was already built from this same commit, the
# whole build job is skipped.
# whole build job is skipped. `force` bypasses this check entirely.
- name: Check if this bundle is already built
id: check
env:
@@ -55,6 +73,16 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
{
echo "### 🔁 Bundle build forced"
echo ""
echo "\`force: true\` — skipping the S3 version check."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
EXISTING_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
@@ -75,7 +103,7 @@ jobs:
# ── 2. Build and upload, only when needed ──────────────────────────────
build:
name: Build and Upload Penpot Bundle
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 90
needs: check
if: needs.check.outputs.exists == 'false'
@@ -113,10 +141,20 @@ jobs:
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
- name: Write step summary
run: |
{
echo "### ✅ Bundle built"
echo ""
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
} >> "$GITHUB_STEP_SUMMARY"
# ── 3. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [check, build]
if: failure()
+17 -1
View File
@@ -1,16 +1,30 @@
name: _DEVELOP
run-name: >-
_DEVELOP (develop @ ${{ github.sha }})
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
schedule:
- cron: '16 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -18,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "develop"
force: ${{ inputs.force || false }}
@@ -13,6 +13,11 @@ on:
type: string
required: false
default: 'develop'
force:
description: 'Rebuild and overwrite even if already built'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -24,6 +29,11 @@ on:
type: string
required: false
default: 'develop'
force:
description: 'Rebuild and overwrite even if already built'
type: boolean
required: false
default: false
secrets:
ORG_WORKFLOW_TOKEN:
description: 'Token with Actions write access on penpot-nitrate'
@@ -47,6 +57,7 @@ jobs:
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
-f gh_ref="$GH_REF" \
-f force="${{ inputs.force }}" \
-f caller_run_id="$DISTINCT_ID" \
-f caller_run_url="$CALLER_URL"
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
steps:
- name: Set common environment variables
+46 -7
View File
@@ -8,6 +8,11 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
@@ -15,9 +20,18 @@ on:
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
# by the same caller into a single shared group, and left a manual dispatch of
# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
group: build-docker-${{ inputs.gh_ref }}
cancel-in-progress: true
env:
@@ -32,12 +46,13 @@ jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
@@ -56,6 +71,7 @@ jobs:
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
BUNDLE_VERSION=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
@@ -67,7 +83,8 @@ jobs:
# The image set is a single block, so a single set-level check is
# enough: `promote` drops a marker object in S3 only after every
# image was built AND every branch tag was moved. Marker present
# means there is nothing at all to do for this commit.
# means there is nothing at all to do for this commit. `force`
# bypasses this check entirely.
- name: Check if this image set is already built
id: check
env:
@@ -75,6 +92,21 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
if [ "${{ inputs.force }}" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
{
echo "### 🔁 Image set build forced"
echo ""
echo "\`force: true\` — skipping the S3 marker check."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
@@ -103,7 +135,7 @@ jobs:
# ── 2. One build per image, in parallel, only when needed ──────────────
build:
name: Build ${{ matrix.image }}
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.exists == 'false'
@@ -134,7 +166,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the 429 Too Many Requests error when downloading
# To avoid the "429 Too Many Requests" error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
@@ -216,7 +248,7 @@ jobs:
# the S3 marker guarantees the branch tags were already moved.
promote:
name: Promote image set
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 10
needs: [prepare, build]
@@ -254,16 +286,23 @@ jobs:
run: |
echo "${{ github.run_id }}" | aws s3 cp - \
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
- name: Write step summary
run: |
{
echo "### ✅ Image set promoted"
echo ""
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-runner-02
runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [prepare, build, promote]
if: failure()
+17 -1
View File
@@ -1,16 +1,30 @@
name: _STAGING
run-name: >-
_STAGING (staging)
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted'
type: boolean
required: false
default: false
schedule:
- cron: '36 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -18,9 +32,11 @@ jobs:
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
build-admin-console-docker:
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "staging"
force: ${{ inputs.force || false }}
+31 -3
View File
@@ -1,17 +1,33 @@
name: _TAG
run-name: >-
_TAG (${{ github.ref_name }} @ ${{ github.sha }})
on:
workflow_dispatch:
inputs:
force:
description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
type: boolean
required: false
default: false
push:
tags:
- '*'
# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1
# must not abort the release already in flight.
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker:
needs: build-bundle
@@ -19,11 +35,21 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
build-docker-admin-console:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
force: ${{ inputs.force || false }}
notify:
name: Notifications
runs-on: ubuntu-24.04
needs: build-docker
runs-on: ubuntu-latest
needs:
- build-docker
- build-docker-admin-console
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
@@ -37,7 +63,9 @@ jobs:
publish-final-tag:
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
needs: build-docker
needs:
- build-docker
- build-docker-admin-console
uses: ./.github/workflows/release.yml
secrets: inherit
with:
+24
View File
@@ -0,0 +1,24 @@
name: _TMP TOKENS
on:
workflow_dispatch:
schedule:
- cron: '46 5-20 * * 1-5'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "hiru-tokens-in-libs"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "hiru-tokens-in-libs"
+1 -1
View File
@@ -34,7 +34,7 @@ permissions:
jobs:
deploy:
runs-on: penpot-runner-01
runs-on: penpot-standar-runner
steps:
- name: Checkout
uses: actions/checkout@v6
+1 -1
View File
@@ -19,7 +19,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
version: ${{ steps.vars.outputs.gh_ref }}
release_notes: ${{ steps.extract_release_notes.outputs.release_notes }}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
composable-test-suite:
if: ${{ !github.event.pull_request.draft }}
name: "Run composable test suite (mocked backend)"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+158 -32
View File
@@ -5,11 +5,37 @@ defaults:
shell: bash
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
shards:
description: 'Shard layout (JSON array)'
type: choice
required: true
default: '[1, 2, 3, 4]'
options:
- '[1, 2, 3, 4]'
- '[1, 2, 3, 4, 5, 6]'
- '[1, 2]'
- '[1]'
workers:
description: 'Playwright workers per shard'
type: string
required: true
default: '2'
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
types:
- opened
@@ -25,25 +51,41 @@ on:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
- '.github/workflows/tests-integration.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
timeout-minutes: 30
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
outputs:
bundle_key: ${{ steps.vars.outputs.bundle_key }}
steps:
# An empty `ref` makes checkout fall back to its default (the PR merge
# ref on pull_request, the pushed ref on push).
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
# The cache key must come from the SHA actually checked out: on a manual
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
- name: Extract cache key
id: vars
run: |
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Build Bundle
working-directory: ./frontend
@@ -53,58 +95,142 @@ jobs:
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
key: ${{ steps.vars.outputs.bundle_key }}
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
runs-on: penpot-runner-02
name: "Integration Tests (${{ matrix.shard }})"
runs-on: penpot-extended-runner
timeout-minutes: 40
needs: build-integration
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
- /var/cache/github-runner/ms-playwright:/ms-playwright
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.gh_ref }}
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-integration.outputs.bundle_key }}
path: frontend/resources/public
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
# No-op once the shared volume is warm; keeps the first run working.
- name: Install Playwright Chromium
working-directory: ./frontend
run: pnpm exec playwright install chromium
# `strategy.job-total` is the matrix size, so the shard denominator
# follows the `shards` input without being hardcoded.
- name: Run Tests
working-directory: ./frontend
env:
WORKERS: ${{ inputs.workers }}
run: |
WORKERS=${WORKERS:-2}
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
pnpm exec playwright test --project default \
--workers="$WORKERS" \
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
--reporter=blob
- name: Upload blob report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-blob-report-${{ matrix.shard }}
path: frontend/blob-report/
overwrite: true
retention-days: 3
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-${{ matrix.shard }}
path: frontend/test-results/
overwrite: true
if-no-files-found: ignore
retention-days: 3
merge-reports:
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
name: "Merge Integration Reports"
runs-on: penpot-extended-runner
timeout-minutes: 15
needs: test-integration
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
ref: ${{ inputs.gh_ref }}
- name: Run Tests
- name: Install deps
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install --frozen-lockfile;
- name: Download blob reports
uses: actions/download-artifact@v7
with:
path: frontend/all-blob-reports
pattern: integration-blob-report-*
merge-multiple: true
- name: Merge into HTML report
working-directory: ./frontend
env:
PLAYWRIGHT_REPORTER: list,json
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
run: |
./scripts/test-e2e
pnpm exec playwright merge-reports \
--reporter=html,json,list ./all-blob-reports
- name: Flaky summary
- name: Test summary
if: always()
working-directory: ./frontend
run: |
if [ ! -f report.json ]; then
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
jq -r '
[ .. | objects
| select(has("tests") and has("file"))
| select(any(.tests[]; .status == "flaky"))
| "- `\(.file):\(.line)` — \(.title)"
] as $f
| "## Flaky tests: \($f | length)\n"
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
' report.json >> "$GITHUB_STEP_SUMMARY"
# Kept for 30 days so flakiness rates can be aggregated across runs
# without scraping job logs.
- name: Upload JSON report
uses: actions/upload-artifact@v7
if: always()
@@ -112,13 +238,13 @@ jobs:
name: integration-json-report
path: frontend/report.json
overwrite: true
if-no-files-found: ignore
retention-days: 30
- name: Upload test result
- name: Upload HTML report
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
name: integration-html-report
path: frontend/playwright-report/
overwrite: true
retention-days: 3
retention-days: 7
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
test-mcp:
if: ${{ !github.event.pull_request.draft }}
name: "Test MCP"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container: penpotapp/devenv:latest
steps:
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
api-test-suite-mocked:
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
name: "Run Plugin API Test Suite (mocked)"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
@@ -95,7 +95,7 @@ jobs:
# api-test-suite-live:
# if: ${{ github.event_name == 'workflow_dispatch' }}
# name: Run Plugin API Test Suite (live)
# runs-on: penpot-runner-02
# runs-on: penpot-extended-runner
# container:
# image: penpotapp/devenv:latest
#
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
runs-on: penpot-extended-runner
container:
image: penpotapp/devenv:latest
volumes:
+1 -1
View File
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- 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 `git commit``mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `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)
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
+22 -2
View File
@@ -14,12 +14,32 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
Wrap lines at 72 characters — git log and tooling
render long lines poorly. Keep each line concise.
Wrap lines at 76 characters — git log adds a
four-space indent, so 76 + 4 fits an 80-column
terminal. Keep each line concise.
AI-assisted-by: model-name
```
## HARD RULES (inexcusable)
These rules are not advisory. Do not commit until every one holds. A commit
that breaks them is wrong, even if the code is right.
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
not eyeball it. This is the rule most often skipped. Rationale: `git log`
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
- **MUST be a blank line** between subject and body.
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
It mechanically validates the rules above; a failing run is a blocker.
- It checks `HEAD` by default: `./scripts/check-commit`
- For another commit: `./scripts/check-commit -c <ref>`
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
break it at a space.
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
**AI-assisted-by trailer rules:**
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
- Do NOT add prefixes like `opencode-go/` — use the bare model name
+6 -4
View File
@@ -52,10 +52,12 @@ file (never pipe tool output through filters).
then re-run `corepack use pnpm@<tag>` in that directory.
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
a placeholder scaffold into its `pnpm-workspace.yaml`:
`allowBuilds: esbuild: set this to true or false` plus
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
then re-run.
`allowBuilds: esbuild: set this to true or false`. Current pnpm writes
only the `allowBuilds` placeholder; any legacy key still present
(`ignoredBuiltDependencies`, `onlyBuiltDependencies`,
`neverBuiltDependencies`) is ignored since pnpm 11. Repo convention is
`allowBuilds: esbuild: true`. Replace the placeholder and drop the
legacy entry, then re-run.
- `plugins/apps/composable-test-suite` once had its own
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
+6
View File
@@ -14,6 +14,12 @@
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
This prevents hiding test failures. See `mem:testing` for details.
- **`.claude/skills` is a symlink to `.agents/skills`.**
Edit skills only in their canonical location (`.agents/skills`); never edit
through `.claude/skills`.
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
consider it done. This is mechanically checked — do not eyeball it.
- **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)
+21
View File
@@ -2,6 +2,12 @@
## 2.18.0 (Unreleased)
### :rocket: Epics and highlights
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
### :bug: Bugs fixed
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
@@ -103,6 +109,21 @@
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
- Fix missing text in legacy SVG board thumbnails [#10182](https://github.com/penpot/penpot/issues/10182) (PR: [#11552](https://github.com/penpot/penpot/pull/11552))
- Fix workspace crash when applying transform modifiers in the WASM renderer [#10894](https://github.com/penpot/penpot/issues/10894) (PR: [#10896](https://github.com/penpot/penpot/pull/10896))
- Limit ZIP entry count and object size on V3 binfile import [#11021](https://github.com/penpot/penpot/issues/11021) (PR: [#11022](https://github.com/penpot/penpot/pull/11022))
- Block plugin UI iframe URLs targeting the Penpot domain [#11271](https://github.com/penpot/penpot/issues/11271) (PR: [#11273](https://github.com/penpot/penpot/pull/11273))
- Restrict the MCP REPL code execution endpoint to development environments [#11283](https://github.com/penpot/penpot/issues/11283) (PR: [#11282](https://github.com/penpot/penpot/pull/11282))
- Filter share-link tokens from the get-view-only-bundle response [#11285](https://github.com/penpot/penpot/issues/11285) (PR: [#11286](https://github.com/penpot/penpot/pull/11286))
- Disable MCP developer tools in multi-user mode [#11291](https://github.com/penpot/penpot/issues/11291) (PR: [#11310](https://github.com/penpot/penpot/pull/11310))
- Fix Hide comments setting being ignored after opening the Comments section [#11308](https://github.com/penpot/penpot/issues/11308) (PR: [#11492](https://github.com/penpot/penpot/pull/11492))
- Block NAT64/6to4/Teredo IPv6 transition addresses in the SSRF guard [#11319](https://github.com/penpot/penpot/issues/11319) (PR: [#11320](https://github.com/penpot/penpot/pull/11320))
- Prevent team admins from removing the team owner [#11367](https://github.com/penpot/penpot/issues/11367) (PR: [#11368](https://github.com/penpot/penpot/pull/11368))
- Enforce share-link comment permissions and page scope [#11370](https://github.com/penpot/penpot/issues/11370) (PR: [#11371](https://github.com/penpot/penpot/pull/11371))
- Clean up orphaned teams, projects and files on profile deletion [#11394](https://github.com/penpot/penpot/issues/11394) (PR: [#11395](https://github.com/penpot/penpot/pull/11395))
- Fix crash when pressing Ctrl+D with no shape selected [#11448](https://github.com/penpot/penpot/issues/11448) (PR: [#11491](https://github.com/penpot/penpot/pull/11491))
- Fix text layout not updating when auto-width is set by double-clicking the bounding box [#11480](https://github.com/penpot/penpot/issues/11480) (PR: [#11541](https://github.com/penpot/penpot/pull/11541))
- Fix boolean shapes rendering deformed in the WASM renderer and exports [#11482](https://github.com/penpot/penpot/issues/11482) (PR: [#11551](https://github.com/penpot/penpot/pull/11551))
### :sparkles: New features & Enhancements
+3
View File
@@ -188,8 +188,11 @@ Commit messages must follow this format:
- Add clear and concise description on the body
- Do not end the subject with a period
- Keep the subject to **70 characters** or fewer
- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted)
- Separate the subject from the body with a **blank line**
You can check a commit against these rules with `./scripts/check-commit`.
### Examples
```
+7
View File
@@ -56,6 +56,7 @@ If your organization is scaling and needs extra support, were here to help. [
- [Why Penpot](#why-penpot)
- [Getting Started](#getting-started)
- [Penpot Enterprise](#penpot-enterprise)
- [Community](#community)
- [Contributing](#contributing)
- [Resources](#resources)
@@ -93,6 +94,12 @@ Penpot is the only design & prototype platform that is deployment agnostic. You
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
<img width="100%" height="1010" alt="2" src="https://github.com/user-attachments/assets/243e796e-a140-481a-b68f-b24be6a70e37" />
## Penpot Enterprise ##
Penpot Enterprise is our paid plan for organizations that need to scale their design work across multiple teams with advanced governance, security, and administration. Manage teams and access from a centralized **Admin Console**, configure advanced permissions, and connect your **identity provider through SSO**. Available for cloud and self-hosted environments, it combines enterprise controls with Penpots open-source foundation and open standards.
## Community ##
We love the Open Source software community. Contributing is our passion and if its yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
+1 -1
View File
@@ -27,7 +27,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
disable-login-with-ldap \
enable-login-with-ldap \
disable-login-with-oidc \
disable-login-with-google \
disable-login-with-github \
+24 -6
View File
@@ -10,7 +10,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[clj-ldap.client :as ldap]
[clojure.string]
[cuerdas.core :as str]
[integrant.core :as ig]))
(defn- prepare-params
@@ -36,11 +36,22 @@
:cause cause))))
(defn- replace-several [s & {:as replacements}]
(reduce-kv clojure.string/replace s replacements))
(reduce-kv str/replace s replacements))
(defn- escape-ldap-filter-value
"Escapes special characters in a string for use in LDAP filter values,
per RFC 4515 section 3."
[s]
(-> s
(str/replace "\\" "\\5c")
(str/replace "*" "\\2a")
(str/replace "(" "\\28")
(str/replace ")" "\\29")
(str/replace "\u0000" "\\00")))
(defn- search-user
[{:keys [::conn base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" email)
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]
@@ -49,12 +60,19 @@
:attributes attrs}]
(first (ldap/search conn base-dn params))))
(defn- get-attr
"Retrieves an attribute from an LDAP entry. Handles multi-valued
attributes by returning the first value."
[entry attr-key]
(let [v (get entry attr-key)]
(if (coll? v) (first v) v)))
(defn- retrieve-user
[{:keys [::conn] :as cfg} {:keys [email password]}]
(when-let [{:keys [dn] :as user} (search-user cfg email)]
(when (ldap/bind? conn dn password)
{:fullname (get user (-> cfg :attrs-fullname keyword))
:email email
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
:email (get-attr user (-> cfg :attrs-email keyword))
:backend "ldap"})))
(def ^:private schema:info-data
@@ -79,7 +97,7 @@
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
(ex/raise :type :restriction
:code :wrong-ldap-response
:explain explain)))
::sm/explain explain)))
user)))
(defn- try-connectivity
+2
View File
@@ -91,6 +91,7 @@
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20
:upload-max-chunk-size (* 1024 1024 30) ; 30MiB
;; SSRF protection
:ssrf-allowed-hosts #{}
@@ -202,6 +203,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:upload-max-chunk-size {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
+8 -3
View File
@@ -15,6 +15,7 @@
(:require
[app.common.schema :as sm]
[app.util.ssrf :as ssrf]
[app.worker :as-alias wrk]
[cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
@@ -23,6 +24,8 @@
java.net.URI))
(def default-max-redirects 5)
(def default-connect-timeout 30000)
(def default-request-timeout 30000)
(defn client?
[o]
@@ -33,15 +36,17 @@
:pred client?})
(defmethod ig/init-key ::client
[_ _]
(http/build-client {:connect-timeout 30000
[_ {:keys [::wrk/executor]}]
(http/build-client {:connect-timeout default-connect-timeout
:executor executor
:follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client")
(http/send req {:client client :as response-type})))
(http/send (merge {:timeout default-request-timeout} req)
{:client client :as response-type})))
(defn- resolve-client
[params]
+7 -1
View File
@@ -60,7 +60,13 @@
(defmethod handle-error :restriction
[err request _]
(let [{:keys [code] :as data} (ex-data err)]
(let [data (ex-data err)
code (get data :code)
explain (ex/explain data)
data (-> data
(dissoc ::sm/explain)
(cond-> explain (assoc :explain explain)))]
(if (= code :method-not-allowed)
{::yres/status 405
::yres/body data}
+1 -1
View File
@@ -200,7 +200,7 @@
{::db/pool (ig/ref ::db/pool)}
::http.client/client
{}
{::wrk/executor (ig/ref ::wrk/executor)}
::session/manager
{::db/pool (ig/ref ::db/pool)}
+3 -3
View File
@@ -75,10 +75,10 @@
{:method method
:uri uri
:body body
:headers headers}
:headers headers
:timeout timeout}
{:response-type :input-stream
:skip-ssrf-check? true
:timeout timeout})
:skip-ssrf-check? true})
status (:status resp)]
(when (not (<= 200 status 299))
(let [body (:body resp)]
+14 -6
View File
@@ -390,18 +390,26 @@
(def ^:private sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT owner_id AS id
FROM comment
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
SELECT DISTINCT c.owner_id AS id
FROM comment c
JOIN comment_thread ct
ON ct.id = c.thread_id
WHERE ct.file_id = ?::uuid
),
profile_ids AS (
SELECT id FROM available_profiles
UNION
SELECT ?::uuid
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname AS fullname,
p.fullname,
p.photo_id,
p.is_active
FROM profile AS p
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
FROM profile p
JOIN profile_ids AS x
ON x.id = p.id;")
(defn get-file-comments-users
[conn file-id profile-id]
+63 -26
View File
@@ -339,6 +339,9 @@
;; --- Chunked Upload: Upload a single chunk
(declare ^:private get-upload-chunk)
(declare ^:private check-upload-chunk-slot)
(def ^:private schema:upload-chunk
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
@@ -354,9 +357,31 @@
{::doc/added "2.17"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id index content] :as _params}]
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
[cfg {:keys [::rpc/profile-id session-id index content]}]
(let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)]
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
{:session-id session-id
:index index}))
(defn- check-upload-chunk-slot
[{:keys [::db/conn]} session-id profile-id index content]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id} {::db/for-update true})]
(when (or (neg? index) (>= index (:total-chunks session)))
(ex/raise :type :validation
:code :invalid-chunk-index
@@ -365,26 +390,23 @@
:total-chunks (:total-chunks session)
:index index))
(when (> (:size content) (cf/get :upload-max-chunk-size))
(ex/raise :type :validation
:code :chunk-too-large
:hint "chunk size exceeds the maximum allowed"
:session-id session-id
:index index
:size (:size content)
:max-size (cf/get :upload-max-chunk-size)))
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content)))
(when (get-upload-chunk conn session-id index)
(ex/raise :type :validation
:code :duplicate-chunk-index
:hint "chunk index already uploaded for this session"
:session-id session-id
:index index))
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touch true
:content-type (:mtype content)
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
{:session-id session-id
:index index})
session))
;; --- Chunked Upload: shared helpers
@@ -399,6 +421,18 @@
[conn session-id]
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
(def ^:private sql:get-upload-chunk
"SELECT id
FROM storage_object
WHERE (metadata->>'~:upload-id') = ?::text
AND (metadata->>'~:chunk-index')::integer = ?
AND deleted_at IS NULL
LIMIT 1")
(defn- get-upload-chunk
[conn session-id index]
(db/exec-one! conn [sql:get-upload-chunk (str session-id) index]))
(defn- concat-chunks
"Reads all chunk storage objects in order and writes them to a single
temporary file on the local filesystem. Returns a path to that file."
@@ -417,18 +451,21 @@
conforming to `media.v/schema:upload` with `:filename`, `:path` and
`:size`.
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Raises a :validation/:missing-chunks error when the stored chunk
indices do not form exactly the `0..total-chunks` range recorded in
the session row (wrong count, gaps or duplicates).
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
chunks (get-upload-chunks conn session-id)
indices (sort (map :chunk-index chunks))]
(when (not= (count chunks) (:total-chunks session))
(when (or (not= (count chunks) (:total-chunks session))
(not= indices (range (:total-chunks session))))
(ex/raise :type :validation
:code :missing-chunks
:hint "number of stored chunks does not match expected total"
:hint "stored chunks do not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
+8 -2
View File
@@ -41,6 +41,7 @@
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[cuerdas.core :as str]))
@@ -960,13 +961,18 @@ RETURNING id, deleted_at;")
(sv/defmethod ::check-organization-sso
"Validate an organization SSO configuration by generating a login redirect URL.
Nitrate calls this while configuring SSO to verify client credentials and OIDC
discovery before saving the settings."
discovery before saving the settings. The issuer URL is nitrate-supplied
(customer-configured), so it is checked against the SSRF blocklist before
any outbound request is attempted."
{::doc/added "2.18"
::sm/params cto/schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
[cfg params]
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
(let [issuer (oidc/organization-sso-discovery-uri params)]
{:valid (boolean (and issuer
(ssrf/safe-url? issuer)
(oidc/is-organization-sso-config-valid? cfg params)))}))
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
@@ -0,0 +1,76 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.auth-ldap-test
(:require
[app.auth.ldap :as ldap-auth]
[clj-ldap.client :as ldap]
[clojure.test :as t]))
;; --- search-user: filter must be escaped (RED: currently not escaped)
(t/deftest search-user-escapes-email-in-filter
(t/testing "wildcard * is escaped before building LDAP filter"
(let [captured-query (atom nil)
fake-search (fn [_conn _base-dn params]
(reset! captured-query (:filter params))
[])]
(with-redefs [ldap/search fake-search]
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
"fry*@planetexpress.com"))
;; After fix: * should be escaped as \2a
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
"filter must have * escaped per RFC 4515"))))
;; --- retrieve-user: email must come from directory, not client (RED)
(t/deftest retrieve-user-uses-directory-email
(t/testing "returned email is from LDAP directory, not client input"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
:mail "fry@planetexpress.com"
:cn "Philip J. Fry"
:uid "fry"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
;; After fix: email should be from directory (fry@planetexpress.com)
;; BUG: email is client input (fry*@planetexpress.com)
(t/is (= "fry@planetexpress.com" (:email result))
"email must come from LDAP directory attribute, not client input"))))))
;; --- authenticate: full flow with directory email (RED)
(t/deftest authenticate-returns-directory-email
(t/testing "authenticate returns directory email for profile"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
:mail "amy@planetexpress.com"
:cn "Amy Wong"
:uid "amy"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"
:bind-dn "cn=admin,dc=planetexpress,dc=com"
:bind-password "GoodNewsEveryone"
:host "localhost" :port 10389
:ssl false :tls false
:base-dn "ou=people,dc=planetexpress,dc=com"}
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
;; After fix: email should be amy@planetexpress.com (directory)
;; BUG: email is *@planetexpress.com (client)
(t/is (= "amy@planetexpress.com" (:email result))
"authenticate must return directory email, not client-supplied wildcard"))))))
@@ -0,0 +1,30 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.http-client-test
(:require
[app.http.client :as http]
[clojure.test :as t]
[java-http-clj.core :as jhttp]
[mockery.core :refer [with-mocks]]))
(t/deftest send-injects-default-timeout-when-absent
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get :uri "https://example.com/"})
(let [[req _opts] (:call-args @mock)]
(t/is (= http/default-request-timeout (:timeout req)))))))
(t/deftest send-preserves-caller-supplied-timeout
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get
:uri "https://example.com/"
:timeout 5000})
(let [[req _opts] (:call-args @mock)]
(t/is (= 5000 (:timeout req)))))))
@@ -8,6 +8,7 @@
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.http.client :as http]
[app.media.remote :as media.remote]
[app.setup :as-alias setup]
[app.util.json :as json]
@@ -500,6 +501,22 @@
:headers {}})]
(t/is (= 200 (:status resp))))))))
(t/deftest service-request-puts-configured-timeout-in-request
(t/testing "service-request puts media-processing-service-timeout on the http request"
(let [captured (atom nil)]
(with-redefs [cf/get (th/config-get-mock config-mock)
http/req (fn [_client request _opts]
(reset! captured request)
{:status 200
:body (json-stream {:width 100 :height 100})})]
(media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}})
(t/is (= 5000 (:timeout @captured)))))))
;; ---------------------------------------------------------------------------
;; Shared key
;; ---------------------------------------------------------------------------
@@ -17,6 +17,7 @@
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.set :as set]
@@ -1806,13 +1807,14 @@
(t/deftest check-organization-sso-returns-valid-true
(let [organization-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
@@ -1827,19 +1829,36 @@
(t/deftest check-organization-sso-passes-issuer-to-validation
(let [organization-id (uuid/random)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
(let [called? (atom false)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg _sso] (reset! called? true) true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id (uuid/random)
:client-id "test-client"
:client-secret "test-secret"
:issuer "http://127.0.0.1/idp"}))]
(t/is (th/success? out))
(t/is (false? (-> out :result :valid)))
(t/is (false? @called?)
"OIDC validation should not run when the issuer is SSRF-blocked"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUSH AUDIT EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -681,6 +681,131 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-assemble-rejects-duplicate-indices
;; assemble-chunks must validate the index SET, not just the count: a
;; session declaring 2 chunks but storing [0,0] must fail instead of
;; assembling a corrupt file. Chunks are written at the storage level
;; because upload-chunk itself now rejects the second index.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 2)
storage (:app.storage/storage th/*system*)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
put-chunk! (fn [idx]
(let [mfile (make-chunk-mfile (first chunks) "image/jpeg")]
(sto/put-object! storage
{::sto/content (sto/content (:path mfile))
::sto/deduplicate? false
::sto/touch true
:content-type "image/jpeg"
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index idx})))]
(put-chunk! 0)
(put-chunk! 0)
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "dupe-indices"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-then-assemble
;; A rejected duplicate must leave the first chunk intact: upload 0,
;; re-upload 0 (rejected), then assemble succeeds with the original size.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
size (alength (first chunks))]
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "after-dupe"
:mtype mtype})]
(t/is (nil? (:error out)))
(let [storage (:app.storage/storage th/*system*)
mobj (sto/get-object storage (:media-id (:result out)))]
(t/is (= size (:size mobj)))))))
(t/deftest chunked-upload-rejected-duplicate-keeps-session-usable
;; Rejecting a duplicate must not poison the session: the remaining
;; distinct indices still accumulate and assemble normally.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000)
mtype "image/jpeg"]
(t/is (= 3 (count chunks)))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (some? (:error out)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 1
:content (make-chunk-mfile (nth chunks 1) mtype)})]
(t/is (nil? (:error out))))
;; The live store holds exactly the two distinct indices: the
;; rejected duplicate stored nothing.
(let [rows (th/db-exec! ["SELECT (metadata->>'~:chunk-index')::integer AS idx FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL ORDER BY idx"
(str session-id)])]
(t/is (= [0 1] (mapv :idx rows))))))
(t/deftest chunked-upload-session-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@@ -767,6 +892,77 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-index-rejected
;; Uploading the same chunk index twice into one session must fail:
;; the second call raises :validation / :duplicate-chunk-index and
;; stores nothing, so one session+index keeps at most one object.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
mfile1 (make-chunk-mfile (first chunks) mtype)
mfile2 (make-chunk-mfile (first chunks) mtype)]
;; First upload succeeds
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile1})]
(t/is (nil? (:error out))))
;; Second upload of the same index must be rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile2})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :duplicate-chunk-index (-> out :error ex-data :code))))
;; Exactly one live object stored for that session/index
(let [rows (th/db-exec! ["SELECT id FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND (metadata->>'~:chunk-index') = '0' AND deleted_at IS NULL"
(str session-id)])]
(t/is (= 1 (count rows))))))
(t/deftest chunked-upload-chunk-too-large
;; Chunks larger than the configured cap must be rejected with
;; :validation / :chunk-too-large before anything is stored, while a
;; chunk exactly at the cap still uploads fine.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:upload-max-chunk-size 1024})}]
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"]
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
;; Nothing stored for the rejected chunk
(let [rows (th/db-exec! ["SELECT id FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL"
(str session-id)])]
(t/is (= 0 (count rows))))
;; A chunk exactly at the cap still uploads fine
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
(t/is (nil? (:error out)))))))
(t/deftest chunked-upload-sessions-per-profile-quota
;; With the session limit set to 2, creating a third session for the
;; same profile must fail with :restriction / :max-quote-reached.
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { rpcPost, extractCookie } from "./helpers/client.mjs";
async function loginWithLdap(email, password) {
const res = await rpcPost("login-with-ldap", { email, password });
if (res.status !== 200 || res.body.type) {
throw new Error(
`LDAP login failed: ${JSON.stringify(res.body)}`
);
}
const cookie = extractCookie(res.setCookie);
return { profile: res.body, cookie };
}
describe("LDAP injection — T5-N1-03", () => {
it("normal LDAP login works with valid credentials", async () => {
const { profile, cookie } = await loginWithLdap(
"fry@planetexpress.com",
"fry"
);
assert.equal(profile.email, "fry@planetexpress.com");
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
});
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
// ATTACK SCENARIO (from Criptored audit):
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
// 4. Bind succeeds: amy's DN + amy's password = valid
//
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
//
// Either outcome is correct — the vulnerability is fixed.
try {
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
// Outcome B: login succeeded, verify email is from directory
assert.equal(
profile.email,
"amy@planetexpress.com",
"email must come from LDAP directory, not client input"
);
} catch (e) {
// Outcome A: injection blocked — * is escaped, no LDAP match
assert.ok(
e.message.includes("wrong-credentials"),
"wildcard should be rejected or return directory email"
);
}
});
it("identity swap: alternate email must return primary directory email", async () => {
// Professor has two emails in LDAP: professor@ and hubert@.
// Login with hubert@ — the profile email should be the one
// the LDAP directory returns as attrs-email, not what the client typed.
//
// EXPECTED BEHAVIOR AFTER FIX:
// Profile email should be "professor@planetexpress.com" (primary directory email),
// NOT "hubert@planetexpress.com" (client literal).
//
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
const { profile, cookie } = await loginWithLdap(
"hubert@planetexpress.com",
"professor"
);
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
// This assertion FAILS with current code (RED) — proves the vulnerability
assert.equal(
profile.email,
"professor@planetexpress.com",
"email must come from LDAP directory, not client input"
);
});
it("wrong password fails", async () => {
try {
await loginWithLdap("fry@planetexpress.com", "wrong-password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail with wrong credentials"
);
}
});
it("non-existent user fails", async () => {
try {
await loginWithLdap("nobody@planetexpress.com", "password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail for non-existent user"
);
}
});
});
@@ -712,14 +712,21 @@
(conj (or interactions []) interaction))
(defn remove-interaction
"Interactions without the one at `index`; unchanged when `index` addresses none."
[interactions index]
(let [interactions (or interactions [])]
(into (subvec interactions 0 index)
(subvec interactions (inc index)))))
(if (and (int? index) (< -1 index (count interactions)))
(into (subvec interactions 0 index)
(subvec interactions (inc index)))
interactions)))
(defn update-interaction
"Interactions with `update-fn` applied at `index`; unchanged when `index`
addresses none."
[interactions index update-fn]
(update interactions index update-fn))
(if (and (int? index) (< -1 index (count interactions)))
(update interactions index update-fn)
interactions))
(defn remap-interactions
"Update all interactions whose destination points to a shape in the
@@ -858,7 +858,20 @@
(t/testing "Update interaction"
(let [new-interactions (ctsi/update-interaction interactions 1 #(ctsi/set-action-type % :open-url))]
(t/is (= (count new-interactions) 2))
(t/is (= (:action-type (last new-interactions)) :open-url))))))
(t/is (= (:action-type (last new-interactions)) :open-url))))
(t/testing "Remove interaction with an index out of range"
(t/is (= interactions (ctsi/remove-interaction interactions 2)))
(t/is (= interactions (ctsi/remove-interaction interactions -1)))
(t/is (= interactions (ctsi/remove-interaction interactions nil)))
(t/is (= [] (ctsi/remove-interaction nil 0))))
(t/testing "Update interaction with an index out of range"
(let [update-fn #(ctsi/set-action-type % :open-url)]
(t/is (= interactions (ctsi/update-interaction interactions 2 update-fn)))
(t/is (= interactions (ctsi/update-interaction interactions -1 update-fn)))
(t/is (= interactions (ctsi/update-interaction interactions nil update-fn)))
(t/is (nil? (ctsi/update-interaction nil 0 update-fn)))))))
(t/deftest remap-interactions
@@ -0,0 +1,484 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u04868522-3ebf-81e8-8006-306b0c9b5f59",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Text: Custom Fonts",
"~:revn": 13,
"~:modified-at": "~m1750151641034",
"~:vern": 0,
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content",
"0004-clean-shadow-and-colors",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0007-clear-invalid-strokes-and-fills-v2",
"0008-fix-library-colors-opacity",
"0009-add-partial-text-touched-flags"
]
},
"~:version": 67,
"~:project-id": "~u53a7ff09-2228-81d3-8006-4b5ea964593b",
"~:created-at": "~m1750081311326",
"~:data": {
"~:pages": ["~u434b0541-fa2f-802f-8006-6a827d964a9c"],
"~:pages-index": {
"~u434b0541-fa2f-802f-8006-6a827d964a9c": {
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:r2": 0,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:r3": 0,
"~:r1": 0,
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:r4": 0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u7d85a63e-18e7-809f-8006-6a827fe8501e",
"~u7d85a63e-18e7-809f-8006-6a833ef5fcef"
]
}
},
"~u7d85a63e-18e7-809f-8006-6a827fe8501e": {
"~#shape": {
"~:y": 451.9999962296588,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "xgmgu1frox",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "gfont-rufina",
"~:key": "ee7vl7klqs",
"~:font-size": "72",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:font-variant-id": "normal-400",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\"",
"~:text": "Text multiple selection one"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "center",
"~:font-id": "gfont-rufina",
"~:key": "17bt2f4evfs",
"~:font-size": "72",
"~:font-weight": "400",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "normal-400",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\""
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Text multiple selection one",
"~:width": 403.99995992417394,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 744.0000211580308,
"~:y": 451.9999962296588
}
},
{
"~#point": {
"~:x": 1147.9999810822046,
"~:y": 451.9999962296588
}
},
{
"~#point": {
"~:x": 1147.9999810822046,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 744.0000211580308,
"~:y": 537.9999971833331
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u7d85a63e-18e7-809f-8006-6a827fe8501e",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 744.0000211580307,
"~:selrect": {
"~#rect": {
"~:x": 744.0000211580307,
"~:y": 451.9999962296588,
"~:width": 403.99995992417394,
"~:height": 86.00000095367432,
"~:x1": 744.0000211580307,
"~:y1": 451.9999962296588,
"~:x2": 1147.9999810822046,
"~:y2": 537.9999971833331
}
},
"~:flip-x": null,
"~:height": 86.00000095367432,
"~:flip-y": null
}
},
"~u7d85a63e-18e7-809f-8006-6a833ef5fcef": {
"~#shape": {
"~:y": 537.9999971833331,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:auto-width",
"~:content": {
"~:type": "root",
"~:key": "xgmgu1frox",
"~:children": [
{
"~:type": "paragraph-set",
"~:children": [
{
"~:line-height": "1.2",
"~:font-style": "normal",
"~:children": [
{
"~:line-height": "",
"~:font-style": "normal",
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:font-id": "gfont-rufina",
"~:key": "ee7vl7klqs",
"~:font-size": "36",
"~:font-weight": "500",
"~:typography-ref-file": null,
"~:font-variant-id": "normal-500",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\"",
"~:text": "Second text, same font"
}
],
"~:typography-ref-id": null,
"~:text-transform": "none",
"~:text-align": "center",
"~:font-id": "gfont-rufina",
"~:key": "17bt2f4evfs",
"~:font-size": "0",
"~:font-weight": "500",
"~:typography-ref-file": null,
"~:text-direction": "ltr",
"~:type": "paragraph",
"~:font-variant-id": "normal-500",
"~:text-decoration": "none",
"~:letter-spacing": "0",
"~:fills": [
{
"~:fill-color": "#000000",
"~:fill-opacity": 1
}
],
"~:font-family": "\"Rufina\""
}
]
}
],
"~:vertical-align": "top"
},
"~:hide-in-viewer": false,
"~:name": "Text multiple selection two",
"~:width": 466.0000131576671,
"~:type": "~:text",
"~:points": [
{
"~#point": {
"~:x": 712.9999941849438,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 1179.0000073426108,
"~:y": 537.9999971833331
}
},
{
"~#point": {
"~:x": 1179.0000073426108,
"~:y": 580.9999976601703
}
},
{
"~#point": {
"~:x": 712.9999941849438,
"~:y": 580.9999976601703
}
}
],
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u7d85a63e-18e7-809f-8006-6a833ef5fcef",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:x": 712.9999941849437,
"~:selrect": {
"~#rect": {
"~:x": 712.9999941849437,
"~:y": 537.9999971833331,
"~:width": 466.0000131576671,
"~:height": 43.00000047683716,
"~:x1": 712.9999941849437,
"~:y1": 537.9999971833331,
"~:x2": 1179.0000073426108,
"~:y2": 580.9999976601703
}
},
"~:flip-x": null,
"~:height": 43.00000047683716,
"~:flip-y": null
}
}
},
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9c",
"~:name": "Page 1"
}
},
"~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}
@@ -0,0 +1,201 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
// ---------------------------------------------------------------------------
// The "Create typography style" button (workspace.options.convert-to-typography)
// in the text options sidebar is only shown when ALL of these hold for the
// selected text shape(s) (src/app/main/ui/workspace/sidebar/options/menus/text.cljs):
// (and (some? font) (not typography) (not multiple?) (not applied-token-name))
// Each test below isolates one condition that must independently hide it:
// - font missing (font-id not registered in app.main.fonts/fontsdb)
// - a typography asset is applied (typography-ref-id set)
// - multiple shapes are selected with differing attributes
// - a typography design token is applied (applied-tokens :typography)
// ---------------------------------------------------------------------------
function convertToTypographyButton(workspace) {
return workspace.rightSidebar.getByRole("button", {
name: "Create typography style",
});
}
test.describe("font missing", () => {
// Fixture render-wasm/get-file-text-custom-fonts.json has a text shape
// ("Penpot & Dragons") using a custom team font-id and no typography/token
// applied - otherwise exactly the state that reveals the button once its
// font resolves. Toggling the get-font-variants mock between "the team owns
// this font" and "empty" simulates the font being present vs. missing.
const FILE = {
id: "434b0541-fa2f-802f-8006-59827d964a9b",
pageId: "434b0541-fa2f-802f-8006-59827d964a9c",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when the shape font is missing", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"render-wasm/get-file-text-custom-fonts.json",
);
// The team does not own the shape's custom font, so it can't be resolved.
await workspace.mockRPC(
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Penpot & Dragons");
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
test("Create typography style button is visible once the shape font resolves", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"render-wasm/get-file-text-custom-fonts.json",
);
// The team owns the shape's custom font, so it resolves normally.
await workspace.mockRPC(
"get-font-variants?team-id=*",
"render-wasm/get-font-variants-custom-fonts.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Penpot & Dragons");
await expect(convertToTypographyButton(workspace)).toBeVisible();
});
});
test.describe("typography asset applied", () => {
// multiselection-typography.json: "Text with typography asset one" has a
// typography-ref-id pointing at an in-file typography asset (font
// gfont-agdasima, a built-in Google font that resolves with no extra
// mocking), and is not multi-selected or token-applied.
const FILE = {
id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef",
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when a typography asset is applied", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"workspace/multiselection-typography.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Text with typography asset one");
// Sanity check: the text options panel did render for this shape - the
// button is specifically hidden by the applied typography, not because
// the whole panel failed to show up.
await expect(
workspace.rightSidebar.getByRole("region", { name: "Text section" }),
).toBeVisible();
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
test.describe("multiple selection", () => {
// get-file-text-multiple-selection.json has two text shapes sharing the
// same (resolvable, built-in) font-id but differing font-size, with no
// typography or token applied - so selecting both together isolates
// `multiple?` becoming true without also making the font unresolved.
const FILE = {
id: "434b0541-fa2f-802f-8006-6a827d964a9b",
pageId: "434b0541-fa2f-802f-8006-6a827d964a9c",
};
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
test("Create typography style button is hidden when multiple shapes with different values are selected", async ({
page,
}) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(
/get\-file\?/,
"workspace/get-file-text-multiple-selection.json",
);
await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId });
await workspace.clickLeafLayer("Text multiple selection one");
await expect(convertToTypographyButton(workspace)).toBeVisible();
await workspace.clickLeafLayer("Text multiple selection two", {
modifiers: ["Shift"],
});
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
test.describe("typography token applied", () => {
// get-file-token-tooltip.json: "Text with token" has a typography design
// token applied (applied-tokens :typography) using font gfont-arizonia (a
// built-in Google font that resolves with no extra mocking).
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
test("Create typography style button is hidden when a typography token is applied", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.mockConfigFlags(["enable-feature-token-input"]);
await workspace.setupEmptyFile();
await workspace.mockRPC("get-team?id=*", "workspace/get-team-tokens.json");
await workspace.mockRPC(
/get\-file\?/,
"workspace/get-file-token-tooltip.json",
);
await workspace.mockRPC(
/get\-file\-fragment\?/,
"workspace/get-file-fragment-tokens.json",
);
await workspace.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspace.goToWorkspace({
fileId: "c7ce0794-0992-8105-8004-38f280443849",
pageId: "4530574a-7a0a-807b-8008-0107b2c4628e",
});
await page.getByRole("tab", { name: "Layers" }).click();
await workspace.layers
.getByTestId("layer-row")
.filter({ hasText: "Text with token" })
.click();
// Sanity check: the text options panel did render for this shape - the
// button is specifically hidden by the applied token, not because the
// whole panel failed to show up.
await expect(
workspace.rightSidebar.getByRole("region", { name: "Text section" }),
).toBeVisible();
await expect(convertToTypographyButton(workspace)).not.toBeVisible();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -291,8 +291,6 @@
--modal-link-foreground-color: var(--color-accent-primary);
--modal-border-color: var(--color-background-quaternary);
--modal-separator-background-color: var(--color-background-quaternary);
--modal-navigator-foreground-color-rest: var(--color-background-quaternary);
--modal-navigator-foreground-color-active: var(--color-accent-primary);
// ALERTS, NOTIFICATION, TOAST & BADGES
+2 -1
View File
@@ -36,6 +36,7 @@
[app.main.ui.releases.v2-15]
[app.main.ui.releases.v2-16]
[app.main.ui.releases.v2-17]
[app.main.ui.releases.v2-18]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@@ -107,4 +108,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.17")))
(rc/render-release-notes (assoc params :version "2.18")))
+116
View File
@@ -0,0 +1,116 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.modal-overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
z-index: var(--z-index-set);
background-color: var(--color-overlay-default);
}
.modal-container {
display: grid;
grid-template-columns: px2rem(324) 1fr;
block-size: $sz-500;
inline-size: px2rem(888);
border-radius: $br-8;
background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
}
.start-image {
inline-size: px2rem(324);
border-radius: $br-8 0 0 $br-8;
}
.modal-content {
padding: $sz-40;
display: grid;
grid-template-rows: auto 1fr $sz-32;
gap: var(--sp-xxl);
}
.modal-header {
display: grid;
gap: var(--sp-s);
}
.link {
color: var(--color-accent-primary);
}
.version-tag {
@include use-typography("headline-small");
display: flex;
justify-content: center;
align-items: center;
block-size: $sz-32;
inline-size: $sz-96;
background-color: var(--color-foreground-primary);
color: var(--color-background-tertiary);
border-radius: $br-8;
}
.modal-title {
@include use-typography("headline-large");
color: var(--color-foreground-primary);
}
.features-block {
display: flex;
flex-direction: column;
gap: var(--sp-l);
inline-size: px2rem(440);
}
.feature {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.feature-title {
@include use-typography("body-large");
color: var(--color-foreground-primary);
}
.feature-content {
@include use-typography("body-medium");
margin: 0;
color: var(--color-foreground-secondary);
}
.feature-list {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
list-style: disc;
display: grid;
gap: var(--sp-s);
}
.navigation {
inline-size: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
justify-self: flex-end;
grid-area: button;
}
+11 -10
View File
@@ -4,28 +4,29 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.step-dots {
display: grid;
grid-template-columns: none;
grid-auto-flow: column;
gap: deprecated.$s-8;
height: fit-content;
width: fit-content;
gap: var(--sp-s);
block-size: fit-content;
inline-size: fit-content;
margin: 0;
padding: 0;
place-self: center flex-start;
}
.dot {
height: deprecated.$s-12;
width: deprecated.$s-12;
border-radius: deprecated.$br-circle;
background-color: var(--modal-navigator-foreground-color-rest);
cursor: pointer;
block-size: $sz-12;
inline-size: $sz-12;
border-radius: $br-circle;
background-color: var(--color-background-quaternary);
}
.current {
background-color: var(--modal-navigator-foreground-color-active);
background-color: var(--color-accent-primary);
}
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -57,8 +58,10 @@
" Ready to dive in? Let 's get started!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -97,8 +100,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -131,8 +136,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -166,8 +173,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -198,6 +207,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -98
View File
@@ -4,101 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+5 -3
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -43,6 +44,7 @@
" Ready to dive in? Let 's get started!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click finish} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -77
View File
@@ -4,80 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+23 -12
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -107,7 +112,8 @@
"And one more thing…Tune into our product showcase to see future plans and help us shape Penpot. Come for the insights, stay for the community…"]
[:p {:class (stl/css :feature-content)}
[:a {:href "https://penpot.app/penpotfest"
[:a {:class (stl/css :link)
:href "https://penpot.app/penpotfest"
:target "_blank"}
"Get your tickets"]
" now to join us 8-10 October, in Madrid!"]]
@@ -118,8 +124,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -148,8 +156,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -179,6 +189,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -49,8 +50,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -115,8 +120,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -153,8 +160,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -184,6 +193,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -85,8 +88,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -121,8 +126,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
@@ -157,6 +164,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+13 -7
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -113,6 +118,7 @@
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -109,8 +114,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -139,8 +146,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -173,6 +182,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -79,8 +82,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -120,8 +125,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -154,6 +161,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+26 -14
View File
@@ -2,12 +2,13 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.releases.v2-16
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -44,8 +45,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -74,8 +77,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -104,8 +109,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -134,8 +141,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -177,8 +186,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
4
[:div {:class (stl/css-case :modal-overlay true)}
@@ -207,6 +218,7 @@
:navigate navigate
:total 5}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+2 -103
View File
@@ -2,107 +2,6 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+26 -13
View File
@@ -2,12 +2,13 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.releases.v2-17
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -44,8 +45,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -74,8 +77,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -104,8 +109,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -134,8 +141,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -171,8 +180,10 @@
:navigate navigate
:total 5}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
4
[:div {:class (stl/css-case :modal-overlay true)}
@@ -213,5 +224,7 @@
:navigate navigate
:total 5}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+2 -103
View File
@@ -2,107 +2,6 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
@@ -0,0 +1,252 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.releases.v2-18
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.18"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.18 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:p {:class (stl/css :feature-content)}
"Say hello to Penpot Enterprise, our new paid plan, shipping alongside the most requested stroke-to-path functionality and a fresh batch of features and fixes."]
[:p {:class (stl/css :feature-content)}
"Penpot stays free, unlimited, and open source. Enterprise is meant for organisations that need to govern over people, workflows or content."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-enterprise.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Introducing Penpot Enterprise"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Introducing Penpot Enterprise"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"As an open source product, Penpot brings the most advanced features to everyone for free."]
[:p {:class (stl/css :feature-content)}
"Penpot Enterprise is a paid layer that overrides, tweaks or restricts how these features can be used at scale."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-sso.jpg"
:class (stl/css :start-image)
:border "0"
:alt "One Sign in for everyone"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"One Sign in for everyone"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Let your whole organization sign in through your own corporate identity provider, whether that's a generic OpenID Connect provider, Azure Active Directory, or Google."]
[:p {:class (stl/css :feature-content)}
"One consistent way into your organizations teams and files, governed by the directory you already run. From the Admin Console, youll be able to configure your identity provider setup to Penpot in just a few steps."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-permissions.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Advanced permissions"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Advanced permissions"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Decide who can do what across every team at once, who can create, edit, administer teams, projects, and files, and who's allowed to invite new people in."]
[:p {:class (stl/css :feature-content)}
"Your rules sit on top of everyone's normal role, so the whole organization stays aligned with how you want to work, and every change takes effect the moment you make it."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-console.jpg"
:class (stl/css :start-image)
:border "0"
:alt "An Admin Panel to rule them all"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"An Admin Panel to rule them all"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"A separate Admin Panel allows authorized users to create independent Organisations to which certain rules can be applied."]
[:p {:class (stl/css :feature-content)}
"We are starting with something straightforward; Per-Organisation SSO (choose between multiple identity providers) and advanced permissions (decide who can do what across every team)."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
4
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-billing.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot Enterprise billing"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Penpot Enterprise billing"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"This is equally important. Whether on a monthly or annual subscription, customers using Penpot Enterprise only pay once per identifiable user (email address), regardless of the number of organisations this user is a member of."]
[:p {:class (stl/css :feature-content)}
"Bulk adding or removing users is trivial and we are making sure that you have total transparency and ergonomics around your billing cycle and what it contains."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
5
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.18-stroke2path.gif"
:class (stl/css :start-image)
:border "0"
:alt "Stroke to Paths and new drawing tools"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Stroke to Paths and new drawing tools"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Paths can now convert their stroke into a separate, editable path shape, letting you reshape or style the outline independently of the original path. Currently available under the new WebGL renderer."]
[:p {:class (stl/css :feature-content)}
"Lots of smaller wins add up this cycle. A few favorites:"]
[:p {:class (stl/css :feature-content)}
"• Draw faster with new shape and free-draw tool flyouts in the toolbar."]
[:p {:class (stl/css :feature-content)}
"• Dedicated Line and Arrow drawing tools (by @davidv399)."]
[:p {:class (stl/css :feature-content)}
"On top of that, 2.18 ships plugin API improvements, a broad round of bug fixes, and community-contributed fixes (thanks to @Krishcode264, @filipsajdak, @sawirricardo, and many more)."]]
[:div {:class (stl/css :navigation)}
[:> c/navigation-bullets*
{:slide slide
:navigate navigate
:total 6}]
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
@@ -0,0 +1,7 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "base";
+5 -3
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,6 +47,7 @@
"Thanks again to our awesome community for their amazing contributions to this release!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click finish} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -77
View File
@@ -4,80 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-480;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -8
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -47,8 +48,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -77,8 +80,10 @@
:navigate navigate
:total 2}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
@@ -98,7 +103,10 @@
[:p {:class (stl/css :feature-content)}
"Penpot plugins are quite easy to install."]
[:p {:class (stl/css :feature-content)}
"Be sure to keep an eye on our evolving " [:a {:href "https://penpot.app/penpothub" :target "_blank"} "Penpot Hub"] " to pick the ones that are best suited to enhance your workflow."]
"Be sure to keep an eye on our evolving "
[:a {:class (stl/css :link)
:href "https://penpot.app/penpothub"
:target "_blank"} "Penpot Hub"] " to pick the ones that are best suited to enhance your workflow."]
[:p {:class (stl/css :feature-content)}
"This is just the beginning of a myriad of possibilities. Lets build this community together ❤️."]]
@@ -110,6 +118,7 @@
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -47,8 +48,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -77,8 +80,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -107,8 +112,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -136,6 +143,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -47,8 +48,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -77,8 +80,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -107,8 +112,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -141,8 +148,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -170,6 +179,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -48,8 +49,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -89,8 +92,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -125,8 +130,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -164,6 +171,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+17 -9
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -76,8 +79,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -106,8 +111,10 @@
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -137,6 +144,7 @@
:navigate navigate
:total 3}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
+21 -11
View File
@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
@@ -46,8 +47,10 @@
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
@@ -88,8 +91,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
@@ -121,8 +126,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
2
[:div {:class (stl/css-case :modal-overlay true)}
@@ -154,8 +161,10 @@
:navigate navigate
:total 4}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
[:> button* {:class (stl/css :next-btn)
:on-click next
:variant "primary"}
"Continue"]]]]]]
3
[:div {:class (stl/css-case :modal-overlay true)}
@@ -191,6 +200,7 @@
:navigate navigate
:total 4}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))
[:> button* {:class (stl/css :next-btn)
:on-click finish
:variant "primary"}
"Let's go"]]]]]])))
+1 -102
View File
@@ -4,105 +4,4 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flex-center;
@include deprecated.headline-small-typography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headline-large-typography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.body-large-typography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.body-medium-typography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.body-medium-typography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend %button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}
@use "base";
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more