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

This commit is contained in:
Andrey Antukh
2026-03-30 12:29:07 +02:00
27 changed files with 3105 additions and 397 deletions

View File

@@ -31,3 +31,7 @@ Requirements:
exact commands).
* Make small and logical commits following the commit guideline described in
`CONTRIBUTING.md`. Commit only when explicitly asked.
- Do not guess or hallucinate git author information (Name or Email). Never include the
`--author` flag in git commands unless specifically instructed by the user for a unique
case; assume the local environment is already configured. Allow git commit to
automatically pull the identity from the local git config `user.name` and `user.email`.

View File

@@ -31,3 +31,7 @@ Requirements:
commands).
* Make small and logical commits following the commit guideline described in
`CONTRIBUTING.md`. Commit only when explicitly asked.
- Do not guess or hallucinate git author information (Name or Email). Never include the
`--author` flag in git commands unless specifically instructed by the user for a unique
case; assume the local environment is already configured. Allow git commit to
automatically pull the identity from the local git config `user.name` and `user.email`.

View File

@@ -1,28 +0,0 @@
---
name: penpot-backend
description: Guidelines and workflows for the Penpot Clojure JVM backend.
---
# Penpot Backend Skill
This skill provides guidelines and workflows for the Penpot Clojure JVM backend.
## Testing & Validation
- **Isolated tests:** `clojure -M:dev:test --focus backend-tests.my-ns-test` (for a specific test namespace)
- **Regression tests:** `clojure -M:dev:test` (ensure the suite passes without regressions)
- **Eval expresion:** `clojure -M:dev -e "(here-the-expresion)"`
## Code Quality
- **Linting:** `pnpm run lint:clj`
- **Formatting:**
- Check: `pnpm run check-fmt`
- Fix: `pnpm run fmt`
- **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in performance-critical paths to avoid reflection overhead.
## Architecture & Conventions
- Uses Integrant for dependency injection (`src/app/main.clj`).
- PostgreSQL for storage, Redis for messaging/caching.
- **RPC:** Commands are under `app.rpc.commands.*`. Use the `get-` prefix on RPC names when we want READ operations.
- **Database:** `app.db` wraps next.jdbc. Queries use a SQL builder.
- Helpers: `db/get`, `db/query`, `db/insert!`, `db/update!`, `db/delete!`
- **Performance Macros:** Always prefer these macros from `app.common.data.macros` over `clojure.core` equivalents: `dm/select-keys`, `dm/get-in`, `dm/str`.

View File

@@ -1,25 +0,0 @@
---
name: penpot-common
description: Guidelines and workflows for the Penpot Common shared module.
---
# Penpot Common Skill
This skill provides guidelines and workflows for the Penpot Common shared module (Clojure/ClojureScript/JS).
## Testing & Validation
- **JS (Node) Isolated tests:** Edit `test/common_tests/runner.cljs` then run `pnpm run test:js`
- **JS (Node) Regression tests:** `pnpm run test:js`
- **JVM Isolated tests:** `pnpm run test:jvm --focus common-tests.my-ns-test`
- **JVM Regression tests:** `pnpm run test:jvm`
## Code Quality
- **Linting:** `pnpm run lint:clj`
- **Formatting:**
- Check: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`
- Fix: `pnpm run fmt:clj`, `pnpm run fmt:js`
## Architecture & Conventions
- Multiplatform code used by frontend, backend, and exporter.
- Uses Clojure reader conditionals (`#?(:clj ... :cljs ...)`).
- Modifying common code requires testing across consumers (frontend, backend, exporter).

View File

