From e2740f3be7bb258f4f0197ab1898ad2f71e8b711 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 22 Oct 2026 14:36:25 +0200 Subject: [PATCH] :bug: Preserve scaled content styles through component sync Store cumulative content scale as an optional shape attribute, default 1. Sync scales the main's styles by the copy/main ratio separately from constraint resizing. Keep descendants untouched and allow later main style edits to propagate (F109). Both renderer modifier paths use this field. Cover a later sync, repeated scaling, main radius edits, reset and its undo. The original stroke regression failed before the fix. Common JVM/JS and frontend suites, backend and exporter tests, lint and format checks pass. Historical scale gestures cannot be reconstructed from old file data. Benchmark vs Phase 1e (lower quantile, ms): world baseline now ratio main-10 1.790 0.993 0.55 main-100 15.415 9.038 0.59 main-1000 153.086 90.145 0.59 three-levels 35.215 18.989 0.54 chain 68.931 35.045 0.51 library-500 83.083 85.830 1.03 many-shapes 4.837 3.344 0.69 variant-100 32.052 33.586 1.05 All worlds pass the 10% regression threshold. AI-assisted-by: GPT-6 --- .../memories/common/component-data-model.md | 9 +++++- .../common/component-sync-contract.md | 5 +++ common/src/app/common/logic/libraries.cljc | 11 +++++-- common/src/app/common/logic/placement.cljc | 1 + common/src/app/common/types/component.cljc | 1 + common/src/app/common/types/modifiers.cljc | 9 +++++- common/src/app/common/types/shape.cljc | 3 +- .../logic/comp_expected_geometry_test.cljc | 32 +++++++++++++++++-- frontend/src/app/main/ui/shapes/export.cljs | 1 + 9 files changed, 65 insertions(+), 7 deletions(-) diff --git a/.serena/memories/common/component-data-model.md b/.serena/memories/common/component-data-model.md index 0a176a77c1..2ee13c9176 100644 --- a/.serena/memories/common/component-data-model.md +++ b/.serena/memories/common/component-data-model.md @@ -43,6 +43,13 @@ Masters are not normally touched through `set-shape-attr`, but touched flags can With `:main-instance? true` (library absorption), nested copies keep their `:touched` and `:remote-synced`; they remain copies inside the new main. +`:content-scale` is an optional positive shape number (absent means 1), +accumulated by scale-content transforms on both renderers. It belongs to +`:geometry-group`, survives clone/serialization, and leaves the data when +the accumulated scale returns to 1. Sync derives styles from the ratio of +the copy's scale to its main's, separately from constraint resizing. +Old files default to 1; historical scale gestures cannot be reconstructed. + `duplicate-component` in `app.common.logic.duplicate` creates a new component master by cloning existing component shapes, setting component metadata, and applying a position delta. It does not have the same clean-copy semantics as `make-component-instance`, so inherited attrs on the source can matter. When a bug depends on touched state, identify which cloning path produced the shape before changing sync logic. @@ -53,4 +60,4 @@ A variant container is a frame with `:is-variant-container true`. Its children a Predicates are broad: `ctk/is-variant?` checks `:variant-id` and applies to both variant master shapes and component rows; `ctk/is-variant-container?` checks the container shape flag. -Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent. \ No newline at end of file +Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent. diff --git a/.serena/memories/common/component-sync-contract.md b/.serena/memories/common/component-sync-contract.md index 7541185de4..27feb413fc 100644 --- a/.serena/memories/common/component-sync-contract.md +++ b/.serena/memories/common/component-sync-contract.md @@ -46,6 +46,11 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w ## Geometry ownership - Copy root: position is its own; rotation/flip belong to it only when its `:geometry-group` is touched. Resizing it touches the root only. +- Content scaling is distinct from constraint resize. `:content-scale` + stores the accumulated scale (default 1); expected placement scales + styles by copy/main scale before syncing. Descendants remain untouched; + later main style edits still reach them at that scale. Reset restores + the near main's scale together with its geometry and styles. - Children of a resized or transformed shape: derived (expected geometry), untouched, and still follow later main edits. Code: a transform touches only the shapes the user transformed (`clp/calculate-ignore-tree` takes their ids; the frontend keeps them in `:workspace-modifiers-ids`); sync places the main subtree of a copy shape with its own geometry like that shape before walking its children. - Layout-owned (never touched; sync writes, then reflow): x/y of non-absolute children of flex/grid frames; width/height on axes sized fill, or auto on a layout frame. - Grow-owned (never touched; sync writes, then re-measure on SVG and WASM): width+height of auto-width text, height of auto-height text. Every editor and resize path writes the measured size as derived; on WASM `sync-file` re-measures the auto-grow texts of the current page it wrote (texts on other pages keep the synced size until measured). diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index df71223675..3bdc5a2622 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -327,7 +327,8 @@ (defn- close-geometry? [shape other] (and (gsh/close-attrs? :selrect (:selrect shape) (:selrect other)) - (gsh/close-attrs? :points (:points shape) (:points other)))) + (gsh/close-attrs? :points (:points shape) (:points other)) + (mth/close? (:content-scale shape 1) (:content-scale other 1)))) (defn- placed-like "The main `objects` with the subtree of `shape-main` placed like `placed`, @@ -335,7 +336,8 @@ children following through their constraints and layouts, then turned and moved with it as one piece." [objects shape-main placed] - (let [width (-> placed :selrect :width) + (let [scale (/ (:content-scale placed 1) (:content-scale shape-main 1)) + width (-> placed :selrect :width) height (-> placed :selrect :height) objects (if (and (mth/close? width (-> shape-main :selrect :width)) (mth/close? height (-> shape-main :selrect :height))) @@ -348,6 +350,10 @@ objects modif-tree))) ids (cfh/get-children-ids-with-self objects (:id shape-main)) + objects (if (mth/close? scale 1) + objects + (let [modifiers (ctm/scale-content (ctm/empty) scale)] + (reduce #(update %1 %2 gsh/transform-shape modifiers) objects ids))) resized (get objects (:id shape-main)) turn (orient-like resized placed (grc/rect->center (:selrect resized))) objects (cond-> objects @@ -383,6 +389,7 @@ (and (some? placed) (not (close-geometry? placed shape-main))) (update :objects placed-like shape-main placed)) ctx (assoc ctx :main-container main-container) + shape-main (get-in main-container [:objects (:id shape-main)]) children-inst (vec (ctn/get-direct-children container shape-inst)) children-main (vec (ctn/get-direct-children main-container shape-main))] (cond-> (-> changes diff --git a/common/src/app/common/logic/placement.cljc b/common/src/app/common/logic/placement.cljc index 82eb5ac99e..cc34d75cf9 100644 --- a/common/src/app/common/logic/placement.cljc +++ b/common/src/app/common/logic/placement.cljc @@ -27,6 +27,7 @@ "The attributes a transform can change, so an update after it compares only these." #{:selrect + :content-scale :points :x :y diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index a161e1f561..b99966435b 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -67,6 +67,7 @@ :r4 :radius-group :type :geometry-group :selrect :geometry-group + :content-scale :geometry-group :points :geometry-group :locked :geometry-group :proportion :geometry-group diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index 34a81365f1..fb16abf80c 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -675,8 +675,15 @@ (defn- apply-scale-content [shape value] ;; Scale can only be positive - (let [value (mth/abs value)] + (let [value (mth/abs value) + scale (* (:content-scale shape 1) value)] (cond-> shape + (mth/close? scale 1) + (dissoc :content-scale) + + (not (mth/close? scale 1)) + (assoc :content-scale scale) + (cfh/text-shape? shape) (update-text-content scale-text-content value) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 80f2e608f6..7cf137e132 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -195,6 +195,7 @@ [:main-instance {:optional true} :boolean] [:remote-synced {:optional true} :boolean] [:shape-ref {:optional true} ::sm/uuid] + [:content-scale {:optional true} ::sm/positive-safe-number] [:touched {:optional true} [:maybe [:set :keyword]]] [:blocked {:optional true} :boolean] [:collapsed {:optional true} :boolean] @@ -510,7 +511,7 @@ ;; Valid attributes for keeping on a switch (def ^:private allowed-shape-attrs #{:page-id :component-id :component-file :component-root :main-instance - :remote-synced :shape-ref :touched :blocked :collapsed :locked + :remote-synced :shape-ref :content-scale :touched :blocked :collapsed :locked :hidden :masked-group :fills :proportion :proportion-lock :constraints-h :constraints-v :fixed-scroll :r1 :r2 :r3 :r4 :rotation :opacity :grids :exports :strokes :blend-mode :interactions :shadow :blur :background-blur :grow-type :applied-tokens diff --git a/common/test/common_tests/logic/comp_expected_geometry_test.cljc b/common/test/common_tests/logic/comp_expected_geometry_test.cljc index 8250bac61a..ac07bdefed 100644 --- a/common/test/common_tests/logic/comp_expected_geometry_test.cljc +++ b/common/test/common_tests/logic/comp_expected_geometry_test.cljc @@ -20,7 +20,9 @@ [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.normalize :as thn] [app.common.test-helpers.shapes :as ths] + [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] [clojure.test :as t])) @@ -108,7 +110,33 @@ :stroke-style :solid}]) (thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child])) resized (resize file :copy2-root 200 200 :scale-content 2) - child (ths/get-shape resized :copy2-child)] + child (ths/get-shape resized :copy2-child) + synced (edit-and-sync resized)] (t/is (mth/close? 4 (-> child :strokes first :stroke-width))) - (t/is (nil? (:touched child))))) + (t/is (nil? (:touched child))) + (t/is (mth/close? 4 (-> (ths/get-shape synced :copy2-child) :strokes first :stroke-width))) + (t/is (nil? (:touched (ths/get-shape synced :copy2-child)))))) + +(t/deftest content-scale-survives-repeated-sync-and-reset-undo + (let [file (-> (component-with-child :scale) + (ths/update-shape :main-child :r1 5) + (thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child])) + scaled (-> file + (resize :copy2-root 200 200 :scale-content 2) + (resize :copy2-root 300 300 :scale-content 1.5)) + synced (edit-and-sync scaled) + changed (-> synced + (ths/update-shape :main-child :r1 7) + (edit-and-sync)) + reset-changes (cll/generate-reset-component + (pcb/empty-changes) changed {(:id changed) changed} + (ctn/make-container (thf/current-page changed) :page) (thi/id :copy2-root)) + reset (thf/apply-changes changed reset-changes) + undone (thf/apply-changes reset {:redo-changes (:undo-changes reset-changes)})] + (t/is (mth/close? 3 (:content-scale (ths/get-shape changed :copy2-root)))) + (t/is (mth/close? 21 (:r1 (ths/get-shape changed :copy2-child)))) + (t/is (nil? (:touched (ths/get-shape changed :copy2-child)))) + (t/is (mth/close? 7 (:r1 (ths/get-shape reset :copy2-child)))) + (t/is (nil? (:content-scale (ths/get-shape reset :copy2-root)))) + (t/is (= (thn/normalize-file changed) (thn/normalize-file undone))))) diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index f32b042779..0e0d35b8bc 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -146,6 +146,7 @@ (add! :component-root) (add! :main-instance) (add! :shape-ref) + (add! :content-scale) (add! :touched touched->str)))) (defn prefix-keys [m]