Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947954933c | ||
|
|
87e5ffdf94 | ||
|
|
3adf9ade14 | ||
|
|
f3da8af7b6 | ||
|
|
7ff76a9ebc | ||
|
|
fc8c5a98de | ||
|
|
bda8459d89 | ||
|
|
06239844b1 | ||
|
|
09736aa4c9 | ||
|
|
ffad71bdc5 | ||
|
|
32ed9b5a08 | ||
|
|
0913545b41 | ||
|
|
37dab75e1a | ||
|
|
f9c02926b9 | ||
|
|
bae3900537 | ||
|
|
757a5bd479 | ||
|
|
9c07dd124a | ||
|
|
99c036feac | ||
|
|
aa78ca0be8 | ||
|
|
8952d70fd2 | ||
|
|
7c27ed812a | ||
|
|
4ce459d720 | ||
|
|
286ccb03fa | ||
|
|
dbe5941a23 | ||
|
|
30849babcc | ||
|
|
dc12f1db91 | ||
|
|
c589563912 | ||
|
|
0eb3179016 | ||
|
|
d1ebf4cda2 | ||
|
|
fdb9e97572 | ||
|
|
9cd3b63eea | ||
|
|
94555c027e | ||
|
|
b283d952a8 | ||
|
|
ac84557740 | ||
|
|
43f7e49aa0 | ||
|
|
eca1d81692 | ||
|
|
d263c23a58 |
No files matched your search
@@ -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 1–3. 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).
|
||||
@@ -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:
|
||||
|
||||
@@ -357,6 +357,39 @@ Insert the new version section right after the `# CHANGELOG` header (before
|
||||
the previous version entry). Use the `edit` tool with enough context to make
|
||||
a unique match.
|
||||
|
||||
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
|
||||
|
||||
After inserting the version section, proactively create or populate the
|
||||
`### :rocket: Epics and highlights` subsection. This section surfaces the
|
||||
most impactful changes for self-hosted users checking for updates.
|
||||
|
||||
**When to create:** If the version section does not already have a
|
||||
`### :rocket: Epics and highlights` subsection, create one. Place it before
|
||||
`### :sparkles:` (matching existing order in CHANGES.md).
|
||||
|
||||
**How to identify highlights:** Review the `:sparkles:` entries for the
|
||||
version and select 2–5 of the most impactful/user-visible ones. Criteria:
|
||||
- New user-visible features (not internal refactors)
|
||||
- Significant capability additions
|
||||
- Items that create "FOMO" for self-hosted users on older versions
|
||||
|
||||
**Use release notes as hints:** Check
|
||||
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
|
||||
version. The slide titles and feature descriptions there are curated
|
||||
marketing content indicating what the team considers highlight-worthy. Match
|
||||
those themes to changelog entries. Treat these files as optional hints — they
|
||||
may not exist for every version.
|
||||
|
||||
**Format requirement:** Every `:rocket:` entry MUST follow the standard
|
||||
changelog format with issue/PR references:
|
||||
```
|
||||
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
```
|
||||
An entry without issue AND PR references is a highlight gap (warning, not an anomaly).
|
||||
|
||||
**Preserve existing entries:** If the `:rocket:` section already exists from
|
||||
a prior run, preserve its entries. Do not remove or rewrite them.
|
||||
|
||||
### 9. Verify
|
||||
|
||||
Read the top of `CHANGES.md` and confirm:
|
||||
@@ -468,9 +501,8 @@ Markdown viewer.
|
||||
## What is an anomaly
|
||||
|
||||
**An anomaly is a milestone-mismatch between an issue and its referenced
|
||||
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
|
||||
all in milestone M" is inconsistent with the actual milestone assignments.
|
||||
There are exactly two types:
|
||||
PR.** There are two anomaly types, plus two highlight gaps (warnings that
|
||||
do not count toward the anomaly total):
|
||||
|
||||
1. **Issue is in the milestone, but its referenced PR is in a different
|
||||
milestone (or has no milestone).** The changelog claims a fix in this
|
||||
@@ -486,6 +518,13 @@ There are exactly two types:
|
||||
PR that closes an issue with no milestone references an issue from
|
||||
another (probably private) project; that is expected and the issue is
|
||||
not part of this changelog. Do not report it.
|
||||
3. **missing-highlights (gap):** A released X.Y.0 version section has no
|
||||
`### :rocket: Epics and highlights` subsection. Patches (X.Y.Z) never
|
||||
carry highlights, so only minors/majors are checked.
|
||||
4. **missing-highlight-reference (gap):** A `:rocket:` entry lacks the
|
||||
required issue AND PR references. Every highlight entry must follow the
|
||||
standard changelog format with `[#ISSUE]` and `(PR: [#PR])` links
|
||||
(multi-PR `(PR: [#A](...), [#B](...))` accepted).
|
||||
|
||||
**Anything else is not an anomaly.** Other discrepancies (exclusion
|
||||
labels on in-changelog issues, missing valid issues, unmerged PR
|
||||
@@ -653,6 +692,40 @@ for pr_num in sorted(changelog_prs):
|
||||
'issue_milestone': issue_ms, # may be None
|
||||
})
|
||||
|
||||
# --- Type C: released X.Y.0 version sections without :rocket: subsection ---
|
||||
# Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0).
|
||||
anomalies_c = [] # list of version strings
|
||||
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
|
||||
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
|
||||
for vs in version_sections:
|
||||
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
|
||||
if not m: continue
|
||||
ver, suffix = m.group(1), m.group(2)
|
||||
if 'unreleased' in suffix.lower(): continue
|
||||
if ver.split('.')[2] != '0': continue
|
||||
if not rocket_heading_re.search(vs):
|
||||
anomalies_c.append(ver)
|
||||
|
||||
# --- Type D: :rocket: entries without issue AND PR references ---
|
||||
# Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`.
|
||||
# Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted.
|
||||
anomalies_d = [] # list of dicts: {version, line}
|
||||
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
|
||||
pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)')
|
||||
for vs in version_sections:
|
||||
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
|
||||
if not m: continue
|
||||
ver = m.group(1)
|
||||
rocket_match = rocket_heading_re.search(vs)
|
||||
if not rocket_match: continue
|
||||
# Extract the :rocket: subsection body (up to next ### or ##)
|
||||
rocket_body = vs[rocket_match.end():]
|
||||
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
|
||||
for line in rocket_body.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('- ') and not (issue_ref_re.search(line) and pr_ref_re.search(line)):
|
||||
anomalies_d.append({'version': ver, 'line': line[:100]})
|
||||
|
||||
# --- Write report ---
|
||||
def fmt_ms(ms):
|
||||
return ms if ms else "_none_"
|
||||
@@ -664,13 +737,17 @@ with open(OUTPUT, 'w') as f:
|
||||
|
||||
n_a = len(anomalies_a)
|
||||
n_b = len(anomalies_b)
|
||||
n_c = len(anomalies_c)
|
||||
n_d = len(anomalies_d)
|
||||
|
||||
f.write('## Summary\n\n')
|
||||
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
|
||||
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
|
||||
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
|
||||
f.write(f'- **Total anomalies:** {n_a + n_b}\n')
|
||||
f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n')
|
||||
f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n')
|
||||
|
||||
# --- Anomalies section ---
|
||||
# --- Anomalies section (milestone mismatches only) ---
|
||||
if n_a or n_b:
|
||||
f.write('## Anomalies\n\n')
|
||||
f.write('These are milestone mismatches between an issue in the changelog '
|
||||
@@ -709,9 +786,37 @@ with open(OUTPUT, 'w') as f:
|
||||
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
|
||||
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
|
||||
f.write('\n')
|
||||
|
||||
else:
|
||||
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
|
||||
|
||||
# --- Highlight gaps (warnings, not anomalies) ---
|
||||
if n_c or n_d:
|
||||
f.write('## Highlight gaps\n\n')
|
||||
f.write('These are warnings, not anomalies: they do not affect the '
|
||||
'milestone-mismatch total above. They track `:rocket:` coverage '
|
||||
'across all released X.Y.0 versions. Historical entries (e.g. '
|
||||
'Taiga links) predate the current reference convention and are '
|
||||
'expected to appear here.\n\n')
|
||||
|
||||
if n_c:
|
||||
f.write(f'### Released X.Y.0 version missing :rocket: section\n\n')
|
||||
f.write('These released minors/majors have no `### :rocket: Epics and highlights` subsection. '
|
||||
'Add highlights to help self-hosted users understand what they are missing.\n\n')
|
||||
for ver in anomalies_c:
|
||||
f.write(f'- Version **{ver}**\n')
|
||||
f.write('\n')
|
||||
|
||||
if n_d:
|
||||
f.write(f'### :rocket: entry without issue AND PR references\n\n')
|
||||
f.write('These highlight entries lack the required issue AND PR references. '
|
||||
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
|
||||
for d in anomalies_d:
|
||||
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
|
||||
f.write('\n')
|
||||
elif not (n_a or n_b):
|
||||
f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n')
|
||||
|
||||
# --- Context ---
|
||||
f.write('---\n\n')
|
||||
f.write('## Context\n\n')
|
||||
@@ -726,8 +831,7 @@ print(f"Anomaly report written to {OUTPUT}")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
This generates `CHANGES-ISSUES.md` containing **only the anomalies** —
|
||||
milestone mismatches between issues and their referenced PRs:
|
||||
This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
|
||||
|
||||
1. **Issue in milestone, referenced PR in different milestone or no milestone** —
|
||||
the changelog claims a fix here, but the PR is released elsewhere.
|
||||
@@ -736,6 +840,13 @@ milestone mismatches between issues and their referenced PRs:
|
||||
(An issue with *no* milestone belongs to another, probably private,
|
||||
project — milestones are only required on the "Main" project — so it is
|
||||
neither an anomaly nor a changelog candidate.)
|
||||
3. **missing-highlights (gap, warning)** — a released X.Y.0 version section
|
||||
has no `### :rocket: Epics and highlights` subsection. Patches (X.Y.Z)
|
||||
never carry highlights.
|
||||
4. **missing-highlight-reference (gap, warning)** — a `:rocket:` entry lacks
|
||||
the required issue AND PR references.
|
||||
|
||||
Gaps do not count toward the anomaly total.
|
||||
|
||||
**Rule violations are not in the report** — they are workflow errors the
|
||||
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
|
||||
@@ -809,10 +920,13 @@ self-contained and clickable in any Markdown viewer.
|
||||
issue from a different project or context. If the PR title and issue title
|
||||
are clearly unrelated, or the PR predates the issue by years, treat it as a
|
||||
data glitch and skip it.
|
||||
- **Anomaly = milestone mismatch only.** The report contains only milestone
|
||||
mismatches: (1) the issue is in this milestone but the referenced PR is
|
||||
in a different milestone (or unassigned), and (2) the PR is in this
|
||||
milestone but the issue it closes is in a different milestone. An
|
||||
- **Anomaly = milestone mismatch only; gaps are warnings.** The report's
|
||||
anomaly total counts only milestone mismatches: (1) the issue is in this
|
||||
milestone but the referenced PR is in a different milestone (or unassigned),
|
||||
and (2) the PR is in this milestone but the issue it closes is in a
|
||||
different milestone. `:rocket:` highlight gaps (missing section on a
|
||||
released X.Y.0, entry without issue AND PR references) are reported in a
|
||||
separate `Highlight gaps` section and never count toward the anomaly total. An
|
||||
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
|
||||
an anomaly: milestones are required only for the "Main" project, so such
|
||||
issues come from another (probably private) project and are not changelog
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-standar-runner
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
@@ -302,7 +302,7 @@ jobs:
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,7 +24,6 @@ opencode.json
|
||||
!AGENTS.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
!SECURITY.md
|
||||
!HIGHLIGHTS.md
|
||||
/*.png
|
||||
/*.svg
|
||||
/*.sql
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -156,6 +156,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
|
||||
|
||||
@@ -218,6 +233,10 @@
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
|
||||
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
|
||||
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
|
||||
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@@ -572,6 +591,10 @@
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# HIGHLIGHTS
|
||||
|
||||
## 2.17.0
|
||||
|
||||
- Background blur is here
|
||||
- WebGL rendering gets stronger
|
||||
- MCP connection status and more
|
||||
- Design tokens: more visible, more user-friendly
|
||||
|
||||
|
||||
## 2.16.0
|
||||
|
||||
- Design tokens in the design panel
|
||||
- Major community contributions
|
||||
- WebGL rendering (beta)
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
- AI connected to real design context
|
||||
- Multi-directional workflow
|
||||
- Your stack, your model, your decision
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ If your organization is scaling and needs extra support, we’re 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 Penpot’s open-source foundation and open standards.
|
||||
|
||||
## Community ##
|
||||
|
||||
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
||||
|
||||
@@ -31,7 +31,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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,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 #{}
|
||||
@@ -203,6 +204,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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
::http.client/client
|
||||
{}
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
::session/manager
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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/touched-at (ct/in-future {:hours 1})
|
||||
: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
|
||||
|
||||
@@ -400,6 +422,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."
|
||||
@@ -418,18 +452,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)))
|
||||
|
||||
@@ -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 SUBSIDIARY 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 SUBSIDIARY 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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -460,6 +460,19 @@
|
||||
(let [content (impl/path-data content)]
|
||||
(segment/merge-nodes content points)))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Collapses the nodes sharing a position into one node.
|
||||
|
||||
Without `points` every position held by more than one command is merged."
|
||||
([content]
|
||||
(let [content (impl/path-data content)]
|
||||
(-> (segment/merge-coincident-nodes content)
|
||||
(impl/from-plain))))
|
||||
([content points]
|
||||
(let [content (impl/path-data content)]
|
||||
(-> (segment/merge-coincident-nodes content points)
|
||||
(impl/from-plain)))))
|
||||
|
||||
(defn join-nodes
|
||||
"Creates new segments between points that weren't previously connected."
|
||||
[content points]
|
||||
|
||||
@@ -940,18 +940,21 @@
|
||||
(not= :close-path (:command c))))]
|
||||
(loop [i 0
|
||||
k 0
|
||||
start nil
|
||||
result (transient [])]
|
||||
(if (>= i n)
|
||||
(persistent! result)
|
||||
(let [cmd (nth content i)
|
||||
nxt (nth content (inc i) nil)
|
||||
move? (= :move-to (:command cmd))
|
||||
start (if move? (helpers/segment->point cmd) start)
|
||||
at-p? (and (not= :close-path (:command cmd))
|
||||
(gpt/close? point (helpers/segment->point cmd)))]
|
||||
(cond
|
||||
;; Offset a subpath start.
|
||||
(and at-p? (= :move-to (:command cmd)))
|
||||
(and at-p? move?)
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k)
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (-> cmd
|
||||
(update-in [:params :x] + (:x off))
|
||||
(update-in [:params :y] + (:y off))))))
|
||||
@@ -972,7 +975,7 @@
|
||||
(= :curve-to (:command nxt))
|
||||
(-> (update-in [:params :c1x] + (:x off2))
|
||||
(update-in [:params :c1y] + (:y off2))))]
|
||||
(recur (+ i 2) (inc k2)
|
||||
(recur (+ i 2) (inc k2) start
|
||||
(-> result (conj! cmd') (conj! mv) (conj! nxt'))))
|
||||
|
||||
;; Open and offset a closed seam.
|
||||
@@ -985,12 +988,20 @@
|
||||
(-> (update-in [:params :c2x] + (:x off))
|
||||
(update-in [:params :c2y] + (:y off))))]
|
||||
;; Drop the close command so the seam stays open.
|
||||
(recur (+ i 2) (inc k) (conj! result cmd')))
|
||||
(recur (+ i 2) (inc k) start (conj! result cmd')))
|
||||
|
||||
;; Open the seam of a subpath that closes back onto the node.
|
||||
(and (= :close-path (:command cmd))
|
||||
(some? start)
|
||||
(gpt/close? point start))
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (helpers/make-line-to (gpt/add point off)))))
|
||||
|
||||
;; Offset the end of an open subpath.
|
||||
(and at-p? (seg? cmd) (not= :close-path (:command nxt)))
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k)
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (cond-> (-> cmd
|
||||
(update-in [:params :x] + (:x off))
|
||||
(update-in [:params :y] + (:y off)))
|
||||
@@ -999,7 +1010,7 @@
|
||||
(update-in [:params :c2y] + (:y off)))))))
|
||||
|
||||
:else
|
||||
(recur (inc i) k (conj! result cmd))))))))
|
||||
(recur (inc i) k start (conj! result cmd))))))))
|
||||
|
||||
(defn separate-nodes
|
||||
"Removes segments between points or splits one node into offset open ends."
|
||||
@@ -1072,7 +1083,7 @@
|
||||
|
||||
result (cond-> result
|
||||
(and (nil? set-a) (nil? set-b))
|
||||
(conj #{point-a point-b})
|
||||
(conj (hash-set point-a point-b))
|
||||
|
||||
(and (some? set-a) (nil? set-b))
|
||||
(add-to-set set-a point-b)
|
||||
@@ -1108,6 +1119,144 @@
|
||||
(->> content
|
||||
(mapv replace-command))))
|
||||
|
||||
(defn- remove-empty-segments
|
||||
"Drops segments with no length whose ends are accepted by `at-point?`."
|
||||
[content at-point?]
|
||||
(loop [result (transient [])
|
||||
prev nil
|
||||
segments? false
|
||||
pending (seq content)]
|
||||
(if-let [{:keys [command] :as segment} (first pending)]
|
||||
(let [close? (= :close-path command)
|
||||
move? (= :move-to command)
|
||||
point (when-not close? (helpers/segment->point segment))
|
||||
;; A close command on a subpath without segments draws nothing.
|
||||
empty? (if close?
|
||||
(not segments?)
|
||||
(and (not move?)
|
||||
(some? prev)
|
||||
(gpt/close? prev point)
|
||||
(at-point? point)))]
|
||||
(if empty?
|
||||
(recur result prev segments? (next pending))
|
||||
(recur (conj! result segment)
|
||||
(if close? nil point)
|
||||
(not (or move? close?))
|
||||
(next pending))))
|
||||
(persistent! result))))
|
||||
|
||||
(defn- point-key
|
||||
"Rounded coordinates of a point, usable as a map key."
|
||||
[point]
|
||||
[(mth/round (:x point) 0.1) (mth/round (:y point) 0.1)])
|
||||
|
||||
(defn- curve-key
|
||||
"Key for the curve a segment draws, equal in either direction."
|
||||
[from segment to]
|
||||
(let [c1 (or (get-handler segment :c1) from)
|
||||
c2 (or (get-handler segment :c2) to)
|
||||
fwd [(point-key from) (point-key c1) (point-key c2) (point-key to)]
|
||||
bwd [(point-key to) (point-key c2) (point-key c1) (point-key from)]]
|
||||
(if (neg? (compare fwd bwd)) fwd bwd)))
|
||||
|
||||
(defn- node-point-groups
|
||||
"Node positions of the content grouped by their rounded coordinates."
|
||||
[content]
|
||||
(group-by point-key
|
||||
(into []
|
||||
(comp (remove #(= :close-path (:command %)))
|
||||
(map helpers/segment->point))
|
||||
content)))
|
||||
|
||||
(defn- coincident-points
|
||||
"Positions of the content that more than one command holds."
|
||||
[content]
|
||||
(into #{}
|
||||
(comp (filter (fn [[_ points]] (> (count points) 1)))
|
||||
(map (fn [[_ points]] (first points))))
|
||||
(node-point-groups content)))
|
||||
|
||||
(defn- repeated-nodes
|
||||
"Rounded positions accepted by `at-point?` that more than one command holds."
|
||||
[content at-point?]
|
||||
(into #{}
|
||||
(comp (filter (fn [[_ points]]
|
||||
(and (> (count points) 1)
|
||||
(at-point? (first points)))))
|
||||
(map key))
|
||||
(node-point-groups content)))
|
||||
|
||||
(defn- resume-segment
|
||||
"Commands that reopen the subpath at `from` and draw `segment` from there."
|
||||
[from segment start]
|
||||
(if (= :close-path (:command segment))
|
||||
(when-not (subpath/pt= from start)
|
||||
[(helpers/make-move-to from) (helpers/make-line-to start)])
|
||||
[(helpers/make-move-to from) segment]))
|
||||
|
||||
(defn- remove-retraced-segments
|
||||
"Drops the segments that draw a curve already drawn through a node.
|
||||
|
||||
A node held by several commands is a junction, but two segments meeting
|
||||
there and drawing the same curve are one line traced twice."
|
||||
[content at-point?]
|
||||
(let [repeated (repeated-nodes content at-point?)
|
||||
retraced? (fn [from to]
|
||||
(or (contains? repeated (point-key from))
|
||||
(contains? repeated (point-key to))))]
|
||||
(if (empty? repeated)
|
||||
content
|
||||
(loop [result (transient [])
|
||||
pending (seq content)
|
||||
drawn #{}
|
||||
from nil
|
||||
start nil
|
||||
lifted? false]
|
||||
(if-let [{:keys [command] :as segment} (first pending)]
|
||||
(if (= :move-to command)
|
||||
(let [point (helpers/segment->point segment)]
|
||||
(recur (conj! result segment) (next pending) drawn point point false))
|
||||
(let [to (if (= :close-path command)
|
||||
start
|
||||
(helpers/segment->point segment))
|
||||
key (curve-key from segment to)]
|
||||
(if (and (contains? drawn key)
|
||||
(retraced? from to))
|
||||
(recur result (next pending) drawn to start true)
|
||||
(recur (reduce conj! result (if lifted?
|
||||
(resume-segment from segment start)
|
||||
[segment]))
|
||||
(next pending) (conj drawn key) to start false))))
|
||||
(persistent! result))))))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Collapses the commands sharing a position at `points` into a single node.
|
||||
|
||||
Drops the empty segments and the ones retracing another through such a
|
||||
point, and stitches the subpath ends meeting there, closing the resulting
|
||||
loops. A point where more than two distinct segments meet is left alone: the
|
||||
format needs one command per segment there. Without `points` every position
|
||||
held by more than one command is merged."
|
||||
([content]
|
||||
(merge-coincident-nodes content (coincident-points content)))
|
||||
([content points]
|
||||
(let [at-point? (fn [point] (some #(gpt/close? point %) points))
|
||||
|
||||
stitch (fn [content]
|
||||
(-> content
|
||||
(subpath/close-subpaths at-point?)
|
||||
;; A subpath whose ends meet carries an explicit close command.
|
||||
(subpath/close-loops)))
|
||||
|
||||
content (-> (vec content)
|
||||
(remove-empty-segments at-point?)
|
||||
(stitch))
|
||||
|
||||
retraced (remove-retraced-segments content at-point?)]
|
||||
(if (= retraced content)
|
||||
content
|
||||
(stitch retraced)))))
|
||||
|
||||
(defn merge-nodes
|
||||
"Joins and merges `points` into one point."
|
||||
[content points]
|
||||
@@ -1116,10 +1265,12 @@
|
||||
(if (seq segments)
|
||||
(let [point->merge-point (-> segments
|
||||
(group-segments)
|
||||
(calculate-merge-points points))]
|
||||
(calculate-merge-points points))
|
||||
merge-points (set (vals point->merge-point))]
|
||||
(-> content
|
||||
(separate-nodes points)
|
||||
(replace-points point->merge-point)))
|
||||
(replace-points point->merge-point)
|
||||
(merge-coincident-nodes merge-points)))
|
||||
content)))
|
||||
|
||||
(defn transform-content
|
||||
|
||||
@@ -99,25 +99,30 @@
|
||||
|
||||
(defn- merge-paths
|
||||
"Tries to merge into candidate the subpaths. Will return the candidate with the subpaths merged
|
||||
and removed from subpaths the subpaths merged"
|
||||
[candidate subpaths]
|
||||
(let [merge-with-candidate
|
||||
and removed from subpaths the subpaths merged. Only meeting points accepted
|
||||
by `meet?` are joined"
|
||||
[candidate subpaths meet?]
|
||||
(let [joins?
|
||||
(fn [point other]
|
||||
(and (pt= point other) (meet? point)))
|
||||
|
||||
merge-with-candidate
|
||||
(fn [[candidate result] current]
|
||||
(cond
|
||||
(pt= (:to current) (:from current))
|
||||
;; Subpath is already a closed path
|
||||
[candidate (conj result current)]
|
||||
|
||||
(pt= (:to candidate) (:from current))
|
||||
(joins? (:to candidate) (:from current))
|
||||
[(subpaths-join candidate current) result]
|
||||
|
||||
(pt= (:from candidate) (:to current))
|
||||
(joins? (:from candidate) (:to current))
|
||||
[(subpaths-join current candidate) result]
|
||||
|
||||
(pt= (:to candidate) (:to current))
|
||||
(joins? (:to candidate) (:to current))
|
||||
[(subpaths-join candidate (reverse-subpath current)) result]
|
||||
|
||||
(pt= (:from candidate) (:from current))
|
||||
(joins? (:from candidate) (:from current))
|
||||
[(subpaths-join (reverse-subpath current) candidate) result]
|
||||
|
||||
:else
|
||||
@@ -163,35 +168,37 @@
|
||||
(into [] xf-mapcat-data merged)))
|
||||
|
||||
(defn close-subpaths
|
||||
"Searches a path for possible subpaths that can create closed loops and merge them"
|
||||
[content]
|
||||
(let [subpaths (get-subpaths content)
|
||||
closed-subpaths
|
||||
(loop [result []
|
||||
current (first subpaths)
|
||||
subpaths (rest subpaths)]
|
||||
"Searches a path for possible subpaths that can create closed loops and merge them.
|
||||
When `meet?` is given only subpaths that touch at an accepted point are merged"
|
||||
([content]
|
||||
(close-subpaths content (constantly true)))
|
||||
([content meet?]
|
||||
(let [subpaths (get-subpaths content)
|
||||
closed-subpaths
|
||||
(loop [result []
|
||||
current (first subpaths)
|
||||
subpaths (rest subpaths)]
|
||||
|
||||
(if (some? current)
|
||||
(let [[new-current new-subpaths]
|
||||
(if (is-closed? current)
|
||||
[current subpaths]
|
||||
(merge-paths current subpaths))]
|
||||
(if (some? current)
|
||||
(let [[new-current new-subpaths]
|
||||
(if (is-closed? current)
|
||||
[current subpaths]
|
||||
(merge-paths current subpaths meet?))]
|
||||
|
||||
(if (= current new-current)
|
||||
;; If equal we haven't found any matching subpaths we advance
|
||||
(recur (conj result new-current)
|
||||
(first new-subpaths)
|
||||
(rest new-subpaths))
|
||||
(if (= current new-current)
|
||||
;; If equal we haven't found any matching subpaths we advance
|
||||
(recur (conj result new-current)
|
||||
(first new-subpaths)
|
||||
(rest new-subpaths))
|
||||
|
||||
;; If different we need to pass again the merge to check for additional
|
||||
;; subpaths to join
|
||||
(recur result
|
||||
new-current
|
||||
new-subpaths)))
|
||||
result))]
|
||||
;; If different we need to pass again the merge to check for additional
|
||||
;; subpaths to join
|
||||
(recur result
|
||||
new-current
|
||||
new-subpaths)))
|
||||
result))]
|
||||
|
||||
|
||||
(into [] xf-mapcat-data closed-subpaths)))
|
||||
(into [] xf-mapcat-data closed-subpaths))))
|
||||
|
||||
(defn- close-loop
|
||||
"Adds an explicit close command when a subpath's endpoints meet."
|
||||
|
||||
@@ -272,6 +272,20 @@
|
||||
-1))))
|
||||
items))))
|
||||
|
||||
(defn- clipped-by-ancestor?
|
||||
"Checks whether position falls outside the visible (clipped) bounds of
|
||||
some ancestor frame with clip content enabled. Used so that a nested
|
||||
frame that extends beyond a clipping ancestor's own bounds is never
|
||||
considered hit/reachable in the invisible, clipped-away region."
|
||||
[objects shape position]
|
||||
(->> (cfh/get-parent-ids objects (dm/get-prop shape :id))
|
||||
(keep (d/getf objects))
|
||||
(some (fn [ancestor]
|
||||
(and (not= (dm/get-prop ancestor :id) uuid/zero)
|
||||
^boolean (cfh/frame-shape? ancestor)
|
||||
(not (:show-content ancestor))
|
||||
(not ^boolean (gsh/has-point? ancestor position)))))))
|
||||
|
||||
(defn get-frame-by-position
|
||||
([objects position]
|
||||
(get-frame-by-position objects position nil))
|
||||
@@ -287,6 +301,7 @@
|
||||
validator (or (get options :validator) #(-> true))]
|
||||
(or (d/seek #(and ^boolean (some? position)
|
||||
^boolean (gsh/has-point? % position)
|
||||
^boolean (not (clipped-by-ancestor? objects % position))
|
||||
^boolean (validator %))
|
||||
frames)
|
||||
(get objects uuid/zero)))))
|
||||
@@ -302,7 +317,8 @@
|
||||
([objects position options]
|
||||
(->> (get-frames objects options)
|
||||
(filter #(and ^boolean (some? position)
|
||||
^boolean (gsh/has-point? % position)))
|
||||
^boolean (gsh/has-point? % position)
|
||||
^boolean (not (clipped-by-ancestor? objects % position))))
|
||||
(sort-z-index-objects objects))))
|
||||
|
||||
(defn top-nested-frame
|
||||
|
||||
@@ -1373,6 +1373,20 @@
|
||||
(t/is (= {:c2x 4.0 :c2y 4.0}
|
||||
(select-keys (:params (peek result)) [:c2x :c2y])))))
|
||||
|
||||
(t/deftest segment-separate-single-node-closed-subpath-start
|
||||
;; The seam of a closed subpath opens even when it is the subpath start.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (vec (path/separate-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
;; the close command becomes the second, offset, open end
|
||||
(t/is (= [:move-to :line-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 0.0}
|
||||
{:x 10.0 :y 10.0} {:x 8.0 :y 8.0}]
|
||||
(mapv #(select-keys (:params %) [:x :y]) result)))))
|
||||
|
||||
(t/deftest segment-separate-single-node-endpoint-noop
|
||||
;; an endpoint node has no following segment, so nothing is split
|
||||
(let [content (path/content
|
||||
@@ -2092,7 +2106,7 @@
|
||||
(t/is (some? result)))))
|
||||
|
||||
(t/deftest path-merge-disconnected-nodes
|
||||
;; Merging separate subpaths joins them at the shared midpoint.
|
||||
;; Merging separate subpaths stitches them into one at the shared midpoint.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
@@ -2100,10 +2114,184 @@
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)}
|
||||
result (vec (path/merge-nodes content pts))]
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0}
|
||||
{:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
|
||||
(mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-nodes-leaves-a-single-node
|
||||
;; The merged node exists once, so separating it yields a fresh split.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 12.0 :y 12.0}}])
|
||||
merged (path/merge-nodes content #{(gpt/point 10.0 10.0)
|
||||
(gpt/point 12.0 12.0)})
|
||||
node (gpt/point 11.0 11.0)]
|
||||
(t/is (= 1 (count (path/point-indices merged node))))
|
||||
;; separating splits the node in two ends, none of them the merged nodes
|
||||
(let [result (vec (path/separate-nodes merged #{node} (gpt/point 8.0 8.0)))]
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 11.0 :y 11.0}
|
||||
{:x 19.0 :y 19.0} {:x 20.0 :y 0.0}]
|
||||
(mapv #(select-keys (:params %) [:x :y]) result))))))
|
||||
|
||||
(t/deftest path-merge-nodes-on-empty-segment
|
||||
;; Merging across an empty segment returns a content instead of throwing
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 0.0}}])]
|
||||
(t/is (some? (path/merge-nodes content #{(gpt/point 0.0 0.0)
|
||||
(gpt/point 20.0 0.0)})))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-stitches-dragged-ends
|
||||
;; Two open ends left at the same position become one node
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 10.0 10.0)}))]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 10.0} {:x 20.0 :y 0.0}]
|
||||
(mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-drops-empty-segment
|
||||
;; A node dragged onto its neighbour leaves no segment behind
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 0.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
(t/is (= [:move-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 20.0 :y 0.0}] (mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-closes-the-loop
|
||||
;; Dragging both ends of a subpath together closes it
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
(t/is (= [:move-to :line-to :close-path] (mapv :command result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-only-at-given-points
|
||||
;; Subpaths touching somewhere else are left alone
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
result (path/merge-coincident-nodes content #{(gpt/point 20.0 0.0)})]
|
||||
(t/is (= (vec content) (vec result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-keeps-closed-subpaths
|
||||
;; Closed subpaths keep their close command, wherever the merge happens
|
||||
(let [rect (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
curve (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 8.0
|
||||
:x 10.0 :y 10.0}}
|
||||
{:command :curve-to :params {:c1x 8.0 :c1y -8.0 :c2x 2.0 :c2y -2.0
|
||||
:x 0.0 :y 0.0}}
|
||||
{:command :close-path :params {}}])]
|
||||
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 10.0 0.0)}))))
|
||||
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 0.0 0.0)}))))
|
||||
(t/is (= (vec curve) (vec (path/merge-coincident-nodes curve #{(gpt/point 0.0 0.0)}))))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-keeps-junctions
|
||||
;; Four distinct segments meeting at a point need one command each
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :move-to :params {:x 0.0 :y 10.0}}
|
||||
{:command :line-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
result (path/merge-coincident-nodes content #{(gpt/point 5.0 5.0)})]
|
||||
(t/is (= (vec content) (vec result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-drops-a-retraced-segment
|
||||
;; The rest of the loop draws the same two lines backwards; dropping them
|
||||
;; leaves a single node where they meet.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 10.0}}
|
||||
{:command :curve-to :params {:c1x 20.0 :c1y 10.0
|
||||
:c2x 10.0 :c2y 5.0
|
||||
:x 10.0 :y 5.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 10.0 5.0)}))]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 5.0} {:x 20.0 :y 10.0}]
|
||||
(mapv :params result)))
|
||||
(t/is (= 1 (count (path/point-indices result (gpt/point 10.0 5.0)))))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-stitches-a-retraced-junction
|
||||
;; The same two lines, drawn out and back from the subpath start
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 5.0 5.0)}))]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 10.0} {:x 5.0 :y 5.0} {:x 10.0 :y 0.0}]
|
||||
(mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-collapses-every-repeated-node
|
||||
;; Without points every position held by more than one command is merged
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 119.0 :y 231.0}}
|
||||
{:command :line-to :params {:x 447.0 :y 253.0}}
|
||||
{:command :curve-to :params {:c1x 447.0 :c1y 253.0
|
||||
:c2x 774.0 :c2y 384.0
|
||||
:x 774.0 :y 384.0}}
|
||||
{:command :curve-to :params {:c1x 774.0 :c1y 384.0
|
||||
:c2x 447.0 :c2y 253.0
|
||||
:x 447.0 :y 253.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (vec (path/merge-coincident-nodes content))]
|
||||
(t/is (= [:move-to :line-to :curve-to] (mapv :command result)))
|
||||
(t/is (= 1 (count (path/point-indices result (gpt/point 447.0 253.0)))))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-keeps-distinct-curves
|
||||
;; Two different curves between the same two points are not a retrace
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 8.0
|
||||
:x 10.0 :y 10.0}}
|
||||
{:command :curve-to :params {:c1x 8.0 :c1y -8.0 :c2x 2.0 :c2y -2.0
|
||||
:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 20.0}}])
|
||||
result (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)})]
|
||||
(t/is (= (vec content) (vec result)))))
|
||||
|
||||
(t/deftest path-separate-nodes-after-merge-yields-one-end-per-line
|
||||
;; A node with two visible lines separates into two ends.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 10.0}}
|
||||
{:command :curve-to :params {:c1x 20.0 :c1y 10.0
|
||||
:c2x 10.0 :c2y 5.0
|
||||
:x 10.0 :y 5.0}}
|
||||
{:command :close-path :params {}}])
|
||||
node (gpt/point 10.0 5.0)
|
||||
merged (path/merge-coincident-nodes content #{node})
|
||||
result (vec (path/separate-nodes merged #{node} (gpt/point 4.0 4.0)))]
|
||||
(t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 5.0}
|
||||
{:x 14.0 :y 9.0} {:x 20.0 :y 10.0}]
|
||||
(mapv #(select-keys (:params %) [:x :y]) result)))))
|
||||
|
||||
(t/deftest path-duplicate-node-content
|
||||
;; Duplicating a node copies its incident segments as subpaths.
|
||||
(let [content (path/content
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
;; 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 common-tests.types-shape-tree-test
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(defn- make-frame
|
||||
[id parent-id shapes x y width height show-content]
|
||||
{:id id
|
||||
:type :frame
|
||||
:parent-id parent-id
|
||||
:frame-id parent-id
|
||||
:shapes (vec shapes)
|
||||
:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height
|
||||
:rotation nil
|
||||
:hidden false
|
||||
:blocked false
|
||||
:show-content show-content})
|
||||
|
||||
(t/deftest top-nested-frame-clip-content-test
|
||||
(t/testing "board A (clip) contains a wider board B; point inside both resolves to B"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 150 150)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
(t/is (= b-id result))))
|
||||
|
||||
(t/testing "point inside B but outside A's clipped bounds is not reachable at all"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 300 300)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
;; Outside A (the clip ancestor) and B's visible/clipped region there is
|
||||
;; not visible either, so no frame should be resolved at that point.
|
||||
(t/is (= uuid/zero result))))
|
||||
|
||||
(t/testing "with show-content true on A, the same point can resolve into B"
|
||||
(let [a-id (uuid/next)
|
||||
b-id (uuid/next)
|
||||
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 true)
|
||||
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
|
||||
position (gpt/point 300 300)
|
||||
result (ctt/top-nested-frame objects position)]
|
||||
(t/is (= b-id result)))))
|
||||
@@ -626,3 +626,92 @@ test("Renders background blur clipped by a board with clip content", async ({
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Clips a group dragged into a board with clip content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-shapes-groups-boards.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
|
||||
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
// Select the group, then drag it so it straddles the right edge of the
|
||||
// board. The overflow must not be painted while the pointer is still down.
|
||||
await workspace.viewport.hover({ position: { x: 1028, y: 548 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await page.mouse.down();
|
||||
for (const [x, y] of [
|
||||
[1000, 540],
|
||||
[920, 530],
|
||||
[830, 520],
|
||||
]) {
|
||||
await workspace.viewport.hover({ position: { x, y } });
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Clips a group dragged inside a board with clip content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-shapes-groups-boards.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "53a7ff09-2228-81d3-8006-4b5eac177245",
|
||||
pageId: "53a7ff09-2228-81d3-8006-4b5eac177246",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await workspace.viewport.hover({ position: { x: 1028, y: 548 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Drop the group inside the board so it becomes one of its children.
|
||||
await page.mouse.down();
|
||||
for (const [x, y] of [
|
||||
[900, 540],
|
||||
[700, 520],
|
||||
]) {
|
||||
await workspace.viewport.hover({ position: { x, y } });
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
await page.waitForTimeout(600);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Drag it towards the right edge, now as a board child.
|
||||
await page.mouse.down();
|
||||
for (const [x, y] of [
|
||||
[750, 520],
|
||||
[830, 520],
|
||||
]) {
|
||||
await workspace.viewport.hover({ position: { x, y } });
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -653,3 +653,32 @@ test("Renders background blur on text shapes", async ({ page }) => {
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Flattens texts to paths", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-text-flatten.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
|
||||
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
const flattenButton = workspace.page.getByRole("button", {
|
||||
name: "Flatten",
|
||||
exact: true,
|
||||
});
|
||||
|
||||
for (const layer of ["Flat spacing", "Flat paragraph", "Flat centered"]) {
|
||||
await workspace.clickLeafLayer(layer);
|
||||
const renderCount = await workspace.getRenderCount();
|
||||
await flattenButton.click();
|
||||
await workspace.waitForNextRender(renderCount);
|
||||
}
|
||||
|
||||
await workspace.page.keyboard.press("Escape");
|
||||
await workspace.hideUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 });
|
||||
});
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
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
|
||||
|
||||
|
||||
@@ -333,13 +333,16 @@
|
||||
[:stroke-style
|
||||
:stroke-alignment
|
||||
:stroke-width
|
||||
:stroke-dash
|
||||
:stroke-gap
|
||||
:stroke-per-side
|
||||
:stroke-width-top
|
||||
:stroke-width-right
|
||||
:stroke-width-bottom
|
||||
:stroke-width-left
|
||||
:stroke-cap-start
|
||||
:stroke-cap-end])
|
||||
:stroke-cap-end
|
||||
:hidden])
|
||||
|
||||
;; FIXME: this function initializes an empty stroke, maybe we can move
|
||||
;; it to common.types
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
(defn clean-edit-state
|
||||
[state]
|
||||
(dissoc state :last-point :prev-handler :drag-handler :preview))
|
||||
(dissoc state :last-point :prev-handler :drag-handler :preview :pending-start))
|
||||
|
||||
(defn- drop-trailing-move-to
|
||||
"Drops a trailing subpath start without segments."
|
||||
|
||||
@@ -113,7 +113,9 @@
|
||||
(update [_ state]
|
||||
(let [id (st/get-path-id state)
|
||||
fix-angle? shift?
|
||||
{:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id])
|
||||
{:keys [last-point prev-handler pending-start]}
|
||||
(get-in state [:workspace-local :edit-path id])
|
||||
|
||||
position (cond-> (gpt/point x y)
|
||||
fix-angle? (path.helpers/position-fixed-angle last-point))]
|
||||
(if-not (= last-point position)
|
||||
@@ -121,6 +123,9 @@
|
||||
(assoc-in [:workspace-local :edit-path id :last-point] position)
|
||||
(update-in [:workspace-local :edit-path id] dissoc :prev-handler)
|
||||
(update-in [:workspace-local :edit-path id] dissoc :preview)
|
||||
(update-in [:workspace-local :edit-path id] dissoc :pending-start)
|
||||
(cond-> (some? pending-start)
|
||||
(update-in (st/get-path-location state) helpers/start-subpath pending-start))
|
||||
(update-in (st/get-path-location state) helpers/append-node position last-point prev-handler))
|
||||
state)))))
|
||||
|
||||
@@ -398,16 +403,19 @@
|
||||
(cond-> (some? drop-index)
|
||||
(with-meta {:index drop-index})))))))))
|
||||
|
||||
(defn- close-drawn-loops
|
||||
"Adds explicit close commands to completed loops."
|
||||
(defn- clean-drawn-content
|
||||
"Collapses the nodes drawn on top of each other and closes completed loops.
|
||||
|
||||
Clicking a node already in the path draws its segments again backwards, and
|
||||
only one copy of each line is kept."
|
||||
[]
|
||||
(ptk/reify ::close-drawn-loops
|
||||
(ptk/reify ::clean-drawn-content
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(d/update-in-when state [:workspace-drawing :object]
|
||||
(fn [object]
|
||||
(-> object
|
||||
(update :content path/close-loops)
|
||||
(update :content path/merge-coincident-nodes)
|
||||
(path/update-geometry)))))))
|
||||
|
||||
(defn- handle-drawing-end
|
||||
@@ -427,13 +435,13 @@
|
||||
(cond
|
||||
(and (> (count content) 1) restart?)
|
||||
(rx/of (common/finish-path)
|
||||
(close-drawn-loops)
|
||||
(clean-drawn-content)
|
||||
(setup-frame)
|
||||
(dwdc/handle-finish-drawing)
|
||||
(start-created-path-edition shape-id))
|
||||
|
||||
(> (count content) 1)
|
||||
(rx/of (close-drawn-loops)
|
||||
(rx/of (clean-drawn-content)
|
||||
(setup-frame)
|
||||
(dwdc/handle-finish-drawing)
|
||||
(dwe/clear-edition-mode))
|
||||
@@ -529,16 +537,11 @@
|
||||
pos (helpers/node-position content index)
|
||||
last-idx (dec (count content))
|
||||
tip? (and (= index last-idx)
|
||||
(not= :close-path (:command (nth content index nil))))
|
||||
state (assoc-in state [:workspace-local :edit-path id :last-point] pos)]
|
||||
(if tip?
|
||||
state
|
||||
(update-in state (st/get-path-location state)
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(update :content path/append-segment
|
||||
{:command :move-to :params (select-keys pos [:x :y])})
|
||||
(path/update-geometry))))))
|
||||
(not= :close-path (:command (nth content index nil))))]
|
||||
(cond-> (assoc-in state [:workspace-local :edit-path id :last-point] pos)
|
||||
;; A tip already ends the content; an inner node needs its own start.
|
||||
(not tip?)
|
||||
(assoc-in [:workspace-local :edit-path id :pending-start] pos)))
|
||||
state)))
|
||||
|
||||
(defn change-edit-mode
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
(-> state
|
||||
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
|
||||
(assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler)
|
||||
(assoc-in [:workspace-local :edit-path id :edited-handler] primary)
|
||||
(cond-> (some? new-prev-handler)
|
||||
(assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler)))))))
|
||||
|
||||
@@ -286,7 +287,10 @@
|
||||
content-modifiers))]
|
||||
|
||||
(-> state
|
||||
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
|
||||
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)
|
||||
(cond-> (= 1 (count handler-ids))
|
||||
(assoc-in [:workspace-local :edit-path id :edited-handler]
|
||||
(first handler-ids))))))))
|
||||
|
||||
(defn- move-node-indices
|
||||
[state node-indices from-point to-point]
|
||||
@@ -466,6 +470,7 @@
|
||||
(rx/map #(move-selected-path-point start-position %))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(merge-dragged-on-drop)))))))
|
||||
|
||||
(declare drag-selected-segments)
|
||||
@@ -555,6 +560,7 @@
|
||||
(rx/map #(move-selected-path-segment start-position %))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(merge-dragged-on-drop))))))))
|
||||
|
||||
(defn bend-segment-modifier
|
||||
@@ -753,6 +759,7 @@
|
||||
(rx/of (move-selected direction shift?)))
|
||||
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(finish-move-selected))))
|
||||
(rx/empty)))))))
|
||||
|
||||
@@ -946,7 +953,7 @@
|
||||
(ptk/data-event :layout/update {:ids [id]})))))
|
||||
|
||||
(defn- split-segments
|
||||
[_id {:keys [from-p to-p t]}]
|
||||
[id {:keys [from-p to-p t]}]
|
||||
(ptk/reify ::split-segments
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -955,7 +962,9 @@
|
||||
(st/set-content (-> content
|
||||
(path/split-segments #{from-p to-p} t)
|
||||
(path/content)))
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
(update-in (st/get-path-location state) path/update-geometry)
|
||||
;; The inserted command shifts the indices a handler id refers to.
|
||||
(update-in [:workspace-local :edit-path id] dissoc :edited-handler))))))
|
||||
|
||||
(defn create-node-at-position
|
||||
[params]
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.path.helpers :as path.helpers]))
|
||||
|
||||
(defn start-subpath
|
||||
"Adds the subpath start a pending node draws its first segment from."
|
||||
[shape position]
|
||||
(update shape :content path/append-segment
|
||||
{:command :move-to :params (select-keys position [:x :y])}))
|
||||
|
||||
(defn append-node
|
||||
"Creates a new node in the path. Usually used when drawing."
|
||||
[shape position prev-point prev-handler]
|
||||
@@ -180,6 +186,36 @@
|
||||
|
||||
:else nil)))
|
||||
|
||||
(defn node-handler-ids
|
||||
"Returns a node's curve handlers, its primary handle first."
|
||||
[content node-index]
|
||||
(if-let [[index prefix :as primary] (node-primary-handler content node-index)]
|
||||
(let [[op-idx op-prefix] (path/opposite-index content index prefix)]
|
||||
(if (some? op-idx)
|
||||
[primary [op-idx op-prefix]]
|
||||
[primary]))
|
||||
[]))
|
||||
|
||||
(defn handler-type-reference
|
||||
"Returns the handler that keeps its geometry when a node's handler type changes.
|
||||
|
||||
The other handler adapts to it. Priority: the node's only selected handler,
|
||||
then `edited-handler` when it is one of this node's two handlers, then its
|
||||
primary handle. `edited-handler` is the last handler edited anywhere in the
|
||||
path, so a node only gets this hint while it holds the latest edit."
|
||||
[content selection edited-handler node-index]
|
||||
(let [handler-ids (node-handler-ids content node-index)
|
||||
selected (filterv (get selection :handlers #{}) handler-ids)]
|
||||
(cond
|
||||
(= 1 (count selected))
|
||||
(first selected)
|
||||
|
||||
(some #{edited-handler} handler-ids)
|
||||
edited-handler
|
||||
|
||||
:else
|
||||
(first handler-ids))))
|
||||
|
||||
(defn handlers-equal-length?
|
||||
"True when a node's two handlers are the same distance from the node."
|
||||
[content index prefix]
|
||||
@@ -301,17 +337,33 @@
|
||||
(remove nil?))
|
||||
(segment-entries content))))
|
||||
|
||||
(defn coincident-node-indices
|
||||
"Adds to `indices` every other command sharing one of their positions.
|
||||
|
||||
Commands at the same position are one node: they move together, so an
|
||||
action cannot depend on which of them the selection holds."
|
||||
[content indices]
|
||||
(let [indices (into #{} (filter #(node? content %)) indices)]
|
||||
(into indices
|
||||
(mapcat #(path/point-indices content %))
|
||||
(node-positions content indices))))
|
||||
|
||||
(defn selected-node-count
|
||||
"Number of nodes in the selection, counting coincident commands as one."
|
||||
[content selection]
|
||||
(count (node-positions content (get selection :nodes #{}))))
|
||||
|
||||
(defn check-enabled
|
||||
"Returns path actions enabled for selected node indices."
|
||||
[content selected-nodes]
|
||||
(when content
|
||||
(let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes)
|
||||
(let [selected-nodes (coincident-node-indices content selected-nodes)
|
||||
selected-segments (filter (fn [{:keys [from-index to-index]}]
|
||||
(and (contains? selected-nodes from-index)
|
||||
(contains? selected-nodes to-index)))
|
||||
(segment-entries content))
|
||||
num-segments (count selected-segments)
|
||||
num-nodes (count selected-nodes)
|
||||
num-nodes (count (node-positions content selected-nodes))
|
||||
nodes-selected? (seq selected-nodes)
|
||||
segments-selected? (seq selected-segments)
|
||||
max-segments (/ (* num-nodes (dec num-nodes)) 2)
|
||||
@@ -523,6 +575,12 @@
|
||||
(let [selection (or selection empty-selection)]
|
||||
(if (= (count old-content) (count new-content))
|
||||
(-> selection
|
||||
;; Drop indices that stopped being nodes.
|
||||
(update :nodes
|
||||
(fn [nodes]
|
||||
(into #{}
|
||||
(filter #(node? new-content %))
|
||||
nodes)))
|
||||
(update :handlers
|
||||
(fn [handlers]
|
||||
(into #{}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
(update-in [:workspace-local :edit-path id :selection]
|
||||
#(helpers/remap-selection % old-content new-content))
|
||||
(update-in [:workspace-local :edit-path id :handler-types]
|
||||
#(helpers/remap-handler-types % old-content new-content))))
|
||||
#(helpers/remap-handler-types % old-content new-content))
|
||||
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))
|
||||
state)))
|
||||
|
||||
ptk/WatchEvent
|
||||
@@ -80,9 +81,11 @@
|
||||
(reduce path/make-curve-point content))))))
|
||||
|
||||
(defn- apply-handler-type-modifiers
|
||||
"Returns modifiers that reshape a node's handlers to `type`."
|
||||
[content node-index type]
|
||||
(if-let [[idx prefix] (helpers/node-primary-handler content node-index)]
|
||||
"Returns modifiers that reshape a node's handlers to `type`.
|
||||
|
||||
`reference` keeps its geometry; the opposite handler adapts to it."
|
||||
[content reference type]
|
||||
(if-let [[idx prefix] reference]
|
||||
(case type
|
||||
:mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0)
|
||||
:aligned (helpers/align-handler-modifiers content idx prefix 0 0)
|
||||
@@ -98,10 +101,13 @@
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
selection (st/get-selection state id)
|
||||
edited (dm/get-in state [:workspace-local :edit-path id :edited-handler])
|
||||
nodes (helpers/handler-target-nodes content selection)]
|
||||
(if (and (some? content) (seq nodes))
|
||||
(let [modifiers (reduce (fn [acc node-index]
|
||||
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
|
||||
(let [reference (helpers/handler-type-reference
|
||||
content selection edited node-index)]
|
||||
(d/deep-merge acc (apply-handler-type-modifiers content reference type))))
|
||||
{} nodes)
|
||||
new-content (path/apply-content-modifiers content modifiers)]
|
||||
(-> (st/set-content state new-content)
|
||||
@@ -136,17 +142,22 @@
|
||||
(make-curve point)))))))))
|
||||
|
||||
(defn- update-path-content
|
||||
"Updates path content, geometry, selection, and handler types."
|
||||
[state new-content]
|
||||
(let [id (st/get-path-id state)
|
||||
old-content (st/get-path state :content)]
|
||||
(-> (cond-> (st/set-content state new-content)
|
||||
(seq new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))
|
||||
(update-in [:workspace-local :edit-path id :selection]
|
||||
#(helpers/remap-selection % old-content new-content))
|
||||
(update-in [:workspace-local :edit-path id :handler-types]
|
||||
#(helpers/remap-handler-types % old-content new-content)))))
|
||||
"Updates path content, geometry, selection, and handler types.
|
||||
|
||||
The selection is remapped by position, so a tool that moves nodes before
|
||||
changing the content structure passes the moved content as `old-content`."
|
||||
([state new-content]
|
||||
(update-path-content state (st/get-path state :content) new-content))
|
||||
([state old-content new-content]
|
||||
(let [id (st/get-path-id state)]
|
||||
(-> (cond-> (st/set-content state new-content)
|
||||
(seq new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))
|
||||
(update-in [:workspace-local :edit-path id :selection]
|
||||
#(helpers/remap-selection % old-content new-content))
|
||||
(update-in [:workspace-local :edit-path id :handler-types]
|
||||
#(helpers/remap-handler-types % old-content new-content))
|
||||
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))))
|
||||
|
||||
(defn remove-segments
|
||||
"Removes segments and opens the path at their endpoints."
|
||||
@@ -227,6 +238,30 @@
|
||||
(defn merge-nodes []
|
||||
(process-path-tool path/merge-nodes))
|
||||
|
||||
(defn- merge-coincident
|
||||
"Collapses the nodes of `indices` sharing a position with another node."
|
||||
[content indices]
|
||||
(path/merge-coincident-nodes content (helpers/node-positions content indices)))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Merges the selected nodes sharing a position with another node.
|
||||
|
||||
Runs after a move, which leaves the nodes it brings together as one
|
||||
command each."
|
||||
[]
|
||||
(ptk/reify ::merge-coincident-nodes
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
indices (helpers/selected-node-indices content (st/get-selection state id))
|
||||
new-content (when (and (some? content) (seq indices))
|
||||
(merge-coincident content indices))]
|
||||
(if (and (some? new-content)
|
||||
(not= (vec new-content) (vec content)))
|
||||
(update-path-content state new-content)
|
||||
state)))))
|
||||
|
||||
(defn join-nodes []
|
||||
(process-path-tool path/join-nodes))
|
||||
|
||||
@@ -281,9 +316,8 @@
|
||||
indices (if (seq selected)
|
||||
selected
|
||||
(helpers/node-indices content))
|
||||
content (path/flip-content content indices axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
flipped (path/flip-content content indices axis)]
|
||||
(update-path-content state flipped (merge-coincident flipped indices))))))
|
||||
|
||||
(defn align-nodes
|
||||
"Aligns selected nodes and their handles within their bounds."
|
||||
@@ -294,9 +328,8 @@
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
selected (get (st/get-selection state id) :nodes #{})
|
||||
content (path/align-content content selected axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
aligned (path/align-content content selected axis)]
|
||||
(update-path-content state aligned (merge-coincident aligned selected))))))
|
||||
|
||||
(defn distribute-nodes
|
||||
"Distributes selected nodes evenly along `axis`."
|
||||
@@ -307,9 +340,8 @@
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
selected (get (st/get-selection state id) :nodes #{})
|
||||
content (path/distribute-content content selected axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
spread (path/distribute-content content selected axis)]
|
||||
(update-path-content state spread (merge-coincident spread selected))))))
|
||||
|
||||
(defn- axis-point
|
||||
"Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`."
|
||||
@@ -379,8 +411,8 @@
|
||||
(cond-> content
|
||||
(seq node-idx) (path/set-nodes-coordinate node-idx axis value)
|
||||
(seq pts) (path/set-handler-points pts))))]
|
||||
(-> (st/set-content state new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
(update-path-content state new-content
|
||||
(merge-coincident new-content node-idx))))))
|
||||
|
||||
(defn toggle-snap []
|
||||
(ptk/reify ::toggle-snap
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
(def ^:private telemetry-origin
|
||||
"check-updates-modal")
|
||||
|
||||
(def ^:private highlights-md-url
|
||||
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md")
|
||||
(def ^:private changelog-md-url
|
||||
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/CHANGES.md")
|
||||
|
||||
(def ^:private changelog-url
|
||||
"https://github.com/penpot/penpot/blob/staging/CHANGES.md")
|
||||
"https://github.com/penpot/penpot/blob/main/CHANGES.md")
|
||||
|
||||
(def ^:private release-notes-url
|
||||
"https://penpot.app/release-notes")
|
||||
@@ -44,6 +44,9 @@
|
||||
(def ^:private bullet-re
|
||||
#"^- (.+)$")
|
||||
|
||||
(def ^:private rocket-heading-re
|
||||
#"(?m)^### :rocket: Epics and highlights\s*$")
|
||||
|
||||
(defn- unreleased-suffix?
|
||||
[suffix]
|
||||
(str/includes? (str/lower (or suffix "")) "unreleased"))
|
||||
@@ -56,9 +59,54 @@
|
||||
item)))
|
||||
vec))
|
||||
|
||||
(defn- extract-rocket-items
|
||||
"Given a version section body, find the :rocket: subsection and
|
||||
extract its bullet items. Returns nil if no :rocket: or empty."
|
||||
[version-body]
|
||||
(when-let [[_ rocket-body] (str/split version-body rocket-heading-re 2)]
|
||||
(let [subsection (-> (str/split rocket-body #"(?m)(?=^#{2,3}\s)") first)]
|
||||
(when subsection
|
||||
(let [items (parse-section-items subsection)]
|
||||
(when (seq items) items))))))
|
||||
|
||||
(def ^:private inline-md-re
|
||||
#"\[([^\]]+)\]\(((?:\([^)]*\)|[^)\s])*)\)|\*\*([^*]+)\*\*")
|
||||
|
||||
(defn- http-url?
|
||||
[url]
|
||||
(boolean (re-matches #"https?://.*" (or url ""))))
|
||||
|
||||
(defn parse-highlight-item
|
||||
"Parse one highlight string with inline markdown (links, bold) into a
|
||||
vector of {:type :text/:link/:bold, :text ..., :href ...} descriptors.
|
||||
Anything unrecognized degrades to :text. Total over strings; nil and
|
||||
empty input return []."
|
||||
[text]
|
||||
(if (or (not (string? text)) (= "" text))
|
||||
[]
|
||||
(loop [out [] s text]
|
||||
(if (= "" s)
|
||||
out
|
||||
(if-let [[m link-text link-href bold-text] (re-find inline-md-re s)]
|
||||
(let [idx (cstr/index-of s m)
|
||||
before (subs s 0 idx)
|
||||
after (subs s (+ idx (count m)))
|
||||
out (if (= "" before) out (conj out {:type :text :text before}))]
|
||||
(cond
|
||||
(some? link-text)
|
||||
(if (http-url? link-href)
|
||||
(recur (conj out {:type :link :text link-text :href link-href}) after)
|
||||
(recur (conj out {:type :text :text m}) after))
|
||||
|
||||
:else
|
||||
(recur (conj out {:type :bold :text bold-text}) after)))
|
||||
(conj out {:type :text :text s}))))))
|
||||
|
||||
(defn parse-highlights
|
||||
"Parse HIGHLIGHTS.md into released version sections with bullet items.
|
||||
Skips Unreleased headings. Preserves file order (newest first)."
|
||||
"Parse CHANGES.md into released version sections with bullet items from
|
||||
the :rocket: Epics and highlights subsection. Skips Unreleased headings,
|
||||
versions without a :rocket: section, and versions with an empty one.
|
||||
Preserves file order (newest first)."
|
||||
[markdown]
|
||||
(if-not (string? markdown)
|
||||
[]
|
||||
@@ -66,14 +114,19 @@
|
||||
(keep (fn [part]
|
||||
(when-let [[_ version suffix] (re-find version-heading-re part)]
|
||||
(when-not (unreleased-suffix? suffix)
|
||||
{:version version
|
||||
:items (parse-section-items part)}))))
|
||||
(when-let [items (extract-rocket-items part)]
|
||||
{:version version
|
||||
:items items})))))
|
||||
vec)))
|
||||
|
||||
(defn parse-latest-released-version
|
||||
"Return the first non-unreleased `## X.Y.Z` heading from a highlights body."
|
||||
"Return the first non-unreleased `## X.Y.Z` heading from a CHANGES.md body."
|
||||
[markdown]
|
||||
(some-> (parse-highlights markdown) first :version))
|
||||
(when (string? markdown)
|
||||
(some->> (re-seq version-heading-re markdown)
|
||||
(keep (fn [[_ version suffix]]
|
||||
(when-not (unreleased-suffix? suffix) version)))
|
||||
first)))
|
||||
|
||||
(defn highlights-until-installed
|
||||
"Keep released sections newer than the installed version (major, minor,
|
||||
@@ -101,8 +154,8 @@
|
||||
|
||||
(defn- handle-highlights
|
||||
[installed body]
|
||||
(let [sections (parse-highlights body)
|
||||
latest (some-> sections first :version)]
|
||||
(let [latest (parse-latest-released-version body)
|
||||
sections (parse-highlights body)]
|
||||
(cond
|
||||
(nil? latest)
|
||||
(show-unable-dialog)
|
||||
@@ -124,7 +177,7 @@
|
||||
(->> (http/send! {:method :get
|
||||
:mode :cors
|
||||
:omit-default-headers true
|
||||
:uri highlights-md-url
|
||||
:uri changelog-md-url
|
||||
:response-type :text})
|
||||
(rx/subs!
|
||||
(fn [response]
|
||||
@@ -280,23 +333,37 @@
|
||||
:class (stl/css :modal-msg)}
|
||||
(tr "dashboard.check-updates.available-message")]
|
||||
|
||||
[:> text* {:as "h3"
|
||||
:typography t/headline-small
|
||||
:class (stl/css :highlights-title)}
|
||||
(tr "dashboard.check-updates.highlights-title")]
|
||||
(when (seq highlights)
|
||||
[:*
|
||||
[:> text* {:as "h3"
|
||||
:typography t/headline-small
|
||||
:class (stl/css :highlights-title)}
|
||||
(tr "dashboard.check-updates.highlights-title")]
|
||||
|
||||
[:div {:class (stl/css :highlights-scroll)}
|
||||
(for [section highlights]
|
||||
(let [version (:version section)
|
||||
items (:items section)]
|
||||
[:div {:key version
|
||||
:class (stl/css :highlights-section)}
|
||||
[:div {:class (stl/css :highlights-version)} version]
|
||||
[:ul {:class (stl/css :highlights-list)}
|
||||
(for [item items]
|
||||
[:li {:key item
|
||||
:class (stl/css :highlights-item)}
|
||||
item])]]))]]
|
||||
[:div {:class (stl/css :highlights-scroll)}
|
||||
(for [section highlights]
|
||||
(let [version (:version section)
|
||||
items (:items section)]
|
||||
[:div {:key version
|
||||
:class (stl/css :highlights-section)}
|
||||
[:div {:class (stl/css :highlights-version)} version]
|
||||
[:ul {:class (stl/css :highlights-list)}
|
||||
(for [item items]
|
||||
[:li {:key item
|
||||
:class (stl/css :highlights-item)}
|
||||
(for [[idx frag] (map-indexed vector (parse-highlight-item item))]
|
||||
(case (:type frag)
|
||||
:link
|
||||
[:a {:key idx
|
||||
:href (:href frag)
|
||||
:target "_blank"
|
||||
:rel "noopener noreferrer"}
|
||||
(:text frag)]
|
||||
|
||||
:bold
|
||||
[:strong {:key idx} (:text frag)]
|
||||
|
||||
(:text frag)))])]]))]])]
|
||||
|
||||
[:div {:class (stl/css :modal-footer :modal-footer-available)}
|
||||
[:> button* {:variant "secondary"
|
||||
|
||||
@@ -162,6 +162,20 @@
|
||||
border-radius: $br-circle;
|
||||
background-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-foreground-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
|
||||
@@ -216,8 +216,7 @@
|
||||
(fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(dom/stop-propagation event)
|
||||
(on-menu-click event))))
|
||||
title-width (/ 100 limit)]
|
||||
(on-menu-click event))))]
|
||||
|
||||
[:article {:class (stl/css-case :dashboard-project-row true :first is-first)}
|
||||
[:header {:class (stl/css :project)}
|
||||
@@ -227,7 +226,6 @@
|
||||
:on-end on-edit
|
||||
:max-length 250}]
|
||||
[:h2 {:on-click on-nav
|
||||
:style {:max-width (str title-width "%")}
|
||||
:class (stl/css :project-name)
|
||||
:title (if (:is-default project)
|
||||
(tr "labels.drafts")
|
||||
|
||||
@@ -92,11 +92,14 @@
|
||||
block-size: $sz-16;
|
||||
line-height: 0.8;
|
||||
margin-inline-end: var(--sp-m);
|
||||
flex: 0 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (ev/event {::ev/name "open-current-subscription"
|
||||
::ev/origin "dashboard:plan-confirmation-modal"}))))
|
||||
::ev/origin "dashboard:plan-confirmation-modal"}))
|
||||
(modal/hide!)))
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
|
||||
@@ -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")))
|
||||
@@ -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 SUBSIDIARY 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;
|
||||
}
|
||||
@@ -4,28 +4,29 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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);
|
||||
}
|
||||
@@ -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"]]]]]])))
|
||||
@@ -4,101 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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"]]]]]])))
|
||||
@@ -4,80 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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]))
|
||||
|
||||
@@ -44,8 +45,10 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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]))
|
||||
|
||||
@@ -44,8 +45,10 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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 SUBSIDIARY 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)}
|
||||
"What’s 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)}
|
||||
"Let’s 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 organization’s teams and files, governed by the directory you already run. From the Admin Console, you’ll 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 SUBSIDIARY SL
|
||||
|
||||
@use "base";
|
||||
@@ -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"]]]]]])))
|
||||
@@ -4,80 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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. Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS SUBSIDIARY 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";
|
||||
@@ -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 @@
|
||||
"Let’s 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"]]]]]])))
|
||||