@@ -1,28 +0,0 @@
---
name: penpot-frontend
description: Guidelines and workflows for the Penpot ClojureScript React frontend.
---
# Penpot Frontend Skill
This skill provides guidelines and workflows for the Penpot ClojureScript React frontend.
## Testing & Validation
- **Isolated tests:** Edit `test/frontend_tests/runner.cljs` to narrow the test suite, then run `pnpm run test`
- **Regression tests:** `pnpm run test` (without modifications on the runner)
- **Integration tests:** `pnpm run test:e2e` or `pnpm run test:e2e --grep "pattern"` (do not modify e2e tests unless explicitly asked).
## Code Quality
- **Linting:**
- `pnpm run lint:clj`
- `pnpm run lint:js`
- `pnpm run lint:scss`
- **Formatting:**
- Check: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`
- Fix: `pnpm run fmt:clj`, `pnpm run fmt:js`, `pnpm run fmt:scss`
## Architecture & Conventions
- Uses React and RxJS (Potok for state management).
- Modern components use the `*` suffix (e.g., `my-component*`) and the `mf/defc` macro.
- Hooks: `mf/use-state`, `mf/use-effect`, `mf/use-memo`, `mf/use-fn`. Prefer macros `mf/with-effect` and `mf/with-memo`.
- Styles: Use CSS custom properties from `_sizes.scss` and tokens from `ds/colors.scss`. Avoid deep selector nesting.

View File

@@ -1,22 +0,0 @@
---
name: penpot-render-wasm
description: Guidelines and workflows for the Penpot Rust to WebAssembly renderer.
---
# Penpot Render-WASM Skill
This skill provides guidelines and workflows for the Penpot Rust to WebAssembly renderer.
## Commands
- **Build:** `./build` (Compiles Rust → WASM. Requires Emscripten environment. Automatically sources `_build_env`)
- **Watch:** `./watch` (Incremental rebuild on file change)
- **Test (All):** `./test` (Runs cargo test)
- **Test (Single):** `cargo test my_test_name` or `cargo test shapes::`
- **Lint:** `./lint` (`clippy -D warnings`)
- **Format:** `cargo fmt --check`
## Architecture & Conventions
- **Global state:** Accessed EXCLUSIVELY through `with_state!` / `with_state_mut!` macros. Never access `unsafe static mut State` directly.
- **Tile-based rendering:** Only 512×512 tiles within the viewport are drawn each frame.
- **Two-phase updates:** Shape data is written via exported setter functions, then a single `render_frame()` triggers the actual Skia draw calls.
- **Frontend Integration:** The WASM module is loaded by `app.render-wasm.*` namespaces. Do not change export function signatures without updating the corresponding ClojureScript bridge.

View File

@@ -220,12 +220,14 @@
(assoc :hint (ex-message error)))}))))
(defmethod handle-exception java.io.IOException
[cause _ _]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)}})
[cause request _]
(binding [l/*context* (request->context request)]
(l/wrn :hint "io exception" :cause cause)
{::yres/status 500
::yres/body {:type :server-error
:code :io-exception
:hint (ex-message cause)
:path (:path request)}}))
(defmethod handle-exception java.util.concurrent.CompletionException
[cause request _]

View File

@@ -603,7 +603,7 @@
(defmethod generate-sync-shape :components
[_ changes _library-id container shape libraries current-file-id]
(let [shape-id (:id shape)
file (get current-file-id libraries)]
file (get libraries current-file-id)]
(generate-sync-shape-direct changes file libraries container shape-id false)))
(defmethod generate-sync-shape :colors
@@ -837,7 +837,7 @@
shape-inst' (ctn/get-head-shape (:objects container-inst) parent)
component' (or (ctkl/get-component library (:component-id shape-inst'))
(ctkl/get-deleted-component library (:component-id shape-inst')))]
(if (some? component)
(if (some? component')
(recur shape-inst'
component')
nil))))))
@@ -2241,7 +2241,7 @@
(contains? #{:auto :fix} (:layout-item-h-sizing shape-main))
(propagate-attrs shape-main #{:layout-item-h-sizing} omit-touched?)
(contains? #{:auto :fix} (:layout-item-h-sizing shape-main))
(contains? #{:auto :fix} (:layout-item-v-sizing shape-main))
(propagate-attrs shape-main #{:layout-item-v-sizing} omit-touched?)))
{:ignore-touched true})
@@ -2271,7 +2271,7 @@
(contains? #{:auto :fix} (:layout-item-h-sizing shape-copy))
(propagate-attrs shape-copy #{:layout-item-h-sizing} omit-touched?)
(contains? #{:auto :fix} (:layout-item-h-sizing shape-copy))
(contains? #{:auto :fix} (:layout-item-v-sizing shape-copy))
(propagate-attrs shape-copy #{:layout-item-v-sizing} omit-touched?)))
{:ignore-touched true})

View File

@@ -12,11 +12,13 @@
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.common :as gco]
[app.common.geom.shapes.corners :as gsc]
[app.common.geom.shapes.effects :as gse]
[app.common.geom.shapes.strokes :as gss]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[clojure.core :as c]))
@@ -117,6 +119,33 @@
(or (not ^boolean (mth/almost-zero? (- (dm/get-prop vector :x) 1)))
(not ^boolean (mth/almost-zero? (- (dm/get-prop vector :y) 1)))))
(defn- safe-size-rect?
"Returns true when `rect` has finite, in-range, positive width and height."
[rect]
(and ^boolean (some? rect)
(let [w (dm/get-prop rect :width)
h (dm/get-prop rect :height)]
(and ^boolean (d/num? w h)
^boolean (pos? w)
^boolean (pos? h)
^boolean (<= w sm/max-safe-int)
^boolean (<= h sm/max-safe-int)))))
(defn safe-size-rect
"Returns the best available size rect for a shape, trying several
fallbacks in order:
1. `:selrect` — if it has valid, in-range, positive dimensions.
2. `points->rect` — computed from the shape's corner points.
3. Top-level `:x :y :width :height` shape fields.
4. `grc/empty-rect` — a unit rect (0,0,0.01,0.01) of last resort."
[{:keys [selrect points x y width height]}]
(or (and ^boolean (safe-size-rect? selrect) selrect)
(let [from-points (grc/points->rect points)]
(and ^boolean (safe-size-rect? from-points) from-points))
(let [from-shape (grc/make-rect x y width height)]
(and ^boolean (safe-size-rect? from-shape) from-shape))
grc/empty-rect))
(defn- mergeable-move?
[op1 op2]
(let [type-op1 (dm/get-prop op1 :type)
@@ -195,7 +224,7 @@
(conj item)))
(conj operations op))))))
(defn valid-vector?
(defn- valid-vector?
[vector]
(let [x (dm/get-prop vector :x)
y (dm/get-prop vector :y)]
@@ -309,11 +338,6 @@
(-> (or modifiers (empty))
(update :structure-child conj (scale-content-op value))))
(defn change-recursive-property
[modifiers property value]
(-> (or modifiers (empty))
(update :structure-child conj (change-property-op property value))))
(defn change-property
[modifiers property value]
(-> (or modifiers (empty))
@@ -348,7 +372,7 @@
(recur result (rest operations)))))))
(defn increase-order
(defn- increase-order
[operations last-order]
(->> operations
(mapv #(update % :order + last-order))))
@@ -390,13 +414,6 @@
([vector]
(move (empty) vector)))
(defn move-parent-modifiers
([x y]
(move-parent (empty) (gpt/point x y)))
([vector]
(move-parent (empty) vector)))
(defn resize-modifiers
([vector origin]
(resize (empty) vector origin))
@@ -404,13 +421,6 @@
([vector origin transform transform-inverse]
(resize (empty) vector origin transform transform-inverse)))
(defn resize-parent-modifiers
([vector origin]
(resize-parent (empty) vector origin))
([vector origin transform transform-inverse]
(resize-parent (empty) vector origin transform transform-inverse)))
(defn rotation-modifiers
[shape center angle]
(let [shape-center (gco/shape->center shape)
@@ -426,73 +436,54 @@
(rotation shape-center angle)
(move move-vec))))
(defn remove-children-modifiers
[shapes]
(-> (empty)
(remove-children shapes)))
(defn add-children-modifiers
[shapes index]
(-> (empty)
(add-children shapes index)))
(defn reflow-modifiers
[]
(-> (empty)
(reflow)))
(defn scale-content-modifiers
[value]
(-> (empty)
(scale-content value)))
(defn change-size
[{:keys [selrect points transform transform-inverse] :as shape} width height]
(let [old-width (-> selrect :width)
old-height (-> selrect :height)
width (or width old-width)
height (or height old-height)
origin (first points)
scalex (/ width old-width)
scaley (/ height old-height)]
[{:keys [points transform transform-inverse] :as shape} width height]
(let [{sr-width :width sr-height :height} (safe-size-rect shape)
width (or width sr-width)
height (or height sr-height)
origin (first points)
scalex (/ width sr-width)
scaley (/ height sr-height)]
(resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse)))
(defn change-dimensions-modifiers
([shape attr value]
(change-dimensions-modifiers shape attr value nil))
([{:keys [transform transform-inverse] :as shape} attr value {:keys [ignore-lock?] :or {ignore-lock? false}}]
([shape attr value {:keys [ignore-lock?] :or {ignore-lock? false}}]
(dm/assert! (map? shape))
(dm/assert! (#{:width :height} attr))
(dm/assert! (number? value))
(let [;; Avoid havig shapes with zero size
value (if (< (mth/abs value) 0.01)
0.01
value)
(let [;; Avoid having shapes with zero size
value (if (< (mth/abs value) 0.01) 0.01 value)
{:keys [proportion proportion-lock]} shape
size (select-keys (:selrect shape) [:width :height])
new-size (if-not (and (not ignore-lock?) proportion-lock)
(assoc size attr value)
(if (= attr :width)
(-> size
(assoc :width value)
(assoc :height (/ value proportion)))
(-> size
(assoc :height value)
(assoc :width (* value proportion)))))
{sr-width :width sr-height :height} (safe-size-rect shape)
locked? (and (not ignore-lock?) proportion-lock)
width (:width new-size)
height (:height new-size)
width (if (= attr :width)
value
(if locked? (* value proportion) sr-width))
{sr-width :width sr-height :height} (:selrect shape)
height (if (= attr :height)
value
(if locked? (/ value proportion) sr-height))
origin (-> shape :points first)
scalex (/ width sr-width)
scaley (/ height sr-height)]
(resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse))))
(resize-modifiers
(gpt/point scalex scaley)
origin
(:transform shape)
(:transform-inverse shape)))))
(defn change-orientation-modifiers
[shape orientation]
@@ -566,7 +557,7 @@
[modifiers]
(assoc (or modifiers (empty)) :geometry-child [] :structure-child []))
(defn select-structure
(defn- select-structure
[modifiers]
(assoc (or modifiers (empty)) :geometry-child [] :geometry-parent []))
@@ -574,10 +565,6 @@
[modifiers]
(assoc (or modifiers (empty)) :structure-child [] :structure-parent []))
(defn select-child-geometry-modifiers
[modifiers]
(-> modifiers select-child select-geometry))
(defn select-child-structre-modifiers
[modifiers]
(-> modifiers select-child select-structure))
@@ -601,7 +588,7 @@
;; Main transformation functions
(defn transform-move!
(defn- transform-move!
"Transforms a matrix by the translation modifier"
[matrix modifier]
(-> (dm/get-prop modifier :vector)
@@ -609,7 +596,7 @@
(gmt/multiply! matrix)))
(defn transform-resize!
(defn- transform-resize!
"Transforms a matrix by the resize modifier"
[matrix modifier]
(let [tf (dm/get-prop modifier :transform)
@@ -631,7 +618,7 @@
(gmt/multiply! tfi)))
matrix)))
(defn transform-rotate!
(defn- transform-rotate!
"Transforms a matrix by the rotation modifier"
[matrix modifier]
(let [center (dm/get-prop modifier :center)
@@ -643,7 +630,7 @@
(gmt/translate! (gpt/negate center)))
matrix)))
(defn transform!
(defn- transform!
"Returns a matrix transformed by the modifier"
[matrix modifier]
(let [type (dm/get-prop modifier :type)]
@@ -652,8 +639,7 @@
:resize (transform-resize! matrix modifier)
:rotation (transform-rotate! matrix modifier))))
(defn modifiers->transform1
"A multiplatform version of modifiers->transform."
(defn- modifiers->transform1
[modifiers]
(reduce transform! (gmt/matrix) modifiers))
@@ -665,80 +651,28 @@
modifiers (sort-by #(dm/get-prop % :order) modifiers)]
(modifiers->transform1 modifiers)))
(defn modifiers->transform-old
"Given a set of modifiers returns its transformation matrix"
[modifiers]
(let [modifiers (->> (concat (dm/get-prop modifiers :geometry-parent)
(dm/get-prop modifiers :geometry-child))
(sort-by :order))]
(loop [matrix (gmt/matrix)
modifiers (seq modifiers)]
(if (c/empty? modifiers)
matrix
(let [modifier (first modifiers)
type (dm/get-prop modifier :type)
matrix
(case type
:move
(-> (dm/get-prop modifier :vector)
(gmt/translate-matrix)
(gmt/multiply! matrix))
:resize
(let [tf (dm/get-prop modifier :transform)
tfi (dm/get-prop modifier :transform-inverse)
vector (dm/get-prop modifier :vector)
origin (dm/get-prop modifier :origin)
origin (if ^boolean (some? tfi)
(gpt/transform origin tfi)
origin)]
(gmt/multiply!
(-> (gmt/matrix)
(cond-> ^boolean (some? tf)
(gmt/multiply! tf))
(gmt/translate! origin)
(gmt/scale! vector)
(gmt/translate! (gpt/negate origin))
(cond-> ^boolean (some? tfi)
(gmt/multiply! tfi)))
matrix))
:rotation
(let [center (dm/get-prop modifier :center)
rotation (dm/get-prop modifier :rotation)]
(gmt/multiply!
(-> (gmt/matrix)
(gmt/translate! center)
(gmt/multiply! (gmt/rotate-matrix rotation))
(gmt/translate! (gpt/negate center)))
matrix)))]
(recur matrix (next modifiers)))))))
(defn transform-text-node [value attrs]
(defn- transform-text-node [value attrs]
(let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str)
letter-spacing (-> (get attrs :letter-spacing 0) d/parse-double (* value) str)]
(d/txt-merge attrs {:font-size font-size
:letter-spacing letter-spacing})))
(defn transform-paragraph-node [value attrs]
(defn- transform-paragraph-node [value attrs]
(let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str)]
(d/txt-merge attrs {:font-size font-size})))
(defn update-text-content
(defn- update-text-content
[shape scale-text-content value]
(update shape :content scale-text-content value))
(defn scale-text-content
(defn- scale-text-content
[content value]
(->> content
(txt/transform-nodes txt/is-text-node? (partial transform-text-node value))
(txt/transform-nodes txt/is-paragraph-node? (partial transform-paragraph-node value))))
(defn apply-scale-content
(defn- apply-scale-content
[shape value]
;; Scale can only be positive
(let [value (mth/abs value)]
@@ -767,7 +701,7 @@
:always
(ctl/update-flex-child value))))
(defn remove-children-set
(defn- remove-children-set
[shapes children-to-remove]
(let [remove? (set children-to-remove)]
(d/removev remove? shapes)))

View File

@@ -47,8 +47,10 @@
[common-tests.types.path-data-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test]
[common-tests.types.shape-layout-test]
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
[common-tests.undo-stack-test]
[common-tests.uuid-test]))
#?(:cljs (enable-console-print!))
@@ -93,6 +95,7 @@
'common-tests.svg-test
'common-tests.text-test
'common-tests.time-test
'common-tests.undo-stack-test
'common-tests.types.absorb-assets-test
'common-tests.types.components-test
'common-tests.types.container-test
@@ -102,6 +105,7 @@
'common-tests.types.path-data-test
'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test
'common-tests.types.shape-layout-test
'common-tests.types.tokens-lib-test
'common-tests.types.token-test
'common-tests.uuid-test))

View File

@@ -8,7 +8,13 @@
(:require
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest modifiers->transform
@@ -24,3 +30,630 @@
transform (ctm/modifiers->transform modifiers)]
(t/is (not (gmt/close? (gmt/matrix) transform)))))
;; ─── Helpers ──────────────────────────────────────────────────────────────────
(defn- make-shape
"Build a minimal axis-aligned rect shape with the given geometry."
([width height]
(make-shape 0 0 width height))
([x y width height]
(cts/setup-shape {:type :rect :x x :y y :width width :height height})))
(defn- make-shape-with-proportion
"Build a shape with a fixed proportion ratio and proportion-lock enabled."
[width height]
(assoc (make-shape width height)
:proportion (/ (float width) (float height))
:proportion-lock true))
(defn- resize-op
"Extract the single resize GeometricOperation from geometry-child."
[modifiers]
(first (:geometry-child modifiers)))
;; ─── change-size ──────────────────────────────────────────────────────────────
(t/deftest change-size-basic
(t/testing "scales both axes to the requested dimensions"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "origin is the first point of the shape (top-left)"
(let [shape (make-shape 10 20 100 50)
mods (ctm/change-size shape 200 50)
origin (:origin (resize-op mods))]
(t/is (mth/close? 10.0 (:x origin)))
(t/is (mth/close? 20.0 (:y origin)))))
(t/testing "nil width falls back to current width, keeping x-scale at 1"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape nil 100)
op (resize-op mods)]
(t/is (mth/close? 1.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "nil height falls back to current height, keeping y-scale at 1"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape 200 nil)
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 1.0 (-> op :vector :y)))))
(t/testing "both nil produces an identity resize (scale 1,1)"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape nil nil)
op (resize-op mods)]
;; Identity resize is optimized away; geometry-child should be empty.
(t/is (empty? (:geometry-child mods)))))
(t/testing "transform and transform-inverse on a plain shape are both the identity matrix"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (gmt/close? (gmt/matrix) (:transform op)))
(t/is (gmt/close? (gmt/matrix) (:transform-inverse op))))))
;; ─── change-dimensions-modifiers ──────────────────────────────────────────────
(t/deftest change-dimensions-modifiers-no-lock
(t/testing "changing width only scales x-axis; y-scale stays 1"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :width 200)
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 1.0 (-> op :vector :y)))))
(t/testing "changing height only scales y-axis; x-scale stays 1"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :height 100)
op (resize-op mods)]
(t/is (mth/close? 1.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "origin is always the top-left point of the shape"
(let [shape (make-shape 30 40 100 50)
mods (ctm/change-dimensions-modifiers shape :width 200)
origin (:origin (resize-op mods))]
(t/is (mth/close? 30.0 (:x origin)))
(t/is (mth/close? 40.0 (:y origin))))))
(t/deftest change-dimensions-modifiers-with-proportion-lock
(t/testing "locking width also adjusts height by the inverse proportion"
;; shape 100x50 → proportion = 100/50 = 2
;; new width 200 → expected height = 200/2 = 100 → scaley = 100/50 = 2
(let [shape (make-shape-with-proportion 100 50)
mods (ctm/change-dimensions-modifiers shape :width 200)
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "locking height also adjusts width by the proportion"
;; shape 100x50 → proportion = 100/50 = 2
;; new height 100 → expected width = 100*2 = 200 → scalex = 200/100 = 2
(let [shape (make-shape-with-proportion 100 50)
mods (ctm/change-dimensions-modifiers shape :height 100)
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "ignore-lock? true bypasses proportion lock"
(let [shape (make-shape-with-proportion 100 50)
mods (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true})
op (resize-op mods)]
(t/is (mth/close? 2.0 (-> op :vector :x)))
;; Height should remain unchanged (scale = 1).
(t/is (mth/close? 1.0 (-> op :vector :y))))))
(t/deftest change-dimensions-modifiers-value-clamping
(t/testing "value below 0.01 is clamped to 0.01"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :width 0.001)
op (resize-op mods)]
;; 0.01 / 100 = 0.0001
(t/is (mth/close? 0.0001 (-> op :vector :x)))))
(t/testing "value of exactly 0 is clamped to 0.01"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :height 0)
op (resize-op mods)]
;; 0.01 / 50 = 0.0002
(t/is (mth/close? 0.0002 (-> op :vector :y))))))
(t/deftest change-dimensions-modifiers-end-to-end
(t/testing "applying change-width modifier produces the expected selrect width"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :width 300)
result (gsh/transform-shape (assoc shape :modifiers mods))]
(t/is (mth/close? 300.0 (-> result :selrect :width)))
(t/is (mth/close? 50.0 (-> result :selrect :height)))))
(t/testing "applying change-height modifier produces the expected selrect height"
(let [shape (make-shape 100 50)
mods (ctm/change-dimensions-modifiers shape :height 200)
result (gsh/transform-shape (assoc shape :modifiers mods))]
(t/is (mth/close? 100.0 (-> result :selrect :width)))
(t/is (mth/close? 200.0 (-> result :selrect :height))))))
;; ─── safe-size-rect fallbacks ─────────────────────────────────────────────────
(t/deftest safe-size-rect-fallbacks
(t/testing "valid selrect is returned as-is"
(let [shape (make-shape 100 50)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
;; scale 2,2 means the selrect was valid
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "zero-width selrect falls back to points, producing a valid rect"
;; Corrupt only the selrect dimensions; the shape's points remain valid.
(let [base (make-shape 100 50)
bad-selrect (assoc (:selrect base) :width 0 :height 0)
shape (assoc base :selrect bad-selrect)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (cts/shape? shape))
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "NaN selrect falls back to points"
(let [base (make-shape 100 50)
bad-selrect (assoc (:selrect base) :width ##NaN :height ##NaN)
shape (assoc base :selrect bad-selrect)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (cts/shape? shape))
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "selrect with dimensions exceeding max-safe-int falls back to points"
(let [base (make-shape 100 50)
bad-selrect (assoc (:selrect base) :width (inc sm/max-safe-int) :height (inc sm/max-safe-int))
shape (assoc base :selrect bad-selrect)
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (cts/shape? shape))
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "invalid selrect and no points falls back to top-level shape fields"
;; Null out both selrect and points; the top-level :x/:y/:width/:height
;; fields on the Shape record are still valid and serve as fallback 3.
(let [shape (-> (make-shape 100 50)
(assoc :selrect nil)
(assoc :points nil))
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (cts/shape? shape))
(t/is (mth/close? 2.0 (-> op :vector :x)))
(t/is (mth/close? 2.0 (-> op :vector :y)))))
(t/testing "all geometry missing: falls back to empty-rect (0.01 x 0.01)"
;; Null out selrect, points and the top-level dimension fields so that
;; every fallback is exhausted and empty-rect (0.01×0.01) is used.
(let [shape (-> (make-shape 100 50)
(assoc :selrect nil)
(assoc :points nil)
(assoc :width nil)
(assoc :height nil))
mods (ctm/change-size shape 200 100)
op (resize-op mods)]
(t/is (cts/shape? shape))
(t/is (mth/close? (/ 200 0.01) (-> op :vector :x)))
(t/is (mth/close? (/ 100 0.01) (-> op :vector :y))))))
;; ─── Builder functions: geometry-child ────────────────────────────────────────
(t/deftest move-builder
(t/testing "move adds an operation to geometry-child"
(let [mods (ctm/move (ctm/empty) (gpt/point 10 20))]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (= :move (-> mods :geometry-child first :type)))))
(t/testing "move with zero vector is optimised away"
(let [mods (ctm/move (ctm/empty) (gpt/point 0 0))]
(t/is (empty? (:geometry-child mods)))))
(t/testing "two consecutive moves on the same axis are merged into one"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 10 0))
(ctm/move (gpt/point 5 0)))]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (mth/close? 15.0 (-> mods :geometry-child first :vector :x)))))
(t/testing "move with x y arity delegates to vector arity"
(let [mods (ctm/move (ctm/empty) 3 7)]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (mth/close? 3.0 (-> mods :geometry-child first :vector :x)))
(t/is (mth/close? 7.0 (-> mods :geometry-child first :vector :y))))))
(t/deftest resize-builder
(t/testing "resize adds an operation to geometry-child"
(let [mods (ctm/resize (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (= :resize (-> mods :geometry-child first :type)))))
(t/testing "identity resize (scale 1,1) is optimised away"
(let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0))]
(t/is (empty? (:geometry-child mods)))))
(t/testing "precise? flag keeps near-identity resize"
(let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0)
nil nil {:precise? true})]
(t/is (= 1 (count (:geometry-child mods))))))
(t/testing "resize stores origin, transform and transform-inverse"
(let [tf (gmt/matrix)
tfi (gmt/matrix)
mods (ctm/resize (ctm/empty) (gpt/point 2 2) (gpt/point 5 10) tf tfi)
op (-> mods :geometry-child first)]
(t/is (mth/close? 5.0 (-> op :origin :x)))
(t/is (mth/close? 10.0 (-> op :origin :y)))
(t/is (gmt/close? tf (:transform op)))
(t/is (gmt/close? tfi (:transform-inverse op))))))
(t/deftest rotation-builder
(t/testing "rotation adds ops to both geometry-child and structure-child"
(let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 45)]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (= 1 (count (:structure-child mods))))
(t/is (= :rotation (-> mods :geometry-child first :type)))
(t/is (= :rotation (-> mods :structure-child first :type)))))
(t/testing "zero-angle rotation is optimised away"
(let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 0)]
(t/is (empty? (:geometry-child mods)))
(t/is (empty? (:structure-child mods))))))
;; ─── Builder functions: geometry-parent ───────────────────────────────────────
(t/deftest move-parent-builder
(t/testing "move-parent adds an operation to geometry-parent, not geometry-child"
(let [mods (ctm/move-parent (ctm/empty) (gpt/point 10 20))]
(t/is (= 1 (count (:geometry-parent mods))))
(t/is (empty? (:geometry-child mods)))
(t/is (= :move (-> mods :geometry-parent first :type)))))
(t/testing "move-parent with zero vector is optimised away"
(let [mods (ctm/move-parent (ctm/empty) (gpt/point 0 0))]
(t/is (empty? (:geometry-parent mods))))))
(t/deftest resize-parent-builder
(t/testing "resize-parent adds an operation to geometry-parent, not geometry-child"
(let [mods (ctm/resize-parent (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))]
(t/is (= 1 (count (:geometry-parent mods))))
(t/is (empty? (:geometry-child mods)))
(t/is (= :resize (-> mods :geometry-parent first :type))))))
;; ─── Builder functions: structure ─────────────────────────────────────────────
(t/deftest structure-builders
(t/testing "add-children appends an add-children op to structure-parent"
(let [id1 (uuid/next)
id2 (uuid/next)
mods (ctm/add-children (ctm/empty) [id1 id2] nil)]
(t/is (= 1 (count (:structure-parent mods))))
(t/is (= :add-children (-> mods :structure-parent first :type)))))
(t/testing "add-children with empty list is a no-op"
(let [mods (ctm/add-children (ctm/empty) [] nil)]
(t/is (empty? (:structure-parent mods)))))
(t/testing "remove-children appends a remove-children op to structure-parent"
(let [id (uuid/next)
mods (ctm/remove-children (ctm/empty) [id])]
(t/is (= 1 (count (:structure-parent mods))))
(t/is (= :remove-children (-> mods :structure-parent first :type)))))
(t/testing "remove-children with empty list is a no-op"
(let [mods (ctm/remove-children (ctm/empty) [])]
(t/is (empty? (:structure-parent mods)))))
(t/testing "reflow appends a reflow op to structure-parent"
(let [mods (ctm/reflow (ctm/empty))]
(t/is (= 1 (count (:structure-parent mods))))
(t/is (= :reflow (-> mods :structure-parent first :type)))))
(t/testing "scale-content appends a scale-content op to structure-child"
(let [mods (ctm/scale-content (ctm/empty) 2.0)]
(t/is (= 1 (count (:structure-child mods))))
(t/is (= :scale-content (-> mods :structure-child first :type)))))
(t/testing "change-property appends a change-property op to structure-parent"
(let [mods (ctm/change-property (ctm/empty) :opacity 0.5)]
(t/is (= 1 (count (:structure-parent mods))))
(t/is (= :change-property (-> mods :structure-parent first :type)))
(t/is (= :opacity (-> mods :structure-parent first :property)))
(t/is (= 0.5 (-> mods :structure-parent first :value))))))
;; ─── Convenience builders ─────────────────────────────────────────────────────
(t/deftest convenience-builders
(t/testing "move-modifiers returns a fresh Modifiers with a move in geometry-child"
(let [mods (ctm/move-modifiers (gpt/point 5 10))]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (= :move (-> mods :geometry-child first :type)))))
(t/testing "move-modifiers accepts x y arity"
(let [mods (ctm/move-modifiers 5 10)]
(t/is (= 1 (count (:geometry-child mods))))))
(t/testing "resize-modifiers returns a fresh Modifiers with a resize in geometry-child"
(let [mods (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0))]
(t/is (= 1 (count (:geometry-child mods))))
(t/is (= :resize (-> mods :geometry-child first :type)))))
(t/testing "reflow-modifiers returns a fresh Modifiers with a reflow in structure-parent"
(let [mods (ctm/reflow-modifiers)]
(t/is (= 1 (count (:structure-parent mods))))
(t/is (= :reflow (-> mods :structure-parent first :type)))))
(t/testing "rotation-modifiers returns move + rotation in geometry-child"
(let [shape (make-shape 100 100)
mods (ctm/rotation-modifiers shape (gpt/point 50 50) 90)]
;; rotation adds a :rotation and a :move to compensate for off-center
(t/is (pos? (count (:geometry-child mods))))
(t/is (some #(= :rotation (:type %)) (:geometry-child mods))))))
;; ─── add-modifiers ────────────────────────────────────────────────────────────
(t/deftest add-modifiers-combinator
(t/testing "combining two disjoint move modifiers sums the vectors"
(let [m1 (ctm/move-modifiers (gpt/point 10 0))
m2 (ctm/move-modifiers (gpt/point 5 0))
result (ctm/add-modifiers m1 m2)]
;; Both are pure geometry-child moves → they get merged
(t/is (= 1 (count (:geometry-child result))))
(t/is (mth/close? 15.0 (-> result :geometry-child first :vector :x)))))
(t/testing "nil first argument is treated as empty"
(let [m2 (ctm/move-modifiers (gpt/point 3 4))
result (ctm/add-modifiers nil m2)]
(t/is (= 1 (count (:geometry-child result))))))
(t/testing "nil second argument is treated as empty"
(let [m1 (ctm/move-modifiers (gpt/point 3 4))
result (ctm/add-modifiers m1 nil)]
(t/is (= 1 (count (:geometry-child result))))))
(t/testing "last-order is the sum of both modifiers' orders"
(let [m1 (ctm/move-modifiers (gpt/point 1 0))
m2 (ctm/move-modifiers (gpt/point 2 0))
result (ctm/add-modifiers m1 m2)]
(t/is (= (+ (:last-order m1) (:last-order m2))
(:last-order result))))))
;; ─── Predicates ───────────────────────────────────────────────────────────────
(t/deftest predicate-empty?
(t/testing "fresh empty modifiers is empty?"
(t/is (ctm/empty? (ctm/empty))))
(t/testing "modifiers with a move are not empty?"
(t/is (not (ctm/empty? (ctm/move-modifiers (gpt/point 1 0))))))
(t/testing "modifiers with only a structure op are not empty?"
(t/is (not (ctm/empty? (ctm/reflow-modifiers))))))
(t/deftest predicate-child-modifiers?
(t/testing "move in geometry-child → child-modifiers? true"
(t/is (ctm/child-modifiers? (ctm/move-modifiers (gpt/point 1 0)))))
(t/testing "scale-content in structure-child → child-modifiers? true"
(t/is (ctm/child-modifiers? (ctm/scale-content (ctm/empty) 2.0))))
(t/testing "move-parent in geometry-parent only → child-modifiers? false"
(t/is (not (ctm/child-modifiers? (ctm/move-parent (ctm/empty) (gpt/point 1 0)))))))
(t/deftest predicate-has-geometry?
(t/testing "move in geometry-child → has-geometry? true"
(t/is (ctm/has-geometry? (ctm/move-modifiers (gpt/point 1 0)))))
(t/testing "move-parent in geometry-parent → has-geometry? true"
(t/is (ctm/has-geometry? (ctm/move-parent (ctm/empty) (gpt/point 1 0)))))
(t/testing "only structure ops → has-geometry? false"
(t/is (not (ctm/has-geometry? (ctm/reflow-modifiers))))))
(t/deftest predicate-has-structure?
(t/testing "reflow op in structure-parent → has-structure? true"
(t/is (ctm/has-structure? (ctm/reflow-modifiers))))
(t/testing "scale-content in structure-child → has-structure? true"
(t/is (ctm/has-structure? (ctm/scale-content (ctm/empty) 2.0))))
(t/testing "only geometry ops → has-structure? false"
(t/is (not (ctm/has-structure? (ctm/move-modifiers (gpt/point 1 0)))))))
(t/deftest predicate-has-structure-child?
(t/testing "scale-content in structure-child → has-structure-child? true"
(t/is (ctm/has-structure-child? (ctm/scale-content (ctm/empty) 2.0))))
(t/testing "reflow in structure-parent only → has-structure-child? false"
(t/is (not (ctm/has-structure-child? (ctm/reflow-modifiers))))))
(t/deftest predicate-only-move?
(t/testing "pure move modifiers → only-move? true"
(t/is (ctm/only-move? (ctm/move-modifiers (gpt/point 1 0)))))
(t/testing "resize modifiers → only-move? false"
(t/is (not (ctm/only-move? (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0))))))
(t/testing "structure ops present → only-move? false"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 1 0))
(ctm/reflow))]
(t/is (not (ctm/only-move? mods)))))
(t/testing "empty modifiers → only-move? true (vacuously)"
(t/is (ctm/only-move? (ctm/empty)))))
;; ─── Projection functions ──────────────────────────────────────────────────────
(t/deftest projection-select-child
(t/testing "select-child keeps geometry-child and structure-child, clears parent"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 1 0))
(ctm/move-parent (gpt/point 2 0))
(ctm/reflow)
(ctm/scale-content 2.0))
result (ctm/select-child mods)]
(t/is (= 1 (count (:geometry-child result))))
(t/is (= 1 (count (:structure-child result))))
(t/is (empty? (:geometry-parent result)))
(t/is (empty? (:structure-parent result))))))
(t/deftest projection-select-parent
(t/testing "select-parent keeps geometry-parent and structure-parent, clears child"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 1 0))
(ctm/move-parent (gpt/point 2 0))
(ctm/reflow)
(ctm/scale-content 2.0))
result (ctm/select-parent mods)]
(t/is (= 1 (count (:geometry-parent result))))
(t/is (= 1 (count (:structure-parent result))))
(t/is (empty? (:geometry-child result)))
(t/is (empty? (:structure-child result))))))
(t/deftest projection-select-geometry
(t/testing "select-geometry keeps both geometry lists, clears structure"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 1 0))
(ctm/move-parent (gpt/point 2 0))
(ctm/reflow)
(ctm/scale-content 2.0))
result (ctm/select-geometry mods)]
(t/is (= 1 (count (:geometry-child result))))
(t/is (= 1 (count (:geometry-parent result))))
(t/is (empty? (:structure-child result)))
(t/is (empty? (:structure-parent result))))))
(t/deftest projection-select-child-structre-modifiers
(t/testing "select-child-structre-modifiers keeps only structure-child"
(let [mods (-> (ctm/empty)
(ctm/move (gpt/point 1 0))
(ctm/move-parent (gpt/point 2 0))
(ctm/reflow)
(ctm/scale-content 2.0))
result (ctm/select-child-structre-modifiers mods)]
(t/is (= 1 (count (:structure-child result))))
(t/is (empty? (:geometry-child result)))
(t/is (empty? (:geometry-parent result)))
(t/is (empty? (:structure-parent result))))))
;; ─── added-children-frames ────────────────────────────────────────────────────
(t/deftest added-children-frames-test
(t/testing "returns frame/shape pairs for add-children operations"
(let [frame-id (uuid/next)
shape-id (uuid/next)
mods (ctm/add-children (ctm/empty) [shape-id] nil)
tree {frame-id {:modifiers mods}}
result (ctm/added-children-frames tree)]
(t/is (= 1 (count result)))
(t/is (= frame-id (:frame (first result))))
(t/is (= shape-id (:shape (first result))))))
(t/testing "returns empty when there are no add-children operations"
(let [frame-id (uuid/next)
mods (ctm/reflow-modifiers)
tree {frame-id {:modifiers mods}}
result (ctm/added-children-frames tree)]
(t/is (empty? result))))
(t/testing "returns empty for an empty modif-tree"
(t/is (empty? (ctm/added-children-frames {})))))
;; ─── apply-modifier and apply-structure-modifiers ─────────────────────────────
(t/deftest apply-modifier-test
(t/testing "rotation op increments shape :rotation field"
(let [shape (make-shape 100 50)
op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 90)
:structure-child
first)
result (ctm/apply-modifier shape op)]
(t/is (mth/close? 90.0 (:rotation result)))))
(t/testing "rotation wraps around 360"
(let [shape (assoc (make-shape 100 50) :rotation 350)
op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 20)
:structure-child
first)
result (ctm/apply-modifier shape op)]
(t/is (mth/close? 10.0 (:rotation result)))))
(t/testing "add-children op appends ids to shape :shapes"
(let [id1 (uuid/next)
id2 (uuid/next)
shape (assoc (make-shape 100 50) :shapes [])
op (-> (ctm/add-children (ctm/empty) [id1 id2] nil)
:structure-parent
first)
result (ctm/apply-modifier shape op)]
(t/is (= [id1 id2] (:shapes result)))))
(t/testing "add-children op with index inserts at the given position"
(let [id-existing (uuid/next)
id-new (uuid/next)
shape (assoc (make-shape 100 50) :shapes [id-existing])
op (-> (ctm/add-children (ctm/empty) [id-new] 0)
:structure-parent
first)
result (ctm/apply-modifier shape op)]
(t/is (= id-new (first (:shapes result))))))
(t/testing "remove-children op removes given ids from shape :shapes"
(let [id1 (uuid/next)
id2 (uuid/next)
shape (assoc (make-shape 100 50) :shapes [id1 id2])
op (-> (ctm/remove-children (ctm/empty) [id1])
:structure-parent
first)
result (ctm/apply-modifier shape op)]
(t/is (= [id2] (:shapes result)))))
(t/testing "change-property op sets the property on the shape"
(let [shape (make-shape 100 50)
op (-> (ctm/change-property (ctm/empty) :opacity 0.5)
:structure-parent
first)
result (ctm/apply-modifier shape op)]
(t/is (= 0.5 (:opacity result)))))
(t/testing "unknown op type returns shape unchanged"
(let [shape (make-shape 100 50)
result (ctm/apply-modifier shape {:type :unknown})]
(t/is (= shape result)))))
(t/deftest apply-structure-modifiers-test
(t/testing "applies structure-parent and structure-child ops in order"
(let [id (uuid/next)
shape (assoc (make-shape 100 50) :shapes [])
mods (-> (ctm/empty)
(ctm/add-children [id] nil)
(ctm/scale-content 1.0))
result (ctm/apply-structure-modifiers shape mods)]
(t/is (= [id] (:shapes result)))))
(t/testing "empty modifiers returns shape unchanged"
(let [shape (make-shape 100 50)
result (ctm/apply-structure-modifiers shape (ctm/empty))]
(t/is (= shape result))))
(t/testing "change-property in structure-parent is applied"
(let [shape (make-shape 100 50)
mods (ctm/change-property (ctm/empty) :opacity 0.3)
result (ctm/apply-structure-modifiers shape mods)]
(t/is (= 0.3 (:opacity result)))))
(t/testing "rotation in structure-child is applied"
(let [shape (make-shape 100 50)
mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45)
result (ctm/apply-structure-modifiers shape mods)]
(t/is (mth/close? 45.0 (:rotation result))))))

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.undo-stack-test
(:require
[app.common.data.undo-stack :as sut]
[clojure.test :as t]))
;; --- make-stack ---
(t/deftest make-stack-creates-empty-stack
(let [stack (sut/make-stack)]
(t/is (= -1 (:index stack)))
(t/is (= [] (:items stack)))))
(t/deftest make-stack-returns-nil-on-peek
(t/is (nil? (sut/peek (sut/make-stack)))))
(t/deftest make-stack-size-is-zero
(t/is (= 0 (sut/size (sut/make-stack)))))
;; --- peek ---
(t/deftest peek-empty-stack
(t/is (nil? (sut/peek (sut/make-stack)))))
(t/deftest peek-after-append
(let [stack (-> (sut/make-stack)
(sut/append :a))]
(t/is (= :a (sut/peek stack)))))
(t/deftest peek-multiple-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= :c (sut/peek stack)))))
(t/deftest peek-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo))]
(t/is (= :a (sut/peek stack)))))
;; --- append ---
(t/deftest append-to-nil-stack
(t/is (nil? (sut/append nil :a))))
(t/deftest append-single-item
(let [stack (-> (sut/make-stack)
(sut/append :a))]
(t/is (= 0 (:index stack)))
(t/is (= [:a] (:items stack)))))
(t/deftest append-multiple-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :c] (:items stack)))))
(t/deftest append-duplicate-at-current-index-ignored
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :a))]
(t/is (= 0 (:index stack)))
(t/is (= [:a] (:items stack)))))
(t/deftest append-duplicate-after-other-operations
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/append :a))]
;; appending :a when current is :a should be a no-op
(t/is (= 0 (:index stack)))
(t/is (= [:a :b] (:items stack)))))
(t/deftest append-same-value-at-different-positions-allowed
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :a))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :a] (:items stack)))))
(t/deftest append-nil-value-returns-unchanged
;; appending nil when peek is nil returns stack unchanged
(let [stack (sut/make-stack)
result (sut/append stack nil)]
(t/is (= stack result))))
(t/deftest append-complex-values
(let [v1 {:id 1 :name "shape"}
v2 {:id 2 :name "rect"}
stack (-> (sut/make-stack)
(sut/append v1)
(sut/append v2))]
(t/is (= 1 (:index stack)))
(t/is (= v2 (sut/peek stack)))))
;; --- append truncates redo history ---
(t/deftest append-truncates-redo-history-at-index-greater-than-zero
;; Truncation only happens when index > 0
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d)
(sut/undo) ;; index -> 2, peek :c
(sut/append :e))] ;; index > 0, truncates :d
(t/is (= 3 (:index stack)))
(t/is (= [:a :b :c :e] (:items stack)))
(t/is (= :e (sut/peek stack)))))
(t/deftest append-at-index-zero-does-not-truncate
;; When index is 0, append just adds to end without truncation
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/append :d))]
;; index 0, no truncation: items become [:a :b :c :d]
(t/is (= 1 (:index stack)))
(t/is (= [:a :b :c :d] (:items stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest append-truncates-multiple-redo-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d)
(sut/append :e)
(sut/undo) ;; index -> 3, peek :d
(sut/undo) ;; index -> 2, peek :c
(sut/append :x))]
(t/is (= 3 (:index stack)))
(t/is (= [:a :b :c :x] (:items stack)))))
;; --- append respects MAX-UNDO-SIZE ---
(t/deftest append-max-undo-size-boundary
(let [;; Fill stack to MAX-UNDO-SIZE items
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range sut/MAX-UNDO-SIZE))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
(t/is (= "item-0" (first (:items stack))))
(t/is (= (str "item-" (dec sut/MAX-UNDO-SIZE)) (sut/peek stack)))))
(t/deftest append-exceeds-max-undo-size-removes-oldest
(let [;; Fill stack to MAX-UNDO-SIZE + 1
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range (inc sut/MAX-UNDO-SIZE)))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
;; Oldest item was removed
(t/is (= "item-1" (first (:items stack))))
(t/is (= (str "item-" sut/MAX-UNDO-SIZE) (sut/peek stack)))))
(t/deftest append-far-exceeds-max-undo-size
(let [;; Fill stack to MAX-UNDO-SIZE + 10
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range (+ sut/MAX-UNDO-SIZE 10)))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
(t/is (= "item-10" (first (:items stack))))))
;; --- fixup ---
(t/deftest fixup-updates-current-item
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/fixup :a-updated))]
(t/is (= :a-updated (sut/peek stack)))
(t/is (= 0 (:index stack)))))
(t/deftest fixup-at-middle-of-stack
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/fixup :b-updated))]
(t/is (= :b-updated (sut/peek stack)))
(t/is (= [:a :b-updated :c] (:items stack)))))
(t/deftest fixup-preserves-index
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo) ;; index -> 0
(sut/fixup :a-new))]
(t/is (= 0 (:index stack)))
(t/is (= :a-new (sut/peek stack)))))
;; --- undo ---
(t/deftest undo-single-item
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-clamps-to-zero
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/undo)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-multiple-steps
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-on-empty-stack
;; undo on empty stack clamps index to 0 (from -1, dec gives -2, max with 0 gives 0)
(let [stack (-> (sut/make-stack)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (nil? (sut/peek stack)))))
(t/deftest undo-preserves-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo))]
(t/is (= [:a :b] (:items stack)))))
;; --- redo ---
(t/deftest redo-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/redo))]
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest redo-at-end-of-stack-no-op
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/redo))]
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest redo-multiple-steps
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/redo) ;; index -> 1
(sut/redo))] ;; index -> 2
(t/is (= 2 (:index stack)))
(t/is (= :c (sut/peek stack)))))
(t/deftest redo-on-empty-stack
(let [stack (-> (sut/make-stack)
(sut/redo))]
(t/is (= -1 (:index stack)))))
(t/deftest redo-not-available-after-truncating-append
;; When index > 0, append truncates redo history
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/append :x) ;; truncates :c, items [:a :b :x]
(sut/redo))]
;; Redo should not work since redo history was truncated
(t/is (= 2 (:index stack)))
(t/is (= :x (sut/peek stack)))))
(t/deftest redo-after-append-at-index-zero
;; When index is 0, append does not truncate, so old items remain
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/append :x) ;; index 0, no truncation: [:a :b :c :x]
(sut/redo))]
;; redo goes from index 1 to 2, which is :c
(t/is (= 2 (:index stack)))
(t/is (= :c (sut/peek stack)))))
;; --- size ---
(t/deftest size-empty-stack
(t/is (= 0 (sut/size (sut/make-stack)))))
(t/deftest size-after-appends
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= 3 (sut/size stack)))))
(t/deftest size-unchanged-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo))]
;; size returns (inc index), not count of items
(t/is (= 2 (sut/size stack)))))
(t/deftest size-is-undo-position
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo)
(sut/undo))]
(t/is (= 1 (sut/size stack)))))
;; --- undo/redo round-trip ---
(t/deftest undo-redo-round-trip
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= :c (sut/peek stack)))
(let [s (sut/undo stack)]
(t/is (= :b (sut/peek s)))
(let [s (sut/undo s)]
(t/is (= :a (sut/peek s)))
(let [s (sut/undo s)]
;; At index 0, undo should clamp
(t/is (= :a (sut/peek s)))
(t/is (= 0 (:index s)))
(let [s (sut/redo s)]
(t/is (= :b (sut/peek s)))
(let [s (sut/redo s)]
(t/is (= :c (sut/peek s)))
(let [s (sut/redo s)]
;; At end, redo should be no-op
(t/is (= :c (sut/peek s)))))))))))
;; --- mixed operations ---
(t/deftest append-after-undo-then-redo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/undo)
(sut/redo)
(sut/append :c))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :c] (:items stack)))
(t/is (= :c (sut/peek stack)))))
(t/deftest fixup-then-append
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/fixup :a-fixed)
(sut/append :b))]
(t/is (= [:a-fixed :b] (:items stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest append-identical-different-objects
(let [m1 {:x 1}
m2 {:x 1}
stack (-> (sut/make-stack)
(sut/append m1)
(sut/append m2))]
;; Maps are equal, so second append should be a no-op
(t/is (= 0 (:index stack)))
(t/is (= [m1] (:items stack)))))
(t/deftest append-maps-not-equal
(let [m1 {:x 1}
m2 {:x 2}
stack (-> (sut/make-stack)
(sut/append m1)
(sut/append m2))]
(t/is (= 1 (:index stack)))
(t/is (= [m1 m2] (:items stack)))))
(t/deftest sequential-fixup-and-undo
(let [stack (-> (sut/make-stack)
(sut/append {:id 1 :val "old"})
(sut/append {:id 2 :val "old"})
(sut/fixup {:id 2 :val "new"})
(sut/undo)
(sut/fixup {:id 1 :val "updated"}))]
(t/is (= {:id 1 :val "updated"} (sut/peek stack)))
(t/is (= [{:id 1 :val "updated"} {:id 2 :val "new"}] (:items stack)))))
(t/deftest append-undo-append-undo-cycle
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c) ;; index 2
(sut/undo) ;; index 1, peek :b
(sut/append :d) ;; index 1 > 0, truncates :c, items [:a :b :d]
(sut/append :e) ;; items [:a :b :d :e]
(sut/undo) ;; index 2, peek :d
(sut/undo))] ;; index 1, peek :b
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))
(t/is (= [:a :b :d :e] (:items stack)))))
(t/deftest size-grows-then-shrinks-with-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d))]
(t/is (= 4 (sut/size stack)))
(let [s (sut/undo stack)]
(t/is (= 3 (sut/size s)))
(let [s (sut/undo s)]
(t/is (= 2 (sut/size s)))
(let [s (sut/redo s)]
(t/is (= 3 (sut/size s))))))))

View File

@@ -9,6 +9,10 @@ localhost:3449 {
}
http://localhost:3450 {
# For subpath test
# handle_path /penpot/* {
# reverse_proxy localhost:4449
# }
reverse_proxy localhost:4449
}

View File

@@ -5,10 +5,6 @@ architectural pieces.
## General Guidelines
To ensure consistency across the Penpot stack, all contributions must adhere to
these criteria:
### 1. Testing & Validation
#### Unit Tests
@@ -71,6 +67,29 @@ Ensure everything is installed before executing tests with the `./scripts/setup`
function in the same namespace if it is only used locally, or look for a helper
namespace to make it unit-testable.
### 4. Stack Trace Analysis
When analyzing production stack traces (minified code), you can generate a
production bundle locally to map the minified code back to the source.
**To build the production bundle:**
Run: `pnpm run build:app`
The compiled files and their corresponding source maps will be generated in
`resources/public/js`.
**Analysis Tips:**
- **Source Maps:** Use the `.map` files generated in `resources/public/js` with
tools like `source-map-lookup` or browser dev tools to resolve minified
locations.
- **Bundle Inspection:** If the issue is related to bundle size or unexpected
code inclusion, inspect the generated modules in `resources/public/js`.
- **Shadow-CLJS Reports:** For more detailed analysis of what is included in the
bundle, you can run shadow-cljs build reports (consult `shadow-cljs.edn` for
build IDs like `main` or `worker`).
## Code Conventions

View File

@@ -239,12 +239,11 @@
([media]
(resolve-file-media media false))
([{:keys [id data-uri] :as media} thumbnail?]
(if data-uri
data-uri
(dm/str
(cond-> (u/join public-uri "assets/by-file-media-id/")
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
(false? thumbnail?) (u/join (dm/str id)))))))
(or data-uri
(dm/str
(cond-> (u/join public-uri "assets/by-file-media-id/")
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
(false? thumbnail?) (u/join (dm/str id)))))))
(defn resolve-href
[resource]

View File

@@ -623,27 +623,30 @@
(assoc-in [:workspace-global :default-font] data))))))
(defn apply-text-modifier
[shape {:keys [width height position-data]}]
[shape text-modifier]
(let [new-shape
(cond-> shape
(some? width)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true}))
(if (some? text-modifier)
(let [{:keys [width height position-data]} text-modifier
new-shape
(cond-> shape
(some? width)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true}))
(some? height)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true}))
(some? height)
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true}))
(some? position-data)
(assoc :position-data position-data))
(some? position-data)
(assoc :position-data position-data))
delta-move
(gpt/subtract (gpt/point (:selrect new-shape))
(gpt/point (:selrect shape)))
delta-move
(gpt/subtract (gpt/point (ctm/safe-size-rect new-shape))
(gpt/point (ctm/safe-size-rect shape)))
new-shape
(update new-shape :position-data gsh/move-position-data delta-move)]
new-shape
(update new-shape :position-data gsh/move-position-data delta-move)]
new-shape))
new-shape)
shape))
(defn commit-update-text-modifier
[]

View File

@@ -395,10 +395,11 @@
(= message "Unexpected end of input")
(str/starts-with? message "invalid props on component")
(str/starts-with? message "Unexpected token ")
;; Abort errors are expected when an in-flight HTTP request is
;; cancelled (e.g. via RxJS unsubscription / take-until). They
;; are handled gracefully inside app.util.http/fetch and must
;; NOT be surfaced as application errors.
;; Native AbortError DOMException: raised when an in-flight
;; HTTP fetch is cancelled via AbortController (e.g. by an
;; RxJS unsubscription / take-until chain). These are
;; handled gracefully inside app.util.http/fetch and must NOT
;; be surfaced as application errors.
(= (.-name ^js cause) "AbortError"))))
(on-unhandled-error [event]

View File

@@ -108,7 +108,7 @@
:code :challenge-required}))
(and (>= status 400) (map? body))
(rx/throw (ex-info "http error" body))
(rx/throw (ex-info "http error" (assoc body :uri uri :status status)))
:else
(rx/throw

View File

@@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.uri :as u]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
@@ -95,8 +96,8 @@
(mf/defc card-item
{::mf/wrap-props false}
[{:keys [item index is-visible collapsed on-import]}]
(let [id (dm/str "card-container-" index)
thb (assoc cf/public-uri :path (dm/str "/images/thumbnails/template-" (:id item) ".jpg"))
(let [id (dm/str "card-container-" index)
href (u/join cf/public-uri (dm/str "images/thumbnails/template-" (:id item) ".jpg"))
hover? (mf/use-state false)
on-click
@@ -124,7 +125,7 @@
:on-mouse-leave #(reset! hover? false)
:on-key-down on-key-down}
[:div {:class (stl/css :img-container)}
[:img {:src (dm/str thb)
[:img {:src (dm/str href)
:alt (:name item)
:loading "lazy"
:decoding "async"}]]

View File

@@ -27,7 +27,7 @@
text-modifier
(mf/deref text-modifier-ref)
shape (if (some? shape)
shape (if (and (some? shape) (some? text-modifier))
(dwt/apply-text-modifier shape text-modifier)
shape)]

View File

@@ -32,7 +32,7 @@
(defn- google-font-id->uuid
"Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the
font is not found in fontsdb. uuid/zero maps to the default font (Source
font is not found in fontsdb. uuid/zero maps to the default font (Source
Sans Pro) in WASM.
A font id may not exist for different reasons:
- the gfonts.json catalog was updated and fonts were renamed or removed,
@@ -158,7 +158,7 @@
[font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/"))
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/"))
nil)))
(defn- font-id->ttf-url

View File

@@ -126,11 +126,17 @@
(fn []
(vreset! unsubscribed? true)
(when @abortable?
;; Provide an explicit reason so that the resulting AbortError carries
;; a meaningful message instead of the browser default
;; "signal is aborted without reason".
(.abort ^js controller (ex-info (str "fetch to '" uri "' is aborted")
{:uri uri}))))))))
;; Do NOT pass a custom reason to .abort(): browsers that support
;; AbortController reason (Chrome 98+, Firefox 97+) would reject
;; the fetch promise with the supplied value directly. When that
;; value is a ClojureScript ExceptionInfo its `.name` property is
;; "Error", not "AbortError", which defeats every existing guard
;; that checks `(= (.-name cause) "AbortError")`. Calling .abort
;; without a reason always produces a native DOMException whose
;; `.name` is "AbortError", which is correctly recognised and
;; suppressed by both the p/catch handler and the global
;; unhandled-exception filter.
(.abort ^js controller)))))))
(defn response->map
[response]

View File

@@ -0,0 +1,274 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.data.workspace-texts-test
(:require
[app.common.geom.rect :as grc]
[app.common.types.shape :as cts]
[app.main.data.workspace.texts :as dwt]
[cljs.test :as t :include-macros true]))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
(defn- make-text-shape
"Build a fully initialised text shape at the given position."
[& {:keys [x y width height position-data]
:or {x 10 y 20 width 100 height 50}}]
(cond-> (cts/setup-shape {:type :text
:x x
:y y
:width width
:height height})
(some? position-data)
(assoc :position-data position-data)))
(defn- make-degenerate-text-shape
"Simulate a text shape decoded from the server via map->Rect (which bypasses
make-rect's 0.01 minimum enforcement), giving it a zero-width / zero-height
selrect. This is the exact condition that triggered the original crash:
change-dimensions-modifiers divided by sr-width (== 0), producing an Infinity
scale factor that propagated through the transform pipeline until
calculate-selrect / center->rect returned nil, and then gpt/point threw
'invalid arguments (on pointer constructor)'."
[& {:keys [x y width height]
:or {x 10 y 20 width 0 height 0}}]
(-> (make-text-shape :x x :y y :width 100 :height 50)
;; Bypass make-rect by constructing the Rect record directly, the same
;; way decode-rect does during JSON deserialization from the backend.
(assoc :selrect (grc/map->Rect {:x x :y y
:width width :height height
:x1 x :y1 y
:x2 (+ x width) :y2 (+ y height)}))))
(defn- sample-position-data
"Return a minimal position-data vector with the supplied coords."
[x y]
[{:x x :y y :width 80 :height 16 :fills [] :text "hello"}])
;; ---------------------------------------------------------------------------
;; Tests: nil / no-op guard
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-nil-modifier-returns-shape-unchanged
(t/testing "nil text-modifier returns the original shape untouched"
(let [shape (make-text-shape)
result (dwt/apply-text-modifier shape nil)]
(t/is (= shape result)))))
(t/deftest apply-text-modifier-empty-map-no-keys-returns-shape-unchanged
(t/testing "modifier with no recognised keys leaves shape unchanged"
(let [shape (make-text-shape)
modifier {}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= (:selrect shape) (:selrect result)))
(t/is (= (:width result) (:width shape)))
(t/is (= (:height result) (:height shape))))))
;; ---------------------------------------------------------------------------
;; Tests: width modifier
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-width-changes-shape-width
(t/testing "width modifier resizes the shape width"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:width 200}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= 200.0 (-> result :selrect :width))))))
(t/deftest apply-text-modifier-width-nil-skips-width-change
(t/testing "nil :width in modifier does not alter the width"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:width nil}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= (-> shape :selrect :width) (-> result :selrect :width))))))
;; ---------------------------------------------------------------------------
;; Tests: height modifier
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-height-changes-shape-height
(t/testing "height modifier resizes the shape height"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:height 120}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= 120.0 (-> result :selrect :height))))))
(t/deftest apply-text-modifier-height-nil-skips-height-change
(t/testing "nil :height in modifier does not alter the height"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:height nil}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= (-> shape :selrect :height) (-> result :selrect :height))))))
;; ---------------------------------------------------------------------------
;; Tests: width + height together
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-width-and-height-both-applied
(t/testing "both width and height are applied simultaneously"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:width 300 :height 80}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= 300.0 (-> result :selrect :width)))
(t/is (= 80.0 (-> result :selrect :height))))))
;; ---------------------------------------------------------------------------
;; Tests: position-data modifier
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-position-data-is-set-on-shape
(t/testing "position-data modifier replaces the position-data on shape"
(let [pd (sample-position-data 5 10)
shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:position-data pd}
result (dwt/apply-text-modifier shape modifier)]
(t/is (some? (:position-data result))))))
(t/deftest apply-text-modifier-position-data-nil-leaves-position-data-unchanged
(t/testing "nil :position-data in modifier does not alter position-data"
(let [pd (sample-position-data 5 10)
shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50)
(assoc :position-data pd))
modifier {:position-data nil}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= pd (:position-data result))))))
;; ---------------------------------------------------------------------------
;; Tests: position-data is translated by delta when shape moves
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-position-data-translated-on-resize
(t/testing "position-data x/y is adjusted by the delta of the selrect origin"
(let [pd (sample-position-data 10 20)
shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50)
(assoc :position-data pd))
;; Only set position-data; no resize so no origin shift expected
modifier {:position-data pd}
result (dwt/apply-text-modifier shape modifier)]
;; Delta should be zero (no dimension change), so coords stay the same
(t/is (= 10.0 (-> result :position-data first :x)))
(t/is (= 20.0 (-> result :position-data first :y))))))
(t/deftest apply-text-modifier-position-data-not-translated-when-nil
(t/testing "nil position-data on result after modifier is left as nil"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:width 200}
result (dwt/apply-text-modifier shape modifier)]
;; shape had no position-data; modifier doesn't set one — stays nil
(t/is (nil? (:position-data result))))))
;; ---------------------------------------------------------------------------
;; Tests: degenerate selrect (zero width or height decoded from the server)
;;
;; Root cause of the original crash:
;; change-dimensions-modifiers divided by (:width selrect) or (:height selrect)
;; which is 0 when the shape was decoded via map->Rect (bypassing make-rect's
;; 0.01 minimum), producing Infinity → transform pipeline returned nil selrect
;; → gpt/point threw "invalid arguments (on pointer constructor)".
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-zero-width-selrect-does-not-throw
(t/testing "width modifier on a shape with zero selrect width does not throw"
;; Simulates a shape received from the server whose selrect has width=0
;; (map->Rect bypasses the 0.01 floor of make-rect).
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
modifier {:width 200}
result (dwt/apply-text-modifier shape modifier)]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest apply-text-modifier-zero-height-selrect-does-not-throw
(t/testing "height modifier on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
modifier {:height 80}
result (dwt/apply-text-modifier shape modifier)]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest apply-text-modifier-zero-width-and-height-selrect-does-not-throw
(t/testing "both modifiers on a fully-degenerate selrect do not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
modifier {:width 150 :height 60}
result (dwt/apply-text-modifier shape modifier)]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest apply-text-modifier-zero-width-selrect-result-has-correct-width
(t/testing "applying width modifier to a zero-width shape yields the requested width"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
modifier {:width 200}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= 200.0 (-> result :selrect :width))))))
(t/deftest apply-text-modifier-zero-height-selrect-result-has-correct-height
(t/testing "applying height modifier to a zero-height shape yields the requested height"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
modifier {:height 80}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= 80.0 (-> result :selrect :height))))))
(t/deftest apply-text-modifier-nil-modifier-on-degenerate-shape-returns-unchanged
(t/testing "nil modifier on a zero-selrect shape returns the same shape"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
result (dwt/apply-text-modifier shape nil)]
(t/is (identical? shape result)))))
;; ---------------------------------------------------------------------------
;; Tests: shape origin is preserved when there is no dimension change
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-selrect-origin-preserved-without-resize
(t/testing "selrect x/y origin does not shift when no dimension changes"
(let [shape (make-text-shape :x 30 :y 40 :width 100 :height 50)
modifier {:position-data (sample-position-data 30 40)}
result (dwt/apply-text-modifier shape modifier)]
(t/is (= (-> shape :selrect :x) (-> result :selrect :x)))
(t/is (= (-> shape :selrect :y) (-> result :selrect :y))))))
;; ---------------------------------------------------------------------------
;; Tests: returned shape is a proper map-like value
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-returns-shape-with-required-keys
(t/testing "result always contains the core shape keys"
(let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50)
modifier {:width 200 :height 80}
result (dwt/apply-text-modifier shape modifier)]
(t/is (some? (:id result)))
(t/is (some? (:type result)))
(t/is (some? (:selrect result))))))
(t/deftest apply-text-modifier-nil-modifier-returns-same-identity
(t/testing "nil modifier returns the exact same shape object (identity)"
(let [shape (make-text-shape)]
(t/is (identical? shape (dwt/apply-text-modifier shape nil))))))
;; ---------------------------------------------------------------------------
;; Tests: delta-move computation does not throw on degenerate selrect
;;
;; The delta-move in apply-text-modifier calls gpt/point on both the
;; original and new shape selrects. gpt/point throws when given a
;; non-point-like value (nil, or a map with non-finite :x/:y). Using
;; ctm/safe-size-rect instead of raw (:selrect …) access ensures a valid
;; rect is always available for that computation.
;; ---------------------------------------------------------------------------
(t/deftest apply-text-modifier-position-data-with-degenerate-selrect-does-not-throw
(t/testing "position-data modifier on a zero-selrect shape does not throw"
(let [pd (sample-position-data 5 10)
shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
result (dwt/apply-text-modifier shape {:position-data pd})]
(t/is (some? result))
(t/is (= pd (:position-data result)))))
(t/testing "width + position-data modifier on a zero-selrect shape does not throw"
(let [pd (sample-position-data 5 10)
shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
result (dwt/apply-text-modifier shape {:width 200 :position-data pd})]
(t/is (some? result))
(t/is (some? (:selrect result))))))

View File

@@ -4,6 +4,7 @@
[frontend-tests.basic-shapes-test]
[frontend-tests.data.repo-test]
[frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-texts-test]
[frontend-tests.helpers-shapes-test]
[frontend-tests.logic.comp-remove-swap-slots-test]
[frontend-tests.logic.components-and-tokens]
@@ -38,6 +39,7 @@
'frontend-tests.basic-shapes-test
'frontend-tests.data.repo-test
'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-texts-test
'frontend-tests.helpers-shapes-test
'frontend-tests.logic.comp-remove-swap-slots-test
'frontend-tests.logic.components-and-tokens

View File

@@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@@ -12,12 +12,13 @@
"type": "module",
"scripts": {
"lint": "./scripts/lint",
"check-fmt": "./scripts/check-fmt",
"fmt": "./scripts/fmt"
},
"devDependencies": {
"@github/copilot": "^1.0.12",
"@types/node": "^20.12.7",
"esbuild": "^0.25.9",
"esbuild": "^0.27.4",
"opencode-ai": "^1.3.7"
}
}

218
pnpm-lock.yaml generated
View File

@@ -15,166 +15,166 @@ importers:
specifier: ^20.12.7
version: 20.19.37
esbuild:
specifier: ^0.25.9
version: 0.25.12
specifier: ^0.27.4
version: 0.27.4
opencode-ai:
specifier: ^1.3.7
version: 1.3.7
packages:
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -222,8 +222,8 @@ packages:
'@types/node@20.19.37':
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
@@ -296,82 +296,82 @@ packages:
snapshots:
'@esbuild/aix-ppc64@0.25.12':
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.25.12':
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.25.12':
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.25.12':
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.25.12':
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.25.12':
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.25.12':
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.25.12':
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.25.12':
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.25.12':
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.25.12':
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.25.12':
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.25.12':
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.25.12':
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.25.12':
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.25.12':
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.25.12':
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.25.12':
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.25.12':
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.25.12':
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.25.12':
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.25.12':
'@esbuild/win32-x64@0.27.4':
optional: true
'@github/copilot-darwin-arm64@1.0.12':
@@ -405,34 +405,34 @@ snapshots:
dependencies:
undici-types: 6.21.0
esbuild@0.25.12:
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
opencode-ai@1.3.7:
optionalDependencies: