✨ Generate layout item edits in component histories

Add grid item justification and child margin mode, alignment, fixed/fill
sizing, size limits, absolute position and stacking order. Sample optional
asymmetric margins before copying. Keep existing input indexes and fixture
defaults, with explicit target eligibility and independent authored groups.

Check companion values, propagation, preserved overrides, reset, update
main, detach and exact undo/redo. Replay the histories through the real
frontend layout events and retain corrupt-result controls.

Correct F239's full-sync comparison to wait for layout on both routes.
The reduced add-child/absolute-child history stays settled, while the
negative control still rejects a real 0.25 px absolute-child displacement.
No geometry tolerance or golden data changes.

Validate the final continuation on common JVM (2004 tests / 32919
assertions), common JS (1940 / 31522), frontend (1745 / 13569), backend
(1793 / 9007) and exporter (16 / 40). All four modules pass lint and format.
All six generated properties pass 40 programs at seed 712 in both runtimes.
Golden data is unchanged.

The final continuation benchmark passes with no regression flags. Lower
quantile ratios against Phase 1e: main 10/100/1000 0.51/0.58/0.57, three
levels 0.57, chain 0.53, library 0.57, many shapes 0.45, variant switch 0.66.
Machine/JVM differences make these rough comparisons; baseline unchanged.

AI-assisted-by: gpt-6
This commit is contained in:
alonso.torres 2026-10-28 19:57:53 +01:00
parent 96a4c76ab6
commit a086fd9657
6 changed files with 218 additions and 14 deletions

View File

@ -96,7 +96,10 @@
:fixed-scroll :constraints-h :constraints-v :layout-item-margin
:layout-gap :layout-padding :layout-align-items :layout-flex-dir
:layout-wrap-type :layout-justify-content :layout-align-content
:layout-gap-type :layout-padding-type})
:layout-gap-type :layout-padding-type :layout-justify-items
:layout-item-margin-type :layout-item-align-self :layout-item-h-sizing
:layout-item-v-sizing :layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h :layout-item-absolute :layout-item-z-index})
(defn set-property
"`shape` with `property` set to `value`. Public so the frontend interpreter
@ -140,7 +143,7 @@
(def ^:private layout-container-properties
#{:layout-gap :layout-padding :layout-align-items :layout-flex-dir
:layout-wrap-type :layout-justify-content :layout-align-content
:layout-gap-type :layout-padding-type})
:layout-gap-type :layout-padding-type :layout-justify-items})
(defn- with-layout-reflow
[changes id]
@ -186,7 +189,7 @@
value' (applied-value* property value)
layout-id (cond
(and (:layout shape) (contains? layout-container-properties property)) shape-id
(and (= :layout-item-margin property)
(and (contains? ctl/layout-child-attrs property)
(:layout (get-in page [:objects (:parent-id shape)]))) (:parent-id shape))
changes (cond-> (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
@ -194,6 +197,11 @@
(fn [shape] (set-property shape property value'))
(:objects page)
{})
(and (= :layout-item-absolute property)
(ctl/grid-layout? (get-in page [:objects layout-id])))
(pcb/update-shapes [layout-id] ctl/assign-cells
{:with-objects? true :ignore-touched true})
layout-id
(-> (with-layout-reflow layout-id)
(assoc ::layout-reflow? true))

View File

@ -87,6 +87,17 @@
(tm/apply-to (n/change-property parent :layout-padding {:p1 3 :p2 6 :p3 9 :p4 12}))
(#(with-settled-layout % name)))))
(defn- with-layout-item-margins
[situation name]
(let [ids (:shapes (tm/shape-by-id situation ((n/main-head-of name) situation)))]
(-> (reduce (fn [s id]
(->> s
(tm/apply-to (n/change-property (constantly id) :layout-item-margin
{:m1 2 :m2 4 :m3 6 :m4 8}))
(tm/apply-to (n/change-property (constantly id) :layout-item-margin-type :multiple))))
situation ids)
(with-settled-layout name))))
(defn- with-outer-layout
[situation name layout]
(if-not layout
@ -201,6 +212,8 @@
(with-layout name (when (zero? index) (:layout opts)))
(cond-> (and (zero? index) (:layout opts) (:layout-children opts))
(with-layout-children name))
(cond-> (and (zero? index) (:layout opts) (:layout-item-margins opts))
(with-layout-item-margins name))
(as-> s (reduce #(tm/apply-to %2 %1) s
[(n/make-nested-component name depth)]))
(with-outer-layout name (when (and (zero? index) (pos? depth) (:layout-copies opts)) (:layout opts)))
@ -432,7 +445,18 @@
[:layout-justify-content [:start :space-between :center :end :space-around :space-evenly]]
[:layout-align-content [:stretch :center :end :start :space-between :space-around :space-evenly]]
[:layout-gap-type [:multiple :simple]]
[:layout-padding-type [:simple :multiple]]])
[:layout-padding-type [:simple :multiple]]
[:layout-justify-items [:start :center :end :stretch]]
[:layout-item-margin-type [:simple :multiple]]
[:layout-item-align-self [:start :center :end :stretch]]
[:layout-item-h-sizing [:fix :fill]]
[:layout-item-v-sizing [:fix :fill]]
[:layout-item-min-w [0 24 64]]
[:layout-item-max-w [256 96 64]]
[:layout-item-min-h [0 24 64]]
[:layout-item-max-h [256 96 64]]
[:layout-item-absolute [false true]]
[:layout-item-z-index [0 2 -1]]])
(def text-style-edits
"All twelve style units, independent of the production text group table."
@ -480,7 +504,14 @@
:layout-gap-type :layout-padding-type
:layout-justify-content :layout-align-content) (some? (:layout shape))
(:layout-flex-dir :layout-wrap-type) (= :flex (:layout shape))
:layout-item-margin (some? (:layout (tm/shape-by-id situation (:parent-id shape))))
:layout-justify-items (= :grid (:layout shape))
:layout-item-align-self (= :flex (:layout (tm/shape-by-id situation (:parent-id shape))))
(:layout-item-margin :layout-item-margin-type
:layout-item-h-sizing :layout-item-v-sizing
:layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h
:layout-item-absolute :layout-item-z-index)
(some? (:layout (tm/shape-by-id situation (:parent-id shape))))
true))))
(and (= kind :edit) (= 2 (mod (or property 0) 3)))
(remove (fn [{:keys [shape]}]
@ -637,7 +668,7 @@
(shadow/parent-orientation (all-objects situation) (all-objects settled))
(shadow/settle (all-objects settled)))
all (into (set (synchronizable-components situation)) (r/pending-components situation))
full-before (:situation (cf/sync-world situation all))
full-before (r/settle situation all)
full (:situation (cf/sync-world settled all))
opts {:free-head-placement false}
settled-design (oracle/canonical settled opts)
@ -1023,8 +1054,9 @@
depth (gen/choose 0 2)
layout (gen/elements [nil :flex :grid])
layout-children gen/boolean
layout-item-margins gen/boolean
ops (operation-gen structure-kinds)]
{:world [components depth {:text true :text-styles true :layout layout :layout-children layout-children}]
{:world [components depth {:text true :text-styles true :layout layout :layout-children layout-children :layout-item-margins layout-item-margins}]
:ops ops}))
(def swap-kinds (into structure-kinds [:swap :switch]))
@ -1034,10 +1066,11 @@
differ? gen/boolean
layout (gen/elements [nil :flex :grid])
layout-children gen/boolean
layout-item-margins gen/boolean
ops (operation-gen swap-kinds)]
{:world [components 2 {:variants true :variant-diff differ? :rich-variants true
:text true :text-styles true :layout layout :layout-copies true
:layout-children layout-children}]
:layout-children layout-children :layout-item-margins layout-item-margins}]
:ops ops}))
(def library-kinds
@ -1047,9 +1080,10 @@
(gen/let [depth (gen/choose 0 2)
layout (gen/elements [nil :flex :grid])
layout-children gen/boolean
layout-item-margins gen/boolean
ops (operation-gen library-kinds)]
{:world [2 depth {:library true :variants true :rich-variants true :text true :text-styles true
:layout layout :layout-copies true :layout-children layout-children}]
:layout layout :layout-copies true :layout-children layout-children :layout-item-margins layout-item-margins}]
:ops ops}))
(def stale-library-kinds
@ -1060,10 +1094,11 @@
library-depth (gen/choose 1 2)
layout (gen/elements [nil :flex :grid])
layout-children gen/boolean
layout-item-margins gen/boolean
ops (operation-gen stale-library-kinds)]
{:world [2 depth {:library true :stale-libraries true :library-variants true :library-text true
:library-depth library-depth :variants true :rich-variants true :text true :text-styles true
:layout layout :layout-copies true :layout-children layout-children}]
:layout layout :layout-copies true :layout-children layout-children :layout-item-margins layout-item-margins}]
:ops ops}))
(def dependency-kinds

View File

@ -28,7 +28,14 @@
:layout-padding :layout-padding :layout-align-items :layout-align-items
:layout-flex-dir :layout-flex-dir :layout-wrap-type :layout-wrap-type
:layout-justify-content :layout-justify-content :layout-align-content :layout-align-content
:layout-gap-type :layout-gap :layout-padding-type :layout-padding})
:layout-gap-type :layout-gap :layout-padding-type :layout-padding
:layout-justify-items :layout-justify-items
:layout-item-margin-type :layout-item-margin
:layout-item-align-self :layout-item-align-self
:layout-item-h-sizing :layout-item-h-sizing :layout-item-v-sizing :layout-item-v-sizing
:layout-item-min-w :layout-item-min-w :layout-item-max-w :layout-item-max-w
:layout-item-min-h :layout-item-min-h :layout-item-max-h :layout-item-max-h
:layout-item-absolute :layout-item-absolute :layout-item-z-index :layout-item-z-index})
(defn attribute-applicable?
"The clipping and background-export controls belong to frames."

View File

@ -184,7 +184,51 @@
[22 :layout-justify-content :layout-justify-content #{:flex :grid}]
[23 :layout-align-content :layout-align-content #{:flex :grid}]
[24 :layout-gap-type :layout-gap #{:flex :grid}]
[25 :layout-padding-type :layout-padding #{:flex :grid}]])
[25 :layout-padding-type :layout-padding #{:flex :grid}]
[26 :layout-justify-items :layout-justify-items #{:grid}]])
(def layout-item-cases
[[27 :layout-item-margin-type :layout-item-margin #{:flex :grid}]
[28 :layout-item-align-self :layout-item-align-self #{:flex}]
[29 :layout-item-h-sizing :layout-item-h-sizing #{:flex :grid}]
[30 :layout-item-v-sizing :layout-item-v-sizing #{:flex :grid}]
[31 :layout-item-min-w :layout-item-min-w #{:flex :grid}]
[32 :layout-item-max-w :layout-item-max-w #{:flex :grid}]
[33 :layout-item-min-h :layout-item-min-h #{:flex :grid}]
[34 :layout-item-max-h :layout-item-max-h #{:flex :grid}]
[35 :layout-item-absolute :layout-item-absolute #{:flex :grid}]
[36 :layout-item-z-index :layout-item-z-index #{:flex :grid}]])
(t/deftest remaining-layout-inputs-resolve-only-in-their-own-context
(doseq [layout [nil :flex :grid]
[property attr _ layouts] (into [[26 :layout-justify-items :layout-justify-items #{:grid}]]
layout-item-cases)]
(let [situation (cg/world [1 0 {:layout layout :layout-children true}])
resolved (cg/resolve-op situation {} [:style 0 property 1 1])
parent (tm/shape-by-id situation (get-in resolved [:shape :parent-id]))]
(t/is (= attr (:property resolved)))
(if (contains? layouts layout)
(do
(t/is (some? (:op resolved)))
(t/is (not= (get (:shape resolved) attr) (:value resolved)))
(t/is (= layout (if (= property 26) (get-in resolved [:shape :layout]) (:layout parent)))))
(do
(t/is (nil? (:op resolved)))
(t/is (= :skip (get-in resolved [:trace :status]))))))))
(t/deftest asymmetric-item-margins-are-settled-before-copying
(doseq [layout [:flex :grid]]
(let [situation (cg/world [1 0 {:layout layout :layout-children true :layout-item-margins true}])
main-id ((n/main-rect-of "random-0") situation)
images (filter #(or (= main-id (get-in % [:shape :id]))
(= main-id (get-in % [:shape :shape-ref]))) (cg/tree situation))]
(t/is (= 3 (count images)))
(doseq [{:keys [shape]} images]
(t/is (= {:m1 2 :m2 4 :m3 6 :m4 8} (:layout-item-margin shape)))
(t/is (= :multiple (:layout-item-margin-type shape))))
(t/is (= :valid (try (oracle/check situation {}) :valid
(catch #?(:clj Exception :cljs js/Error) error
(select-keys (ex-data error) [:invariant :group]))))))))
(t/deftest multi-child-layout-fixtures-start-settled-before-user-actions
(doseq [layout [:flex :grid] depth [0 1]]
@ -309,6 +353,94 @@
(t/is (pos? (get-in result [:coverage :random kind :changed] 0))
(str kind " changes " property " in " layout))))))
(t/deftest adding-a-flex-child-before-making-a-copy-child-absolute-stays-settled
(t/is (map? (cg/run {:world [1 0 {:layout :flex :layout-children true}]
:ops [[:add-child 0] [:style 2 35 1 1]]}))))
(t/deftest full-settlement-comparison-still-rejects-absolute-child-drift
(let [initial (-> (cg/initial-state [1 0 {:layout :flex :layout-children true}])
(cg/step [:add-child 0]) cg/settle-state)
id (get-in (cg/resolve-op (:situation initial) {} [:style 2 35 1 1]) [:shape :id])
edited (cg/step initial [:style 2 35 1 1])
settled (assoc edited :situation (r/settle (:situation edited)) :checkpoint nil)
settle r/settle
corrupt-full (fn [situation component-ids]
(let [result (settle situation component-ids)
file (tm/file result)
path [:data :pages-index (:id (thf/current-page file)) :objects id]]
(tm/with-file result (update-in file path gsh/move (gpt/point 0.25 0)))))
invariant (with-redefs [r/settle (fn
([situation] (settle situation (r/pending-components situation)))
([situation ids] (corrupt-full situation ids)))]
(try (cg/settle-state settled) nil
(catch #?(:clj Exception :cljs js/Error) error
(:invariant (ex-data error)))))]
(t/is (= :I6 invariant))))
(t/deftest layout-item-overrides-retain-values-and-companions
(doseq [[property attr group layouts] layout-item-cases
layout (sort layouts)]
(t/testing (str attr " in " layout)
(let [initial (cg/initial-state [1 0 {:layout layout :layout-children true :layout-item-margins true}])
situation (:situation initial)
authored (if (= property 27) 0 1)
other (if (= property 27) 1 0)
resolved (cg/resolve-op situation {} [:style 0 property authored 1])
shape (:shape resolved)
id (:id shape)
expected (if (= property 27)
{:layout-item-margin-type :simple :layout-item-margin {:m1 2 :m2 4 :m3 6 :m4 8}}
{attr (:value resolved)})
edited (-> initial (cg/step [:style 0 property authored 1]) cg/settle-state)
after (:situation edited)
file (tm/file after)
path [:data :pages-index (:id (thf/current-page file)) :objects id]
invariant (fn [file]
(try (oracle/check (tm/with-file after file) (:model edited)) nil
(catch #?(:clj Exception :cljs js/Error) error
(:invariant (ex-data error)))))
reverted (cond-> (if (contains? shape attr)
(assoc-in file (conj path attr) (get shape attr))
(update-in file path dissoc attr))
(and (= property 35) (= layout :grid))
(assoc-in (conj (pop path) (:parent-id shape) :layout-grid-cells)
(:layout-grid-cells (tm/shape-by-id situation (:parent-id shape)))))
main-index (first (keep (fn [index]
(when (= (:shape-ref shape)
(get-in (cg/resolve-op situation {} [:style index property other 0])
[:shape :id])) index))
(range (count (cg/tree situation)))))
updated (-> edited (cg/step [:style main-index property authored 0]) cg/settle-state
(cg/step [:style main-index property other 0]) cg/settle-state)
value (:value (cg/resolve-op situation {} [:style main-index property other 0]))
copies (filter #(= (:shape-ref shape) (get-in % [:shape :shape-ref])) (cg/tree situation))]
(t/is (= expected (get-in edited [:model id group])))
(t/is (= #{group} (:touched (tm/shape-by-id after id))))
(t/is (= (:layout-item-margin shape) (:layout-item-margin (tm/shape-by-id after id))))
(t/is (= :I9 (invariant reverted)))
(t/is (= :I3 (invariant (update-in file (conj path :touched) disj group))))
(t/is (some? main-index))
(t/is (= 2 (count copies)))
(doseq [{copy :shape} copies]
(let [actual (tm/shape-by-id (:situation updated) (:id copy))]
(t/is (= (if (= id (:id copy)) (:value resolved) value) (get actual attr)))
(t/is (= (if (= id (:id copy)) #{group} #{}) (set (:touched actual))))))
(t/is (= expected (get-in updated [:model id group])))))))
(t/deftest layout-item-histories-survive-component-operations
(doseq [[property _ _ layouts] layout-item-cases
layout (sort layouts)]
(let [authored (if (= property 27) 0 1)
other (if (= property 27) 1 0)
result (cg/run {:world [1 0 {:layout layout :layout-children true :layout-item-margins true}]
:ops [[:style 0 property authored 1] [:style 0 property authored 0]
[:style 0 property other 0] [:undo] [:redo] [:update-main 0]
[:style 0 property other 1] [:reset 0]
[:style 0 property other 1] [:detach 0] [:undo] [:redo]]})]
(doseq [kind [:style :update-main :reset :detach]]
(t/is (pos? (get-in result [:coverage :random kind :changed] 0))
(str kind " changes " property " in " layout))))))
(t/deftest text-style-edits-cover-every-style-unit
(doseq [[unit group] (map-indexed vector
[:text-content-family :text-content-weight :text-content-size

View File

@ -90,7 +90,7 @@
(t/deftest ^:async layout-container-histories-survive-component-operations
(doseq [[property layouts] [[20 [:flex]] [21 [:flex]] [22 [:flex :grid]] [23 [:flex :grid]]
[24 [:flex :grid]] [25 [:flex :grid]]]
[24 [:flex :grid]] [25 [:flex :grid]] [26 [:grid]]]
layout layouts]
(await (replay {:world [1 0 {:layout layout :layout-children true}]
:ops [[:style 0 property 1 1] [:style 0 property 1 0]
@ -98,6 +98,21 @@
[:style 0 property 0 1] [:reset 0]
[:style 0 property 0 1] [:detach 0] [:undo] [:redo]]}))))
(t/deftest ^:async layout-item-histories-survive-component-operations
(doseq [property (range 27 37)
layout (if (= property 28) [:flex] [:flex :grid])]
(let [authored (if (= property 27) 0 1)
other (if (= property 27) 1 0)]
(await (replay {:world [1 0 {:layout layout :layout-children true :layout-item-margins true}]
:ops [[:style 0 property authored 1] [:style 0 property authored 0]
[:style 0 property other 0] [:undo] [:redo] [:update-main 0]
[:style 0 property other 1] [:reset 0]
[:style 0 property other 1] [:detach 0] [:undo] [:redo]]})))))
(t/deftest ^:async adding-a-flex-child-before-making-a-copy-child-absolute-stays-settled
(await (replay {:world [1 0 {:layout :flex :layout-children true}]
:ops [[:add-child 0] [:style 2 35 1 1] [:undo] [:redo]]})))
(t/deftest ^:async nesting-a-copy-alongside-a-hidden-layout-child-stays-settled
(await (replay {:world [3 0 {:text true :text-styles true :layout :flex}]
:ops [[:delete 9 9 1 2] [:text 5 9 1 3] [:style 3 1 1]

View File

@ -150,13 +150,20 @@
[(dwsl/update-layout-child [id] (select-keys shape [:layout-item-margin
:layout-item-margin-type]))])
(:layout-item-margin-type :layout-item-align-self
:layout-item-h-sizing :layout-item-v-sizing
:layout-item-min-w :layout-item-max-w
:layout-item-min-h :layout-item-max-h
:layout-item-absolute :layout-item-z-index)
[(dwsl/update-layout-child [id] {property value})]
;; the layers panel's rename, which renames a main's component too
:name
[(dw/end-rename-shape id value)]
;; Container controls use the sidebar transaction and reflow event.
(:layout-flex-dir :layout-wrap-type :layout-justify-content
:layout-align-content :layout-gap-type :layout-padding-type)
:layout-align-content :layout-gap-type :layout-padding-type :layout-justify-items)
[(dwsl/update-layout [id] {property value})]
[(dwsh/update-shapes #{id} (fn [shape] (n/set-property shape property value)))]))