Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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
|
||||
|
||||
@@ -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,9 @@
|
||||
- **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`.
|
||||
- **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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -7,14 +7,11 @@
|
||||
(ns app.rpc.commands.viewer
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.fdata :as fdata]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
@@ -25,81 +22,12 @@
|
||||
|
||||
;; --- QUERY: View Only Bundle
|
||||
|
||||
(def ^:private view-only-data-keys
|
||||
"Minimal `:data` keys a view-only bundle exposes, for the primary file
|
||||
and for each linked library alike. Keep both scopes on this single
|
||||
definition so they cannot drift apart."
|
||||
[:id :options :pages :pages-index :components])
|
||||
|
||||
(defn- remove-not-allowed-pages
|
||||
[data allowed]
|
||||
(-> data
|
||||
(update :pages (fn [pages] (filterv #(contains? allowed %) pages)))
|
||||
(update :pages-index select-keys allowed)))
|
||||
|
||||
(defn- find-library-refs
|
||||
"Return `{lib-id #{component-id}}` referenced by the given shapes."
|
||||
[shapes]
|
||||
(reduce (fn [acc shape]
|
||||
(if (and (map? shape)
|
||||
(some? (:component-file shape))
|
||||
(some? (:component-id shape)))
|
||||
(update acc (:component-file shape) (fnil conj #{}) (:component-id shape))
|
||||
acc))
|
||||
{}
|
||||
shapes))
|
||||
|
||||
(defn- component-shapes
|
||||
"Shapes owned by a library component: the subtree rooted at its main
|
||||
instance. Stored components carry no `:objects`; they point at the main
|
||||
instance through `:main-instance-id`/`:main-instance-page` instead. Looks
|
||||
the page up in the component's own library first and in any other loaded
|
||||
library as fallback (cloned or moved components); ties break by library
|
||||
id order so the fallback is deterministic. Returns nil when the main
|
||||
instance cannot be resolved."
|
||||
[libs-by-id lib-id {:keys [main-instance-id main-instance-page]}]
|
||||
(when (and (some? main-instance-id) (some? main-instance-page))
|
||||
(let [objects (or (get-in libs-by-id [lib-id :data :pages-index main-instance-page :objects])
|
||||
(some (fn [[_ lib]]
|
||||
(get-in lib [:data :pages-index main-instance-page :objects]))
|
||||
(sort-by key libs-by-id)))]
|
||||
(when (and (some? objects) (some? (get objects main-instance-id)))
|
||||
(cfh/get-children-with-self objects main-instance-id)))))
|
||||
|
||||
(defn- collect-used-library-components
|
||||
"Walk the objects of the (already page-scoped) primary file data and of
|
||||
the referenced library components themselves, and return a map of
|
||||
`{lib-id #{component-id}}` with every library component the allowed
|
||||
pages need. Follows nested references to a fixpoint so trimming never
|
||||
drops a component that is only reached through another component."
|
||||
[primary-data libs-by-id]
|
||||
(let [root-shapes (concat (mapcat (comp vals :objects) (vals (:pages-index primary-data)))
|
||||
(mapcat (comp vals :objects) (vals (:components primary-data))))]
|
||||
(loop [seen {}]
|
||||
(let [nested (mapcat (fn [[lib-id comp-ids]]
|
||||
(mapcat #(component-shapes libs-by-id lib-id
|
||||
(get-in libs-by-id [lib-id :data :components %]))
|
||||
comp-ids))
|
||||
seen)
|
||||
merged (merge-with into seen (find-library-refs (concat root-shapes nested)))]
|
||||
(if (= merged seen)
|
||||
seen
|
||||
(recur merged))))))
|
||||
|
||||
(defn- trim-library-data
|
||||
"Reduce a linked library to the minimum a share-link viewer needs: keep
|
||||
the narrow `:data` keys, drop the library's own pages (a share link
|
||||
never grants pages of a library), and keep only the components
|
||||
referenced by the allowed pages of the primary file. Envelope metadata
|
||||
outside `:data` (name, project, sync state) is intentionally preserved:
|
||||
the viewer client needs it and it carries no design content."
|
||||
[used-refs {:keys [id] :as lib}]
|
||||
(-> lib
|
||||
(update :data select-keys view-only-data-keys)
|
||||
(assoc-in [:data :pages] [])
|
||||
(assoc-in [:data :pages-index] {})
|
||||
(update-in [:data :components] select-keys (get used-refs id #{}))))
|
||||
|
||||
(defn obfuscate-email
|
||||
"Obfuscate the `email` for share-link members so the viewer only sees a
|
||||
partially redacted address. Accepts any string shape (including nil,
|
||||
@@ -155,25 +83,12 @@
|
||||
(update :data remove-not-allowed-pages (:pages perms))
|
||||
|
||||
:always
|
||||
(update :data select-keys view-only-data-keys))
|
||||
(update :data select-keys [:id :options :pages :pages-index :components]))
|
||||
|
||||
libs (->> (bfc/get-file-libraries conn file-id)
|
||||
(mapv (fn [{:keys [id] :as lib}]
|
||||
(merge lib (bfc/get-file cfg id)))))
|
||||
|
||||
;; A share link never grants pages of a linked library, so trim
|
||||
;; each library to the components the allowed pages reference.
|
||||
;; File data may hold lazy pointer-mapped pages/components, so
|
||||
;; realize each file with its own loader before walking it.
|
||||
;; Membership bundles keep the full libraries.
|
||||
libs (if (= :share-link (:type perms))
|
||||
(let [file' (fdata/realize-pointers cfg file)
|
||||
libs' (mapv #(fdata/realize-pointers cfg %) libs)
|
||||
libs-by-id (d/index-by :id libs')
|
||||
used (collect-used-library-components (:data file') libs-by-id)]
|
||||
(mapv #(trim-library-data used %) libs'))
|
||||
libs)
|
||||
|
||||
links (cond->> (->> (db/query conn :share-link {:file-id file-id})
|
||||
(mapv (fn [row]
|
||||
(-> row
|
||||
|
||||
@@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
(ns backend-tests.rpc-viewer-test
|
||||
(:require
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
@@ -18,25 +17,6 @@
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private safe-library-envelope-keys
|
||||
"Top-level keys a trimmed library may expose next to `:data`. Anything
|
||||
else is design content leaking through the envelope (see F3)."
|
||||
#{:backend :comment-thread-seqn :created-at :features :has-media-trimmed
|
||||
:id :is-indirect :is-shared :migrations :modified-at :name :project-id
|
||||
:revn :synced-at :team-id :vern :version})
|
||||
|
||||
(defn- update-file!
|
||||
[& {:keys [profile-id file-id changes]}]
|
||||
(let [out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id profile-id
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes changes})]
|
||||
(t/is (nil? (:error out)))
|
||||
(:result out)))
|
||||
|
||||
(t/deftest obfuscate-email-happy-path
|
||||
(t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com")))
|
||||
(t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com")))
|
||||
@@ -225,640 +205,3 @@
|
||||
(t/is (= 2 (count share-links)))
|
||||
(t/is (some #(= link-a-id (:id %)) share-links))
|
||||
(t/is (some #(= link-b-id (:id %)) share-links))))))
|
||||
|
||||
(t/deftest trim-library-data-unit
|
||||
(let [lib-id (uuid/random)
|
||||
comp-a (uuid/random)
|
||||
comp-b (uuid/random)
|
||||
comp-c (uuid/random)
|
||||
page-id (uuid/random)
|
||||
|
||||
mk-shape (fn [id comp] {:id id :type :rect :component-id comp :component-file lib-id})
|
||||
shape-a (mk-shape (uuid/random) comp-a)
|
||||
|
||||
primary {:pages [page-id]
|
||||
:pages-index {page-id {:id page-id :objects {(:id shape-a) shape-a}}}
|
||||
:components {}}
|
||||
|
||||
lib {:id lib-id
|
||||
:synced-at "now"
|
||||
:data {:id lib-id
|
||||
:options {}
|
||||
:pages [page-id]
|
||||
:pages-index {page-id {:id page-id}}
|
||||
:components {comp-a {:id comp-a :name "a" :objects {}}
|
||||
comp-b {:id comp-b :name "b" :objects {}}}}}]
|
||||
|
||||
(t/testing "keeps referenced components and drops library pages"
|
||||
(let [used (#'viewer/collect-used-library-components primary {lib-id lib})
|
||||
trimmed (#'viewer/trim-library-data used lib)]
|
||||
(t/is (= #{comp-a} (get used lib-id)))
|
||||
(t/is (= #{comp-a} (set (keys (get-in trimmed [:data :components])))))
|
||||
(t/is (= [] (get-in trimmed [:data :pages])))
|
||||
(t/is (= {} (get-in trimmed [:data :pages-index])))
|
||||
(t/is (= lib-id (:id trimmed)))
|
||||
(t/is (= "now" (:synced-at trimmed)))))
|
||||
|
||||
(t/testing "no references keeps no components"
|
||||
(let [trimmed (#'viewer/trim-library-data {} lib)]
|
||||
(t/is (= {} (get-in trimmed [:data :components])))
|
||||
(t/is (= [] (get-in trimmed [:data :pages])))))
|
||||
|
||||
(t/testing "follows nested component references through the main instance"
|
||||
;; Stored components carry no `:objects`; nested references resolve
|
||||
;; through the main-instance subtree on the library page.
|
||||
(let [main-used (uuid/random)
|
||||
main-nested (uuid/random)
|
||||
child (assoc (mk-shape (uuid/random) comp-c) :parent-id main-used)
|
||||
lib2 (assoc lib :data
|
||||
{:id lib-id
|
||||
:options {}
|
||||
:pages [page-id]
|
||||
:pages-index {page-id {:id page-id
|
||||
:objects {main-used {:id main-used
|
||||
:type :frame
|
||||
:shapes [(:id child)]}
|
||||
(:id child) child
|
||||
main-nested {:id main-nested
|
||||
:type :frame}}}}
|
||||
:components {comp-a {:id comp-a
|
||||
:name "a"
|
||||
:main-instance-id main-used
|
||||
:main-instance-page page-id}
|
||||
comp-b {:id comp-b
|
||||
:name "b"}
|
||||
comp-c {:id comp-c
|
||||
:name "c"
|
||||
:main-instance-id main-nested
|
||||
:main-instance-page page-id}}})
|
||||
used (#'viewer/collect-used-library-components primary {lib-id lib2})
|
||||
trimmed (#'viewer/trim-library-data used lib2)]
|
||||
(t/is (= #{comp-a comp-c} (get used lib-id)))
|
||||
(t/is (= #{comp-a comp-c} (set (keys (get-in trimmed [:data :components])))))
|
||||
(t/is (not (contains? (get-in trimmed [:data :components]) comp-b)))))))
|
||||
|
||||
(t/deftest share-link-bundle-trims-linked-libraries
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
stranger (th/create-profile* 2 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
lib (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
|
||||
;; A second page that only exists inside the library; the main
|
||||
;; file never references it, so no share link on the main file
|
||||
;; should ever expose it.
|
||||
canary (uuid/random)
|
||||
|
||||
_ (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id owner)
|
||||
:id (:id lib)
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :add-page
|
||||
:id canary
|
||||
:page {:id canary
|
||||
:name "Private canary"
|
||||
:options {}
|
||||
:objects {}}}]})
|
||||
|
||||
file (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id file)
|
||||
:library-id (:id lib)})
|
||||
|
||||
slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{(get-in file [:data :pages 0])}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
slink-id (get-in slink [:result :id])]
|
||||
|
||||
(t/testing "control: non-member cannot read the library directly"
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id stranger)
|
||||
:id (:id lib)
|
||||
:components-v2 true})
|
||||
error-data (ex-data (:error out))]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (= :not-found (:type error-data)))))
|
||||
|
||||
(t/testing "anonymous bundle omits unreferenced library pages"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
:share-id slink-id
|
||||
:file-id (:id file)})
|
||||
result (:result out)
|
||||
lib-pages (into #{} (mapcat #(get-in % [:data :pages] []))
|
||||
(:libraries result))]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (not (contains? lib-pages canary)))
|
||||
(t/is (every? #(= #{:id :options :pages :pages-index :components}
|
||||
(set (keys (:data %))))
|
||||
(:libraries result)))
|
||||
(t/is (every? #(every? safe-library-envelope-keys
|
||||
(keys (dissoc % :data)))
|
||||
(:libraries result)))))))
|
||||
|
||||
(t/deftest share-link-bundle-library-edge-cases
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
add-canary (fn [file n]
|
||||
(let [canary (uuid/random)]
|
||||
(th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id owner)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :add-page
|
||||
:id canary
|
||||
:page {:id canary
|
||||
:name n
|
||||
:options {}
|
||||
:objects {}}}]})
|
||||
canary))
|
||||
|
||||
lib2 (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
canary2 (add-canary lib2 "Private canary 2")
|
||||
|
||||
lib1 (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
canary1 (add-canary lib1 "Private canary 1")
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id lib1)
|
||||
:library-id (:id lib2)})
|
||||
|
||||
file (th/create-file* 3 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id file)
|
||||
:library-id (:id lib1)})
|
||||
|
||||
full (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{(get-in file [:data :pages 0])}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
full-id (get-in full [:result :id])
|
||||
|
||||
empty (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "team"})
|
||||
empty-id (get-in empty [:result :id])
|
||||
|
||||
bundle (fn [& {:as params}]
|
||||
(th/command! (merge {::th/type :get-view-only-bundle
|
||||
:file-id (:id file)}
|
||||
params)))
|
||||
lib-pages (fn [result]
|
||||
(into #{} (mapcat #(get-in % [:data :pages] []))
|
||||
(:libraries result)))]
|
||||
|
||||
(t/testing "indirect libraries are trimmed too"
|
||||
(let [out (bundle :share-id full-id)
|
||||
result (:result out)
|
||||
pages (lib-pages result)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 2 (count (:libraries result))))
|
||||
(t/is (not (contains? pages canary1)))
|
||||
(t/is (not (contains? pages canary2)))))
|
||||
|
||||
(t/testing "empty-pages link exposes no library content"
|
||||
(let [out (bundle :share-id empty-id)
|
||||
result (:result out)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (every? #(= {} (get-in % [:data :components])) (:libraries result)))
|
||||
(t/is (every? #(= [] (get-in % [:data :pages])) (:libraries result)))))
|
||||
|
||||
(t/testing "member bundle keeps full libraries"
|
||||
(let [out (bundle ::rpc/profile-id (:id owner))
|
||||
result (:result out)
|
||||
pages (lib-pages result)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? pages canary1))
|
||||
(t/is (contains? pages canary2))))
|
||||
|
||||
(t/testing "file without libraries returns no libraries"
|
||||
(let [plain (th/create-file* 4 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
link (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id plain)
|
||||
:pages #{(get-in plain [:data :pages 0])}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
out (th/command! {::th/type :get-view-only-bundle
|
||||
:share-id (get-in link [:result :id])
|
||||
:file-id (:id plain)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= [] (:libraries (:result out))))))))
|
||||
|
||||
(t/deftest share-link-bundle-keeps-used-library-components
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
lib (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
lib-page (first (get-in lib [:data :pages]))
|
||||
|
||||
c-nested (uuid/random)
|
||||
c-used (uuid/random)
|
||||
c-unused (uuid/random)
|
||||
n-main (uuid/random)
|
||||
u-main (uuid/random)
|
||||
u-child (uuid/random)
|
||||
x-main (uuid/random)
|
||||
inst (uuid/random)
|
||||
|
||||
frame (fn [id parent frame-id extra]
|
||||
(cts/setup-shape
|
||||
(merge {:id id
|
||||
:name "Board"
|
||||
:frame-id frame-id
|
||||
:parent-id parent
|
||||
:type :frame}
|
||||
extra)))
|
||||
|
||||
;; Nested component, referenced only through c-used.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib-page
|
||||
:id n-main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (frame n-main uuid/zero uuid/zero
|
||||
{:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id c-nested})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "nested"
|
||||
:main-instance-id n-main
|
||||
:main-instance-page lib-page
|
||||
:id c-nested
|
||||
:anotation nil}])
|
||||
|
||||
;; Used component, instancing the nested one.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib-page
|
||||
:id u-main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (frame u-main uuid/zero uuid/zero
|
||||
{:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id c-used})}
|
||||
{:type :add-obj
|
||||
:page-id lib-page
|
||||
:id u-child
|
||||
:parent-id u-main
|
||||
:frame-id u-main
|
||||
:components-v2 true
|
||||
:obj (frame u-child u-main u-main
|
||||
{:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id c-nested})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "used"
|
||||
:main-instance-id u-main
|
||||
:main-instance-page lib-page
|
||||
:id c-used
|
||||
:anotation nil}])
|
||||
|
||||
;; Unreferenced component, never instanced anywhere.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib-page
|
||||
:id x-main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (frame x-main uuid/zero uuid/zero
|
||||
{:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id c-unused})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "unused"
|
||||
:main-instance-id x-main
|
||||
:main-instance-page lib-page
|
||||
:id c-unused
|
||||
:anotation nil}])
|
||||
|
||||
file (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
main-page (first (get-in file [:data :pages]))
|
||||
|
||||
;; Instance the used component on the main file.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:changes [{:type :add-obj
|
||||
:page-id main-page
|
||||
:id inst
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (frame inst uuid/zero uuid/zero
|
||||
{:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id c-used})}])
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id file)
|
||||
:library-id (:id lib)})
|
||||
|
||||
slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{main-page}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
slink-id (get-in slink [:result :id])]
|
||||
|
||||
(t/testing "anonymous bundle keeps referenced components and drops the rest"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
:share-id slink-id
|
||||
:file-id (:id file)})
|
||||
result (:result out)
|
||||
libs (:libraries result)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (count libs)))
|
||||
(t/is (= #{c-used c-nested}
|
||||
(set (keys (get-in (first libs) [:data :components])))))
|
||||
(t/is (= [] (get-in (first libs) [:data :pages])))))))
|
||||
|
||||
(t/deftest share-link-bundle-drops-components-from-disallowed-pages
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
lib (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
lib-page (first (get-in lib [:data :pages]))
|
||||
|
||||
comp (uuid/random)
|
||||
main (uuid/random)
|
||||
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib-page
|
||||
:id main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id main
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id comp})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "Board"
|
||||
:main-instance-id main
|
||||
:main-instance-page lib-page
|
||||
:id comp
|
||||
:anotation nil}])
|
||||
|
||||
file (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
page-a (first (get-in file [:data :pages]))
|
||||
page-b (uuid/random)
|
||||
inst (uuid/random)
|
||||
|
||||
;; Second page, outside the share link scope.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:changes [{:type :add-page
|
||||
:id page-b
|
||||
:page {:id page-b
|
||||
:name "Hidden"
|
||||
:options {}
|
||||
:objects {}}}])
|
||||
|
||||
;; Instance the library component only on the disallowed page.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:changes [{:type :add-obj
|
||||
:page-id page-b
|
||||
:id inst
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id inst
|
||||
:name "Board"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id lib)
|
||||
:component-id comp})}])
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id file)
|
||||
:library-id (:id lib)})
|
||||
|
||||
slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-a}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})]
|
||||
|
||||
(t/testing "references from disallowed pages seed nothing"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
:share-id (get-in slink [:result :id])
|
||||
:file-id (:id file)})
|
||||
result (:result out)
|
||||
libs (:libraries result)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (count libs)))
|
||||
(t/is (= {} (get-in (first libs) [:data :components])))
|
||||
(t/is (= [] (get-in (first libs) [:data :pages])))))))
|
||||
|
||||
(t/deftest share-link-bundle-keeps-cross-library-nested-components
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
lib2 (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
lib2-page (first (get-in lib2 [:data :pages]))
|
||||
|
||||
comp-b (uuid/random)
|
||||
b-main (uuid/random)
|
||||
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib2)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib2-page
|
||||
:id b-main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id b-main
|
||||
:name "Leaf"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib2)
|
||||
:component-id comp-b})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "leaf"
|
||||
:main-instance-id b-main
|
||||
:main-instance-page lib2-page
|
||||
:id comp-b
|
||||
:anotation nil}])
|
||||
|
||||
lib1 (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared true})
|
||||
lib1-page (first (get-in lib1 [:data :pages]))
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id lib1)
|
||||
:library-id (:id lib2)})
|
||||
|
||||
comp-a (uuid/random)
|
||||
a-main (uuid/random)
|
||||
a-child (uuid/random)
|
||||
|
||||
;; Component whose main instance embeds an instance from lib2.
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id lib1)
|
||||
:changes [{:type :add-obj
|
||||
:page-id lib1-page
|
||||
:id a-main
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id a-main
|
||||
:name "Wrapper"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance true
|
||||
:component-root true
|
||||
:component-file (:id lib1)
|
||||
:component-id comp-a})}
|
||||
{:type :add-obj
|
||||
:page-id lib1-page
|
||||
:id a-child
|
||||
:parent-id a-main
|
||||
:frame-id a-main
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id a-child
|
||||
:name "Leaf"
|
||||
:frame-id a-main
|
||||
:parent-id a-main
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id lib2)
|
||||
:component-id comp-b})}
|
||||
{:type :add-component
|
||||
:path ""
|
||||
:name "wrapper"
|
||||
:main-instance-id a-main
|
||||
:main-instance-page lib1-page
|
||||
:id comp-a
|
||||
:anotation nil}])
|
||||
|
||||
file (th/create-file* 3 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
main-page (first (get-in file [:data :pages]))
|
||||
inst (uuid/random)
|
||||
|
||||
_ (update-file!
|
||||
:profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:changes [{:type :add-obj
|
||||
:page-id main-page
|
||||
:id inst
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id inst
|
||||
:name "Wrapper"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :frame
|
||||
:main-instance false
|
||||
:component-root true
|
||||
:component-file (:id lib1)
|
||||
:component-id comp-a})}])
|
||||
|
||||
_ (th/link-file-to-library* {:file-id (:id file)
|
||||
:library-id (:id lib1)})
|
||||
|
||||
slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{main-page}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})]
|
||||
|
||||
(t/testing "nested references resolve across libraries"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
:share-id (get-in slink [:result :id])
|
||||
:file-id (:id file)})
|
||||
result (:result out)
|
||||
by-id (into {} (map (juxt :id identity)) (:libraries result))]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 2 (count (:libraries result))))
|
||||
(t/is (= #{comp-a}
|
||||
(set (keys (get-in by-id [(:id lib1) :data :components])))))
|
||||
(t/is (= #{comp-b}
|
||||
(set (keys (get-in by-id [(:id lib2) :data :components])))))
|
||||
(t/is (= [] (get-in by-id [(:id lib2) :data :pages])))))))
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]]]]]])))
|
||||
@@ -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)}
|
||||
@@ -76,8 +79,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -106,8 +111,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -137,6 +144,7 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -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)}
|
||||
@@ -88,8 +91,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -121,8 +126,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -154,8 +161,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -191,6 +200,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -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)}
|
||||
@@ -76,8 +79,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
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -107,6 +112,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";
|
||||
@@ -41,7 +41,9 @@
|
||||
:m1 :margin-top
|
||||
:m2 :margin-right
|
||||
:m3 :margin-bottom
|
||||
:m4 :margin-left})
|
||||
:m4 :margin-left
|
||||
|
||||
:font-family :font-families})
|
||||
|
||||
(def ^:private map:token-attr-plugin->token-attr
|
||||
(merge
|
||||
|
||||
@@ -700,7 +700,9 @@
|
||||
|
||||
(defn use-shape
|
||||
[id]
|
||||
(when (initialized?)
|
||||
;; Use `wasm/live?` (not `initialized?`) so context-restore reload can
|
||||
;; select shapes while `reloading?` still blocks external app callers.
|
||||
(when (wasm/live?)
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
(h/call wasm/internal-module "_use_shape"
|
||||
(aget buffer 0)
|
||||
@@ -710,7 +712,7 @@
|
||||
|
||||
(defn has-shape
|
||||
[id]
|
||||
(when (initialized?)
|
||||
(when (wasm/live?)
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
|
||||
result
|
||||
@@ -739,9 +741,11 @@
|
||||
;; Cache content for text editor sync
|
||||
(text-editor/cache-shape-text-content! shape-id content)
|
||||
|
||||
;; The WASM design state may not be ready (e.g. while switching renderer
|
||||
;; with a text shape being edited). Skip the WASM layout calls in that case.
|
||||
(when (initialized?)
|
||||
;; Skip when the GL/WASM context is not usable. Use `wasm/live?` (not
|
||||
;; `initialized?`) so context-restore reload can re-upload text while
|
||||
;; `reloading?` still blocks external app callers. Geometry shapes already
|
||||
;; use `live?` via `set-shape-base-props`; text must match that path.
|
||||
(when (wasm/live?)
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
@@ -76,3 +76,48 @@
|
||||
(t/is (= (:stroke-alignment stroke') :inner))
|
||||
(t/is (= (:stroke-color stroke') "#FABADA"))
|
||||
(t/is (= (:stroke-width stroke') 2))))))))
|
||||
(t/deftest test-update-stroke-color-preserves-dash-gap
|
||||
;; Custom dash/gap on a dashed stroke describe stroke geometry, not color;
|
||||
;; a stroke color change must preserve them (issue #11549).
|
||||
(t/async
|
||||
done
|
||||
(let [store (ths/setup-store
|
||||
(-> (cthf/sample-file :file1 :page-label :page1)
|
||||
(cths/add-sample-shape :shape1 :strokes
|
||||
[{:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 2
|
||||
:stroke-style :dashed
|
||||
:stroke-dash 4
|
||||
:stroke-gap 20}])
|
||||
(cths/add-sample-shape :shape2 :strokes
|
||||
[{:stroke-color "#000000"
|
||||
:stroke-opacity 1
|
||||
:stroke-width 2
|
||||
:stroke-style :dashed}])))
|
||||
events [(dc/change-stroke-color #{(cthi/id :shape1)} {:color "#FABADA"} 0)
|
||||
(dc/change-stroke-color #{(cthi/id :shape2)} {:color "#FABADA"} 0)]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [objects (dsh/lookup-page-objects new-state)
|
||||
shape1' (get objects (cthi/id :shape1))
|
||||
stroke1' (first (:strokes shape1'))
|
||||
shape2' (get objects (cthi/id :shape2))
|
||||
stroke2' (first (:strokes shape2'))]
|
||||
|
||||
;; dashed stroke with custom dash/gap keeps them after color change
|
||||
(t/is (some? shape1'))
|
||||
(t/is (= (:stroke-color stroke1') "#FABADA"))
|
||||
(t/is (= (:stroke-style stroke1') :dashed))
|
||||
(t/is (= (:stroke-width stroke1') 2))
|
||||
(t/is (= (:stroke-dash stroke1') 4))
|
||||
(t/is (= (:stroke-gap stroke1') 20))
|
||||
|
||||
;; dashed stroke without explicit dash/gap stays unset:
|
||||
;; no implicit default is materialized into stored data
|
||||
(t/is (some? shape2'))
|
||||
(t/is (= (:stroke-color stroke2') "#FABADA"))
|
||||
(t/is (= (:stroke-style stroke2') :dashed))
|
||||
(t/is (nil? (:stroke-dash stroke2')))
|
||||
(t/is (nil? (:stroke-gap stroke2')))))))))
|
||||
@@ -22,9 +22,14 @@
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(t/use-fixtures :each {:before cthi/reset-idmap!})
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
(cthi/reset-idmap!)
|
||||
(thw/setup-wasm-mocks!))
|
||||
:after thw/teardown-wasm-mocks!})
|
||||
|
||||
(def ^:private get-resolved-value @#'ptok/get-resolved-value)
|
||||
|
||||
@@ -81,6 +86,18 @@
|
||||
(t/is (= :m3 (ptok/token-attr-plugin->token-attr :margin-bottom)))
|
||||
(t/is (= :m4 (ptok/token-attr-plugin->token-attr :margin-left))))
|
||||
|
||||
(t/deftest token-attr-plugin->token-attr-resolves-font-family-alias
|
||||
;; Plugin-facing `fontFamilies` (kebab-cased to `:font-families` by the
|
||||
;; schema layer) maps to the canonical internal `:font-family`.
|
||||
(t/is (= :font-family (ptok/token-attr-plugin->token-attr :font-families)))
|
||||
(t/is (= :font-family (ptok/token-attr-plugin->token-attr "font-families"))))
|
||||
|
||||
(t/deftest token-attr->token-attr-plugin-resolves-font-family-alias
|
||||
;; Symmetric direction: the canonical internal attribute maps to the
|
||||
;; plural plugin-facing name so applied-token readback serializes as
|
||||
;; camelCase `fontFamilies`, not the undocumented singular `fontFamily`.
|
||||
(t/is (= :font-families (ptok/token-attr->token-attr-plugin :font-family))))
|
||||
|
||||
(t/deftest token-attr-plugin->token-attr-coerces-string-input
|
||||
;; This is the actual regression — JS plugin calls supply strings.
|
||||
(t/is (= :fill (ptok/token-attr-plugin->token-attr "fill")))
|
||||
@@ -150,6 +167,57 @@
|
||||
(done)))
|
||||
0))))
|
||||
|
||||
(t/deftest shape-apply-token-accepts-font-families
|
||||
(t/async
|
||||
done
|
||||
(let [set-id (cthi/new-id! :token-set)
|
||||
token-id (cthi/new-id! :font-family-token)
|
||||
file (-> (cthf/sample-file :file1 :page-label :page1)
|
||||
(ctho/add-text :text1 "Hello World!")
|
||||
(ctht/add-tokens-lib)
|
||||
(ctht/update-tokens-lib
|
||||
#(-> %
|
||||
(ctob/add-set
|
||||
(ctob/make-token-set :id set-id
|
||||
:name "fonts"))
|
||||
(ctob/add-theme
|
||||
(ctob/make-token-theme :name "theme"
|
||||
:sets #{"fonts"}))
|
||||
(ctob/set-active-themes #{"/theme"})
|
||||
(ctob/add-token
|
||||
set-id
|
||||
(ctob/make-token :id token-id
|
||||
:name "font.primary"
|
||||
:type :font-family
|
||||
:value ["Inter"])))))
|
||||
store (ths/setup-store file)
|
||||
_ (set! st/state store)
|
||||
_ (set! st/stream (ptk/input-stream store))
|
||||
^js context (api/create-context "00000000-0000-0000-0000-000000000000")
|
||||
^js page (.-currentPage context)
|
||||
^js shape (.getShapeById page (str (cthi/id :text1)))
|
||||
^js library (.-library context)
|
||||
^js local (.-local library)
|
||||
^js catalog (.-tokens local)
|
||||
^js token-set (.getSetById catalog (str set-id))
|
||||
^js token (.getTokenById token-set (str token-id))]
|
||||
(.applyToken shape token #js ["fontFamilies"])
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(let [shape-id (cthi/id :text1)
|
||||
page-id (cthf/current-page-id file)]
|
||||
;; Plugin readback exposes the documented plural key.
|
||||
(t/is (= "font.primary" (.. shape -tokens -fontFamilies)))
|
||||
;; The undocumented singular spelling must not leak.
|
||||
(t/is (undefined? (.. shape -tokens -fontFamily)))
|
||||
;; Internal state keeps the canonical `:font-family` key.
|
||||
(t/is (= "font.primary"
|
||||
(get-in @store
|
||||
[:files (:id file) :data :pages-index page-id
|
||||
:objects shape-id :applied-tokens :font-family])))
|
||||
(done)))
|
||||
0))))
|
||||
|
||||
(t/deftest token-attr?-rejects-unknown-input
|
||||
(t/is (false? (boolean (ptok/token-attr? :not-a-real-attr))))
|
||||
(t/is (false? (boolean (ptok/token-attr? "not-a-real-attr"))))
|
||||
|
||||
@@ -10,30 +10,49 @@
|
||||
[app.main.ui.dashboard.check-updates :as dcu]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(def ^:private sample-highlights
|
||||
(str "# HIGHLIGHTS\n"
|
||||
(def ^:private sample-changes
|
||||
(str "# CHANGELOG\n"
|
||||
"\n"
|
||||
"## 2.18.0 (Unreleased)\n"
|
||||
"\n"
|
||||
"- To do\n"
|
||||
"### :sparkles: New features & Enhancements\n"
|
||||
"\n"
|
||||
"## 2.17.2\n"
|
||||
"- Something WIP\n"
|
||||
"\n"
|
||||
"- Background blur is here\n"
|
||||
"- WebGL rendering gets stronger\n"
|
||||
"## 2.17.0\n"
|
||||
"\n"
|
||||
"## 2.17.1\n"
|
||||
"### :rocket: Epics and highlights\n"
|
||||
"\n"
|
||||
"- MCP connection status and more\n"
|
||||
"- Design tokens: more visible, more user-friendly\n"))
|
||||
"- Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))\n"
|
||||
"- WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))\n"
|
||||
"\n"
|
||||
"### :sparkles: New features & Enhancements\n"
|
||||
"\n"
|
||||
"- Other stuff\n"
|
||||
"\n"
|
||||
"## 2.16.0\n"
|
||||
"\n"
|
||||
"### :rocket: Epics and highlights\n"
|
||||
"\n"
|
||||
"### :sparkles: New features & Enhancements\n"
|
||||
"\n"
|
||||
"- Tokens stuff\n"
|
||||
"\n"
|
||||
"## 2.15.0\n"
|
||||
"\n"
|
||||
"### :sparkles: New features & Enhancements\n"
|
||||
"\n"
|
||||
"- MCP server\n"))
|
||||
|
||||
(t/deftest parse-latest-released-version-skips-unreleased
|
||||
(t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights))))
|
||||
;; --- parse-latest-released-version ---
|
||||
|
||||
(t/deftest parse-latest-released-version-from-changes
|
||||
(t/is (= "2.17.0" (dcu/parse-latest-released-version sample-changes))))
|
||||
|
||||
(t/deftest parse-latest-released-version-first-released
|
||||
(t/is (= "2.17.2"
|
||||
(t/is (= "2.17.0"
|
||||
(dcu/parse-latest-released-version
|
||||
"## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n"))))
|
||||
"## 2.17.0\n\n### :sparkles:\n\n- Fix\n\n## 2.16.0\n\n### :sparkles:\n\n- Fix\n"))))
|
||||
|
||||
(t/deftest parse-latest-released-version-only-unreleased
|
||||
(t/is (nil? (dcu/parse-latest-released-version
|
||||
@@ -41,30 +60,154 @@
|
||||
|
||||
(t/deftest parse-latest-released-version-empty
|
||||
(t/is (nil? (dcu/parse-latest-released-version "")))
|
||||
(t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n"))))
|
||||
(t/is (nil? (dcu/parse-latest-released-version "# CHANGELOG\n"))))
|
||||
|
||||
(t/deftest parse-highlights-skips-unreleased-and-collects-bullets
|
||||
(t/is (= [{:version "2.17.2"
|
||||
:items ["Background blur is here"
|
||||
"WebGL rendering gets stronger"]}
|
||||
{:version "2.17.1"
|
||||
:items ["MCP connection status and more"
|
||||
"Design tokens: more visible, more user-friendly"]}]
|
||||
(dcu/parse-highlights sample-highlights))))
|
||||
;; --- parse-highlights ---
|
||||
|
||||
(t/deftest parse-highlights-extracts-rocket-items
|
||||
(let [result (dcu/parse-highlights sample-changes)]
|
||||
(t/is (= [{:version "2.17.0"
|
||||
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
|
||||
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
|
||||
result))))
|
||||
|
||||
(t/deftest parse-highlights-skips-empty-rocket
|
||||
;; 2.16.0 has an empty :rocket: section — should be skipped
|
||||
(let [result (dcu/parse-highlights sample-changes)]
|
||||
(t/is (not (some #(= "2.16.0" (:version %)) result)))))
|
||||
|
||||
(t/deftest parse-highlights-skips-missing-rocket
|
||||
;; 2.15.0 has no :rocket: section — should be skipped
|
||||
(let [result (dcu/parse-highlights sample-changes)]
|
||||
(t/is (not (some #(= "2.15.0" (:version %)) result)))))
|
||||
|
||||
(t/deftest parse-highlights-skips-unreleased
|
||||
;; 2.18.0 (Unreleased) should be skipped
|
||||
(let [result (dcu/parse-highlights sample-changes)]
|
||||
(t/is (not (some #(= "2.18.0" (:version %)) result)))))
|
||||
|
||||
(t/deftest parse-highlights-empty-input
|
||||
(t/is (= [] (dcu/parse-highlights "")))
|
||||
(t/is (= [] (dcu/parse-highlights nil)))
|
||||
(t/is (= [] (dcu/parse-highlights 42))))
|
||||
|
||||
(t/deftest parse-highlights-extracts-multiple-versions
|
||||
(let [input (str "## 2.17.0\n\n### :rocket: Epics and highlights\n\n"
|
||||
"- Feature A [#1](https://github.com/penpot/penpot/issues/1)\n\n"
|
||||
"## 2.16.0\n\n### :rocket: Epics and highlights\n\n"
|
||||
"- Feature B [#2](https://github.com/penpot/penpot/issues/2)\n")
|
||||
result (dcu/parse-highlights input)]
|
||||
(t/is (= 2 (count result)))
|
||||
(t/is (= "2.17.0" (:version (first result))))
|
||||
(t/is (= "2.16.0" (:version (second result))))))
|
||||
|
||||
;; --- version-compare ---
|
||||
|
||||
(t/deftest version-compare
|
||||
(t/is (zero? (v/compare-versions "2.17.1" "2.17.1")))
|
||||
(t/is (pos? (v/compare-versions "2.17.2" "2.17.1")))
|
||||
(t/is (neg? (v/compare-versions "2.17.1" "2.17.2")))
|
||||
(t/is (zero? (v/compare-versions "2.17.0" "2.17.0")))
|
||||
(t/is (pos? (v/compare-versions "2.17.0" "2.16.0")))
|
||||
(t/is (neg? (v/compare-versions "2.16.0" "2.17.0")))
|
||||
(t/is (pos? (v/compare-versions "3.0.0" "2.99.99")))
|
||||
(t/is (neg? (v/compare-versions "2.17.2" "2.17.10"))))
|
||||
(t/is (neg? (v/compare-versions "2.17.0" "2.17.10"))))
|
||||
|
||||
;; --- highlights-until-installed ---
|
||||
|
||||
(t/deftest highlights-until-installed
|
||||
(let [sections (dcu/parse-highlights sample-highlights)]
|
||||
(t/is (= [{:version "2.17.2"
|
||||
:items ["Background blur is here"
|
||||
"WebGL rendering gets stronger"]}]
|
||||
(dcu/highlights-until-installed sections "2.17.1")))
|
||||
(t/is (= [] (dcu/highlights-until-installed sections "2.17.2")))
|
||||
(t/is (= sections (dcu/highlights-until-installed sections "2.16.0")))
|
||||
(t/is (= [] (dcu/highlights-until-installed sections "2.17.10")))))
|
||||
(let [sections (dcu/parse-highlights sample-changes)]
|
||||
;; installed 2.16.0 → shows 2.17.0 highlights
|
||||
(t/is (= [{:version "2.17.0"
|
||||
:items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
|
||||
"WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
|
||||
(dcu/highlights-until-installed sections "2.16.0")))
|
||||
;; installed 2.17.0 → no newer highlights
|
||||
(t/is (= [] (dcu/highlights-until-installed sections "2.17.0")))
|
||||
;; installed older version → shows all available highlights
|
||||
(t/is (= sections (dcu/highlights-until-installed sections "2.14.0")))
|
||||
;; installed newer than any highlight → empty
|
||||
(t/is (= [] (dcu/highlights-until-installed sections "2.99.0")))))
|
||||
|
||||
;; --- parse-highlight-item ---
|
||||
|
||||
(t/deftest parse-highlight-item-link
|
||||
(t/is (= [{:type :text :text "See "}
|
||||
{:type :link :text "#9844"
|
||||
:href "https://github.com/penpot/penpot/issues/9844"}]
|
||||
(dcu/parse-highlight-item
|
||||
"See [#9844](https://github.com/penpot/penpot/issues/9844)"))))
|
||||
|
||||
(t/deftest parse-highlight-item-bold
|
||||
(t/is (= [{:type :bold :text "New plugin system."}]
|
||||
(dcu/parse-highlight-item "**New plugin system.**"))))
|
||||
|
||||
(t/deftest parse-highlight-item-mixed-real-line
|
||||
(t/is (= [{:type :text :text "Background blur "}
|
||||
{:type :link :text "#9844"
|
||||
:href "https://github.com/penpot/penpot/issues/9844"}
|
||||
{:type :text :text " (PR: "}
|
||||
{:type :link :text "#10034"
|
||||
:href "https://github.com/penpot/penpot/pull/10034"}
|
||||
{:type :text :text ")"}]
|
||||
(dcu/parse-highlight-item
|
||||
"Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"))))
|
||||
|
||||
(t/deftest parse-highlight-item-plain-text
|
||||
(t/is (= [{:type :text :text "Just plain text, no markdown"}]
|
||||
(dcu/parse-highlight-item "Just plain text, no markdown"))))
|
||||
|
||||
(t/deftest parse-highlight-item-empty
|
||||
(t/is (= [] (dcu/parse-highlight-item "")))
|
||||
(t/is (= [] (dcu/parse-highlight-item nil))))
|
||||
|
||||
(t/deftest parse-highlight-item-unbalanced-fallback
|
||||
(t/is (= [{:type :text :text "Broken [link without end"}]
|
||||
(dcu/parse-highlight-item "Broken [link without end")))
|
||||
(t/is (= [{:type :text :text "Unclosed **bold"}]
|
||||
(dcu/parse-highlight-item "Unclosed **bold"))))
|
||||
|
||||
(t/deftest parse-highlight-item-non-http-url-is-plain-text
|
||||
(t/is (= [{:type :text :text "[click](javascript:alert(1))"}]
|
||||
(dcu/parse-highlight-item "[click](javascript:alert(1))"))))
|
||||
|
||||
;; --- parse-highlight-item edge cases ---
|
||||
|
||||
(t/deftest parse-highlight-item-taiga-link
|
||||
(t/is (= [{:type :text :text "Grid CSS layout "}
|
||||
{:type :link :text "Taiga #4915"
|
||||
:href "https://tree.taiga.io/project/penpot/epic/4915"}]
|
||||
(dcu/parse-highlight-item
|
||||
"Grid CSS layout [Taiga #4915](https://tree.taiga.io/project/penpot/epic/4915)"))))
|
||||
|
||||
(t/deftest parse-highlight-item-multi-pr-line
|
||||
(t/is (= [{:type :text :text "Add MCP "}
|
||||
{:type :link :text "#9174"
|
||||
:href "https://github.com/penpot/penpot/issues/9174"}
|
||||
{:type :text :text " (PR: "}
|
||||
{:type :link :text "#9032"
|
||||
:href "https://github.com/penpot/penpot/pull/9032"}
|
||||
{:type :text :text ", "}
|
||||
{:type :link :text "#9321"
|
||||
:href "https://github.com/penpot/penpot/pull/9321"}
|
||||
{:type :text :text ")"}]
|
||||
(dcu/parse-highlight-item
|
||||
"Add MCP [#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))"))))
|
||||
|
||||
(t/deftest parse-highlight-item-link-only
|
||||
(t/is (= [{:type :link :text "#1"
|
||||
:href "https://github.com/penpot/penpot/issues/1"}]
|
||||
(dcu/parse-highlight-item
|
||||
"[#1](https://github.com/penpot/penpot/issues/1)"))))
|
||||
|
||||
(t/deftest parse-highlight-item-bold-adjacent-to-link
|
||||
(t/is (= [{:type :bold :text "Hot"}
|
||||
{:type :text :text " "}
|
||||
{:type :link :text "#1"
|
||||
:href "https://github.com/penpot/penpot/issues/1"}]
|
||||
(dcu/parse-highlight-item
|
||||
"**Hot** [#1](https://github.com/penpot/penpot/issues/1)"))))
|
||||
|
||||
(t/deftest parse-highlight-item-bold-inside-link-is-not-nested
|
||||
;; Link wins; inner ** stays raw text (documented, no nesting)
|
||||
(t/is (= [{:type :link :text "a **b**"
|
||||
:href "https://github.com/penpot/penpot/issues/1"}]
|
||||
(dcu/parse-highlight-item
|
||||
"[a **b**](https://github.com/penpot/penpot/issues/1)"))))
|
||||
@@ -1799,7 +1799,7 @@ msgstr "At least 1 uppercase letter"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "At least 1 digit"
|
||||
msgstr "At least 1 number"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
|
||||
@@ -1764,7 +1764,7 @@ msgstr "Al menos 1 letra mayúscula"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "Al menos 1 dígito"
|
||||
msgstr "Al menos 1 número"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
|
||||
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
|
||||
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
|
||||
- **plugins-runtime**: Fixed the `fontFamilies` token property mapping so `Shape.applyToken(token, ["fontFamilies"])` resolves to the canonical `:font-family` attribute and applied-token readback exposes the documented `fontFamilies` key instead of the undocumented singular `fontFamily`. Closes #11405.
|
||||
|
||||
## 1.5.0 (2026-07-08)
|
||||
|
||||
|
||||
@@ -2,5 +2,3 @@ storeDir: ../.pnpm-store
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -1,6 +1,6 @@
|
||||
use skia_safe::{self as skia, Paint};
|
||||
|
||||
use crate::shapes::{Shape, Type};
|
||||
use crate::shapes::{radius_to_sigma, Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use crate::render::vector::draw_shape_geometry;
|
||||
@@ -136,17 +136,40 @@ impl SvgLayerCanvas {
|
||||
}
|
||||
|
||||
/// Finalizes a fragment canvas as a `<clipPath>` def.
|
||||
///
|
||||
/// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd
|
||||
/// stroke rings would otherwise fill solid.
|
||||
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
|
||||
let data = canvas.end();
|
||||
let doc = String::from_utf8_lossy(data.as_bytes());
|
||||
let inner = extract_inner_svg(&doc);
|
||||
let prefix = format!("f{}_", self.frag_no);
|
||||
self.frag_no += 1;
|
||||
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix));
|
||||
let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))
|
||||
.replace("fill-rule=", "clip-rule=");
|
||||
self.defs.push_str(&format!(
|
||||
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
|
||||
));
|
||||
}
|
||||
|
||||
/// Registers a layer-blur `<filter>` and returns its id.
|
||||
///
|
||||
/// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
|
||||
/// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
|
||||
pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
|
||||
let id = self.unique("blur");
|
||||
self.defs.push_str(&format!(
|
||||
concat!(
|
||||
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
|
||||
"color-interpolation-filters=\"sRGB\">",
|
||||
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
|
||||
"</filter>"
|
||||
),
|
||||
id = id,
|
||||
sigma = sigma
|
||||
));
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a clip geometry into `cv` (already set up with the page transform).
|
||||
@@ -167,11 +190,11 @@ fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, pai
|
||||
}
|
||||
|
||||
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
|
||||
/// blend mode). Returns `None` when the shape needs no wrapper.
|
||||
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
|
||||
///
|
||||
/// Layer blur / shadows are intentionally omitted here — they need native SVG
|
||||
/// filter re-emission to survive `SkSVGDevice` and land in later PRs.
|
||||
pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
|
||||
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
|
||||
/// Shadows still need dedicated re-emission in a later PR.
|
||||
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
let opacity = element.opacity();
|
||||
@@ -183,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
|
||||
parts.push(format!("style=\"mix-blend-mode:{css}\""));
|
||||
}
|
||||
|
||||
if let Some(blur) = element.visible_layer_blur() {
|
||||
// Match canvas `Shape::image_filter`: sigma from radius × export scale.
|
||||
let sigma = radius_to_sigma(blur.value * builder.scale);
|
||||
let id = builder.push_layer_blur_filter(sigma);
|
||||
parts.push(format!("filter=\"url(#{id})\""));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -378,6 +378,24 @@ fn stroke_with_style(
|
||||
stroke
|
||||
}
|
||||
|
||||
fn image_stroke(kind: StrokeKind, style: StrokeStyle, width: f32, image_id: Uuid) -> Stroke {
|
||||
let mut stroke = match kind {
|
||||
StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None),
|
||||
StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None),
|
||||
};
|
||||
stroke.fill = test_image_fill(image_id);
|
||||
stroke
|
||||
}
|
||||
|
||||
pub(super) fn image_solid_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
|
||||
image_stroke(kind, StrokeStyle::Solid, width, image_id)
|
||||
}
|
||||
|
||||
pub(super) fn image_dotted_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke {
|
||||
image_stroke(kind, StrokeStyle::Dotted, width, image_id)
|
||||
}
|
||||
|
||||
/// Text with a linked image fill (register URL via `render_with`).
|
||||
pub(super) fn add_image_text(
|
||||
pool: &mut ShapesPool,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use crate::error::Result;
|
||||
use crate::render::shape_renderer::ShapeRenderer;
|
||||
use crate::render::vector::VectorRenderer;
|
||||
use crate::shapes::{Shape, Stroke};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::images::emit_fills;
|
||||
use super::images::{emit_fills, emit_strokes};
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
@@ -16,9 +14,7 @@ pub(super) fn render_frame(
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let matrix = element.centered_transform();
|
||||
|
||||
let effects = effect_attrs(element);
|
||||
let effects = effect_attrs(builder, element);
|
||||
if let Some(attrs) = &effects {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
@@ -51,12 +47,7 @@ pub(super) fn render_frame(
|
||||
// Strokes over children (frame space), outside the content clip.
|
||||
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
canvas.restore();
|
||||
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
|
||||
}
|
||||
|
||||
if effects.is_some() {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub(super) fn render_group(
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(element);
|
||||
let effects = effect_attrs(builder, element);
|
||||
if let Some(attrs) = &effects {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::error::Result;
|
||||
use crate::math::Rect as MathRect;
|
||||
use crate::render::get_dest_rect;
|
||||
use crate::render::get_image_dest_rect;
|
||||
use crate::render::shape_renderer::ShapeRenderer;
|
||||
use crate::render::vector::VectorRenderer;
|
||||
use crate::shapes::{Fill, ImageFill, Shape};
|
||||
use crate::render::vector::{paint_svg_stroke_silhouette, VectorRenderer};
|
||||
use crate::shapes::{Fill, ImageFill, Shape, Stroke};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
use crate::math::Rect as MathRect;
|
||||
use crate::render::{get_image_dest_rect, RenderResources};
|
||||
use crate::render::RenderResources;
|
||||
|
||||
/// Emits fills bottom -> top for SVG export.
|
||||
///
|
||||
@@ -69,6 +71,94 @@ fn emit_image_fill(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits strokes bottom -> top for SVG export.
|
||||
///
|
||||
/// Image strokes with a registered URL become a linked `<image>` clipped to the
|
||||
/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes
|
||||
/// go through [`VectorRenderer`].
|
||||
pub(super) fn emit_strokes(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
shape: &Shape,
|
||||
strokes: &[&Stroke],
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
if strokes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
// strokes[0] is topmost; draw bottom -> top.
|
||||
for stroke in strokes.iter().rev() {
|
||||
match &stroke.fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
|
||||
}
|
||||
_ => {
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_strokes(shape, std::slice::from_ref(stroke))?;
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linked `<image>` clipped to the stroke outline (opaque filled path).
|
||||
fn emit_image_stroke(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
image_fill: &ImageFill,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("imgstrokeclip");
|
||||
let canvas = builder.new_fragment();
|
||||
{
|
||||
let cv: &skia_safe::Canvas = &canvas;
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
|
||||
cv.restore();
|
||||
return Ok(());
|
||||
}
|
||||
cv.restore();
|
||||
}
|
||||
builder.finish_clip_path_fragment(&clip_id, canvas);
|
||||
|
||||
let href = xml_escape_attr(url);
|
||||
let dest = image_stroke_dest_rect(shape, stroke);
|
||||
emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where to place the linked image for an image-filled stroke.
|
||||
///
|
||||
/// Starts from the same dest as the GPU path (`selrect` + `stroke.delta()`), then
|
||||
/// grows on open paths so marker caps are still covered by the `<image>`.
|
||||
fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
|
||||
let mut dest = get_dest_rect(&shape.selrect(), stroke.delta());
|
||||
if !shape.is_open() {
|
||||
return dest;
|
||||
}
|
||||
let cap_margin = stroke.cap_bounds_margin();
|
||||
if cap_margin <= 0.0 {
|
||||
return dest;
|
||||
}
|
||||
let mut with_caps = shape.selrect();
|
||||
with_caps.inset((-cap_margin, -cap_margin));
|
||||
dest.join(with_caps);
|
||||
dest
|
||||
}
|
||||
|
||||
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
|
||||
pub(super) fn emit_linked_image_element(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
|
||||
@@ -60,9 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
|
||||
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
|
||||
/// `<clipPath>`.
|
||||
///
|
||||
/// Special-case re-emission for shadows, layer blur, masks, text strokes, and
|
||||
/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer
|
||||
/// and dotted/dashed strokes are emitted as filled outlines.
|
||||
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
|
||||
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
|
||||
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
|
||||
/// image-filled strokes use a linked `<image>` clipped to the stroke.
|
||||
pub fn render_to_svg(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
@@ -135,7 +136,7 @@ use groups::render_group;
|
||||
use text::render_text_fill;
|
||||
|
||||
use document::effect_attrs;
|
||||
use images::emit_fills;
|
||||
use images::{emit_fills, emit_strokes};
|
||||
|
||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||
fn render_body(
|
||||
@@ -186,7 +187,7 @@ fn render_leaf(
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(element);
|
||||
let effects = effect_attrs(builder, element);
|
||||
if let Some(attrs) = &effects {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
@@ -211,17 +212,22 @@ fn render_leaf(
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
canvas.restore();
|
||||
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
|
||||
if !element.has_fills() {
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
for stroke in &visible_strokes {
|
||||
renderer.draw_stroke_inner_shadows(element, stroke)?;
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(16 16)" d="M-8 0C-8 1.6422184 -7.538126 3.1422181 -6.6143785 4.5C-7.538126 5.8577819 -8 7.3577814 -8 9C-8 11.737486 -6.8878965 13.904153 -4.6636891 15.5C-6.8878965 17.095848 -8 19.262514 -8 22C-8 24.737486 -6.8878965 26.904152 -4.6636891 28.5C-6.8878965 30.095848 -8 32.262516 -8 35C-8 37.737484 -6.8878965 39.904152 -4.6636891 41.5C-6.8878965 43.095848 -8 45.262516 -8 48C-8 50.737484 -6.8878965 52.904152 -4.6636891 54.5C-6.8878965 56.095848 -8 58.262516 -8 61C-8 63.737488 -6.8878965 65.904152 -4.6636891 67.5C-6.8878965 69.095848 -8 71.262512 -8 74C-8 78.851593 -5.5850339 81.506355 -0.75510108 81.964279C0.26408672 85.988091 2.8491201 88 7 88C9.7374859 88 11.904153 86.887894 13.5 84.663689C15.095847 86.887894 17.262514 88 20 88C22.737486 88 24.904152 86.887894 26.5 84.663689C28.095848 86.887894 30.262514 88 33 88C35.737484 88 37.904152 86.887894 39.499996 84.663689C41.095844 86.887894 43.262512 88 45.999996 88C48.737484 88 50.904152 86.887894 52.499996 84.663689C54.095844 86.887894 56.262512 88 59 88C61.737484 88 63.904152 86.887894 65.5 84.663689C67.095848 86.887894 69.262512 88 72 88C74.737488 88 76.904152 86.887894 78.5 84.663689C80.095848 86.887894 82.262512 88 85 88C87.737488 88 89.904152 86.887894 91.5 84.663689C93.095848 86.887894 95.262512 88 98 88C103.33334 88 106 85.333336 106 80C106 78.368233 105.54349 76.876137 104.63046 75.52372C106.87682 73.929276 108 71.754707 108 69C108 66.262512 106.88789 64.095848 104.66369 62.5C106.88789 60.904152 108 58.737484 108 56C108 53.262516 106.88789 51.095848 104.66369 49.5C106.88789 47.904152 108 45.737484 108 43C108 40.262516 106.88789 38.095848 104.66369 36.5C106.88789 34.904152 108 32.737484 108 30C108 27.262514 106.88789 25.095848 104.66369 23.5C106.88789 21.904152 108 19.737486 108 17C108 14.262514 106.88789 12.095847 104.66369 10.5C106.88789 8.9041529 108 6.7374859 108 4C108 -1.3333335 105.33334 -4 100 -4C99.343742 -4 98.697281 -3.9204535 98.060608 -3.7613606C96.55526 -6.5871201 94.201721 -8 91 -8C88.262512 -8 86.095848 -6.8878965 84.5 -4.6636891C82.904152 -6.8878965 80.737488 -8 78 -8C75.262512 -8 73.095848 -6.8878965 71.5 -4.6636891C69.904152 -6.8878965 67.737488 -8 65 -8C62.262516 -8 60.095848 -6.8878965 58.5 -4.6636891C56.904152 -6.8878965 54.737484 -8 52 -8C49.262516 -8 47.095848 -6.8878965 45.5 -4.6636891C43.904152 -6.8878965 41.737484 -8 39 -8C36.262516 -8 34.095848 -6.8878965 32.5 -4.6636891C30.904152 -6.8878965 28.737486 -8 26 -8C23.262514 -8 21.095848 -6.8878965 19.5 -4.6636891C17.904152 -6.8878965 15.737486 -8 13 -8C10.262514 -8 8.0958471 -6.8878965 6.5 -4.6636891C4.9041519 -6.8878965 2.7374856 -8 0 -8C-5.3333335 -8 -8 -5.3333335 -8 0ZM100 0L0 0L0 80L100 80L100 0Z" clip-rule="evenodd"/>
|
||||
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="132" height="112" viewBox="0 0 132 112"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
|
||||
<path transform="translate(16 16)" d="M-8 -8L108 -8L108 88L-8 88L-8 -8ZM100 0L0 0L0 80L100 80L100 0Z" clip-rule="evenodd"/>
|
||||
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-16" y="-16" width="132" height="112" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 16 16)"/></g></svg>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
|
||||
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
|
||||
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
|
||||
</g></svg>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
|
||||
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
|
||||
</g></svg>
|
||||