zenith-tx 0.0.4

Zenith transaction op set, apply/dry-run engine, diffs, and audit records.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
//! Transaction envelope: [`Transaction`] and the [`Op`] enum.
//!
//! Deserializes from JSON like:
//! ```json
//! {"ops":[
//!   {"op":"set_text_align","node":"label","align":"center"},
//!   {"op":"set_fill","node":"box","fill":"color.accent"},
//!   {"op":"set_stroke","node":"box","stroke":"color.rule"},
//!   {"op":"set_stroke_width","node":"box","stroke_width":"size.stroke"},
//!   {"op":"set_visible","node":"box","visible":false},
//!   {"op":"set_locked","node":"box","locked":true},
//!   {"op":"set_geometry","node":"r","x":10,"w":200},
//!   {"op":"set_points","node":"poly","points":[{"x":0,"y":0},{"x":100,"y":0},{"x":50,"y":80}]}
//! ]}
//! ```

use crate::TxError;

/// A 2-D vertex used by [`Op::SetPoints`], expressed in pixels.
///
/// JSON shape: `{"x": 50.0, "y": 80.0}`
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct OpPoint {
    /// X coordinate in document pixels.
    pub x: f64,
    /// Y coordinate in document pixels.
    pub y: f64,
}

/// A single text span used by [`Op::ReplaceText`].
///
/// JSON shape: `{"text":"Hello","fill":"color.brand","italic":true}`.
/// All fields except `text` are optional and default to `None`/absent.
/// `fill` and `font_weight` are token ids (like [`Op::SetFill`]), not raw values.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct OpSpan {
    /// The literal text content of this span.
    pub text: String,
    /// Token id to set as the per-span fill (e.g. `"color.brand"`). `None` = inherit.
    #[serde(default)]
    pub fill: Option<String>,
    /// Token id to set as the per-span font-weight. `None` = inherit.
    #[serde(default)]
    pub font_weight: Option<String>,
    /// Italic override. `None` = inherit.
    #[serde(default)]
    pub italic: Option<bool>,
    /// Underline decoration. `None` = inherit.
    #[serde(default)]
    pub underline: Option<bool>,
    /// Strikethrough decoration. `None` = inherit.
    #[serde(default)]
    pub strikethrough: Option<bool>,
    /// Vertical alignment (`"super"` / `"sub"`). `None` = baseline (inherit).
    #[serde(default)]
    pub vertical_align: Option<String>,
    /// Footnote reference — the id of a page-level footnote. `None` = no ref.
    #[serde(default)]
    pub footnote_ref: Option<String>,
}

/// Insertion position for [`Op::AddNode`] within a container's children.
///
/// JSON shapes: `{"at":"last"}`, `{"at":"first"}`, `{"at":"index","index":2}`,
/// `{"at":"before","id":"sibling"}`, `{"at":"after","id":"sibling"}`.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(tag = "at", rename_all = "snake_case")]
pub enum Position {
    /// Insert as the last child (topmost in z-order). Default.
    #[default]
    Last,
    /// Insert as the first child (bottommost in z-order).
    First,
    /// Insert at an explicit index (clamped to the children length).
    Index { index: usize },
    /// Insert immediately before the sibling with this id.
    Before { id: String },
    /// Insert immediately after the sibling with this id.
    After { id: String },
}

/// Per-transaction permission flags that relax otherwise-enforced guards.
///
/// Carried in a transaction's optional `"permissions"` object, e.g.
/// `{"permissions":{"allow_locked":false,"allow_raw_visual_literals":false}}`.
/// Both flags default to `false`, so a transaction JSON that omits the
/// `permissions` key still parses with all guards active.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Default)]
pub struct Permissions {
    /// When `true`, mutating ops are allowed to target locked nodes.
    /// When `false` (default), a guarded op against a locked node is rejected
    /// with a `node.locked` diagnostic.
    #[serde(default)]
    pub allow_locked: bool,
    /// When `true`, raw (non-token) visual literal values are permitted.
    #[serde(default)]
    pub allow_raw_visual_literals: bool,
}

/// A batch of operations to apply to a document in order.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct Transaction {
    pub ops: Vec<Op>,
    /// Permission flags relaxing per-op guards. Defaults to all-`false`
    /// (every guard active) when the `permissions` key is absent from JSON.
    #[serde(default)]
    pub permissions: Permissions,
}

impl Transaction {
    /// Parse a `Transaction` from a JSON string.
    pub fn from_json(s: &str) -> Result<Transaction, TxError> {
        serde_json::from_str(s).map_err(|e| TxError {
            message: format!("failed to parse transaction JSON: {e}"),
        })
    }
}

/// A single operation within a [`Transaction`].
///
/// The `op` field in JSON is the snake_case tag, e.g. `"set_text_align"`.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Op {
    /// Set the `align` property on a text node.
    ///
    /// Valid values: `start`, `center`, `end`, `justify`.
    SetTextAlign {
        /// The stable node `id` to target.
        node: String,
        /// The new alignment value.
        align: String,
    },
    /// Move a node one sibling position toward the end (front/top of z-order).
    ///
    /// Has no effect if the node is already last in its parent's children.
    MoveForward {
        /// The stable node `id` to target.
        node: String,
    },
    /// Move a node one sibling position toward the beginning (back/bottom of z-order).
    ///
    /// Has no effect if the node is already first in its parent's children.
    MoveBackward {
        /// The stable node `id` to target.
        node: String,
    },
    /// Move a node to the topmost position (last child) in its parent's children.
    ///
    /// Has no effect if the node is already the last sibling (frontmost/topmost).
    MoveToFront {
        /// The stable node `id` to target.
        node: String,
    },
    /// Move a node to the bottommost position (first child) in its parent's children.
    ///
    /// Has no effect if the node is already the first sibling (backmost/bottommost).
    MoveToBack {
        /// The stable node `id` to target.
        node: String,
    },
    /// Set the `fill` property on a node that supports fill.
    ///
    /// The `fill` value is a token id (e.g. `"color.accent"`); the engine
    /// wraps it as `PropertyValue::TokenRef(fill)`. Post-validation rejects
    /// unknown token ids automatically.
    ///
    /// Supported nodes: `rect`, `ellipse`, `text`, `polygon`, `polyline`.
    /// Unsupported: `line`, `frame`, `group`, `image` — yields
    /// `tx.unsupported_property`.
    SetFill {
        /// The stable node `id` to target.
        node: String,
        /// Token id to set as the fill (e.g. `"color.brand"`).
        fill: String,
    },
    /// Set the `stroke` (outline color) property on a node that supports stroke.
    ///
    /// The `stroke` value is a token id (e.g. `"color.rule"`); the engine wraps it
    /// as `PropertyValue::TokenRef(stroke)`. Post-validation rejects unknown token
    /// ids automatically.
    ///
    /// Supported nodes: `rect`, `line`, `polygon`, `polyline`.
    /// Unsupported: `ellipse` (fill-only), `text`, `frame`, `group`, `image` —
    /// yields `tx.unsupported_property`.
    SetStroke {
        /// The stable node `id` to target.
        node: String,
        /// Token id to set as the stroke color (e.g. `"color.rule"`).
        stroke: String,
    },
    /// Set the `stroke-width` property on a node that supports stroke.
    ///
    /// The value is a **dimension token id** (e.g. `"size.stroke"`), stored as
    /// `PropertyValue::TokenRef`. A token (not a raw number) is required because
    /// v0 stroke-width only resolves through dimension tokens at compile time;
    /// post-validation rejects unknown token ids automatically.
    ///
    /// Supported nodes: `rect`, `line`, `polygon`, `polyline`.
    /// Unsupported: `ellipse`, `text`, `frame`, `group`, `image` — yields
    /// `tx.unsupported_property`.
    SetStrokeWidth {
        /// The stable node `id` to target.
        node: String,
        /// Dimension token id to set as the stroke width (e.g. `"size.stroke"`).
        stroke_width: String,
    },
    /// Show or hide a node by setting its `visible` property.
    ///
    /// All known node variants except `Unknown` support this property.
    SetVisible {
        /// The stable node `id` to target.
        node: String,
        /// `false` hides the node; `true` makes it visible.
        visible: bool,
    },
    /// Lock or unlock a node by setting its `locked` property.
    ///
    /// All known node variants except `Unknown` support this property.
    SetLocked {
        /// The stable node `id` to target.
        node: String,
        /// `true` locks the node; `false` unlocks it.
        locked: bool,
    },
    /// Move and/or resize a bbox node by updating its `x`, `y`, `w`, `h`
    /// geometry fields, and optionally set its `rotate` angle. All five fields
    /// are optional — only the fields present in the JSON payload are changed;
    /// omitted fields are left untouched.
    ///
    /// Values are in document pixels (`(px)` unit) for `x`/`y`/`w`/`h`.
    /// `rotate` is in degrees (`(deg)` unit at storage; pass a raw `f64` here).
    ///
    /// Supported nodes for x/y/w/h: `rect`, `ellipse`, `frame`, `image`,
    /// `text`, `code`, `group`, `field`.
    /// Supported nodes for rotate: `rect`, `ellipse`, `frame`, `image`, `text`,
    /// `code`, `group`, `polygon`, `polyline`.
    /// Unsupported for rotate: `line`, `instance`, `field`, `footnote`,
    /// `unknown` — yields `tx.unsupported_property`.
    ///
    /// If all five fields are omitted, an advisory `tx.noop` is emitted and no
    /// node is recorded as affected.
    ///
    /// JSON example (partial — only x, w, and rotate change):
    /// ```json
    /// {"op":"set_geometry","node":"r","x":10,"w":200,"rotate":45}
    /// ```
    SetGeometry {
        /// The stable node `id` to target.
        node: String,
        /// New left edge in pixels. Omit to leave unchanged.
        #[serde(default)]
        x: Option<f64>,
        /// New top edge in pixels. Omit to leave unchanged.
        #[serde(default)]
        y: Option<f64>,
        /// New width in pixels. Omit to leave unchanged.
        #[serde(default)]
        w: Option<f64>,
        /// New height in pixels. Omit to leave unchanged.
        #[serde(default)]
        h: Option<f64>,
        /// New rotation in degrees. Omit to leave unchanged.
        #[serde(default)]
        rotate: Option<f64>,
    },
    /// Replace the entire vertex list of a `polygon` or `polyline` node.
    ///
    /// Post-validation rejects automatically if the new point count falls
    /// below the node's minimum (`polygon` needs ≥ 3, `polyline` needs ≥ 2).
    ///
    /// Supported nodes: `polygon`, `polyline`.
    /// Unsupported: all other variants — yields `tx.unsupported_property`.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"set_points","node":"poly","points":[{"x":0,"y":0},{"x":100,"y":0},{"x":50,"y":80}]}
    /// ```
    SetPoints {
        /// The stable node `id` to target.
        node: String,
        /// Replacement vertex list. Each vertex is in document pixels.
        points: Vec<OpPoint>,
    },
    /// Construct a new node from a `.zen` source fragment and insert it into a
    /// container (a page, group, or frame) at a chosen position.
    ///
    /// `source` is a single `.zen` node fragment, e.g.
    /// `rect id="box" x=(px)10 y=(px)10 w=(px)100 h=(px)80 fill=(token)"color.accent"`.
    /// It is parsed through the canonical KDL parser, so every node kind, nested
    /// children (for group/frame), tokens, and properties are supported with no
    /// per-field mapping. Exactly one top-level node must be present.
    ///
    /// Post-validation rejects an incomplete/invalid node automatically (missing
    /// required geometry, duplicate id, unknown token/asset ref, too few points, …).
    AddNode {
        /// Stable id of the container to insert into: a page id, or a group/frame id.
        parent: String,
        /// Where among the container's children to insert. Defaults to `last`.
        #[serde(default)]
        position: Position,
        /// A single `.zen` node fragment to construct and insert.
        source: String,
    },
    /// Remove a node (and its subtree) by id from whatever container holds it.
    ///
    /// Rejects with `tx.unknown_node` if no node with that id exists.
    RemoveNode {
        /// The stable node `id` to remove.
        node: String,
    },
    /// Set the `opacity` of a node (0.0 = fully transparent, 1.0 = fully opaque).
    ///
    /// The value is clamped to `[0.0, 1.0]` before being stored.
    ///
    /// Supported nodes: all concrete variants (`rect`, `ellipse`, `line`, `text`,
    /// `code`, `frame`, `group`, `image`, `polygon`, `polyline`).
    /// Unsupported: `unknown` — yields `tx.unsupported_property`.
    SetOpacity {
        /// The stable node `id` to target.
        node: String,
        /// New opacity value; clamped to `[0.0, 1.0]`.
        opacity: f64,
    },
    /// Replace the entire span list of a `text` node with a new set of spans.
    ///
    /// The `spans` vec fully replaces `TextNode.spans`. Replacing with an empty
    /// vec is valid and clears all text content. `fill` and `font_weight` in each
    /// [`OpSpan`] are token ids wrapped as `PropertyValue::TokenRef`; post-validation
    /// rejects unknown token ids automatically (same as `set_fill`).
    ///
    /// Supported nodes: `text`, and `shape` (replaces the shape's owned label
    /// spans, which use the same span model as a text node).
    /// Unsupported: all other variants — yields `tx.unsupported_property`.
    ReplaceText {
        /// The stable node `id` to target.
        node: String,
        /// Replacement span list. Each span's `text` is required; all other fields
        /// are optional and default to `None` (inherit from node-level styles).
        spans: Vec<OpSpan>,
    },
    /// Duplicate a leaf node, assigning it a new id, and insert the clone
    /// immediately after the original in the same parent's children.
    ///
    /// **v0 scope — leaf nodes only.** Duplicating a container (`frame` or
    /// `group`) is rejected with `tx.unsupported_property`. A deep-clone would
    /// copy all descendant ids, producing duplicate ids throughout the subtree;
    /// re-id'ing an entire subtree is deferred to a future version.
    ///
    /// Post-validation catches a `new_id` that collides with an existing node
    /// id via the `id.duplicate` diagnostic (same as [`Op::AddNode`]).
    ///
    /// Rejects with `tx.unknown_node` if `node` does not exist in the document.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"duplicate_node","node":"box","new_id":"box-copy"}
    /// ```
    DuplicateNode {
        /// The stable id of the node to duplicate.
        node: String,
        /// The id to assign to the newly created clone.
        new_id: String,
    },
    /// Duplicate an entire page (and its full subtree), inserting the copy
    /// immediately after the source page in the document body.
    ///
    /// Unlike [`Op::DuplicateNode`] (leaf-only, v0), this performs a deep clone:
    /// the new page gets `new_id`, and **every descendant node id** in the copy
    /// is suffixed with `id_suffix` so all ids stay unique. Any page-level
    /// `safe_zones[].id` is suffixed the same way.
    ///
    /// `duplicate_page` only *creates* new content and never mutates the source,
    /// so it is exempt from lock enforcement.
    ///
    /// Rejects with `tx.unknown_node` if no page with id `page` exists.
    /// Post-validation rejects the transaction if `id_suffix` fails to keep ids
    /// unique (e.g. an empty suffix) via the `id.duplicate` diagnostic — that is
    /// the safety net; an empty suffix also emits a helpful advisory.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"duplicate_page","page":"page.x","new_id":"page.x2","id_suffix":".v2"}
    /// ```
    DuplicatePage {
        /// Source page id to clone.
        page: String,
        /// Id for the new (duplicated) page.
        new_id: String,
        /// Suffix appended to EVERY descendant node id in the copy (keeps ids unique).
        id_suffix: String,
    },
    /// Wrap a set of sibling nodes inside a new group node.
    ///
    /// All `node_ids` must be **direct siblings under the same parent**
    /// (a page, group, or frame). If any id is not found, or if the ids
    /// do not all share one common parent, the op is rejected with
    /// `tx.invalid_parent`.
    ///
    /// The new group is inserted at the position of the **earliest** (lowest
    /// index) member, preserving z-order. The grouped nodes are transferred
    /// into the new group in their original relative order.
    ///
    /// Post-validation catches a `group_id` that collides with an existing
    /// node id via the `id.duplicate` diagnostic.
    ///
    /// **v0 note:** the group is created with `x`/`y` = `None` (no translation
    /// offset). Children keep their authored coordinates; any visual shift must
    /// be handled by the caller by adjusting child geometry separately.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"group","node_ids":["rect1","rect2"],"group_id":"grp-new"}
    /// ```
    Group {
        /// Ids of the nodes to group. Must be ≥ 1 and share a common parent.
        node_ids: Vec<String>,
        /// The id to assign to the newly created group node.
        group_id: String,
    },
    /// Dissolve a group node, moving its children up to the group's parent.
    ///
    /// The group is replaced in-place by its children (spliced at the group's
    /// original index), preserving source order.
    ///
    /// Rejects with `tx.unknown_node` if `group_id` is not found.
    /// Rejects with `tx.unsupported_property` ("not a group") if the node is
    /// not a `group` variant.
    ///
    /// **v0 limitation:** the group's own `x`/`y` translation is NOT applied
    /// to children on ungroup (children keep their authored coordinates). If the
    /// group had a non-zero `x`/`y` offset, the rendered positions of children
    /// may shift after ungroup. An advisory is emitted in that case.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"ungroup","group_id":"grp1"}
    /// ```
    Ungroup {
        /// The id of the group node to dissolve.
        group_id: String,
    },
    /// Move a node to a different container (page, group, or frame).
    ///
    /// Rejects with `tx.unknown_node` if `node` is not found.
    /// Rejects with `tx.invalid_parent` if `new_parent` is not a container
    /// (page, group, or frame), or if `new_parent` is `node` itself or a
    /// descendant of `node` (cycle detection).
    ///
    /// `position` controls where in the new parent's children the node is
    /// inserted; defaults to [`Position::Last`] (top of z-order).
    ///
    /// JSON example:
    /// ```json
    /// {"op":"reparent","node":"rect1","new_parent":"grp1","position":{"at":"last"}}
    /// ```
    Reparent {
        /// The stable id of the node to move.
        node: String,
        /// The id of the container to move the node into.
        new_parent: String,
        /// Where to insert the node in the new parent. Defaults to `last`.
        #[serde(default)]
        position: Position,
    },
    /// Align a set of nodes to a common edge or centre along one axis.
    ///
    /// `align` controls the alignment target:
    /// - Horizontal: `"left"`, `"hcenter"`, `"right"`
    /// - Vertical: `"top"`, `"vcenter"`, `"bottom"`
    ///
    /// `anchor` controls the reference rectangle:
    /// - `"selection"` (default): the union bounding box of all alignable nodes.
    /// - `"page"`: the page that contains the nodes (0,0 to page w/h).
    /// - a node id: the bbox of that node.
    /// - an explicit dimension like `"(px)120"`: align the chosen edge of every
    ///   listed node to that absolute page coordinate. For the horizontal edges
    ///   (`left`, `hcenter`, `right`) the value is an X coordinate; for the
    ///   vertical edges (`top`, `vcenter`, `bottom`) it is a Y coordinate.
    ///
    /// Only nodes supported by `set_geometry` (`rect`, `ellipse`, `frame`,
    /// `image`) with resolvable `x/y/w/h` in px/pt are alignable. Any node
    /// that lacks full geometry is skipped with a `tx.geometry_unresolved`
    /// warning; the rest are still aligned.
    ///
    /// An unknown `align` value is rejected with `tx.unsupported_property`.
    /// An unknown `anchor` value is rejected with `tx.unsupported_property`.
    /// A `"(px)…"` anchor whose dimension cannot be parsed is rejected with
    /// `tx.invalid_value`.
    /// Fewer than one alignable node emits `tx.noop`.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"align_nodes","node_ids":["a","b","caption"],"align":"left","anchor":"(px)120"}
    /// ```
    AlignNodes {
        /// Ids of the nodes to align.
        node_ids: Vec<String>,
        /// Which edge or centre to align to: `left`, `hcenter`, `right`,
        /// `top`, `vcenter`, or `bottom`.
        align: String,
        /// Reference rectangle: `"selection"` (union bbox), `"page"`, a node id,
        /// or an explicit dimension like `"(px)120"`. Defaults to `"selection"`.
        #[serde(default = "default_anchor")]
        anchor: String,
    },
    /// Set the `overflow` property of a `text` or `code` node.
    ///
    /// Valid values: `"fit"`, `"clip"`, `"visible"`. Any other value is rejected
    /// with `tx.invalid_value`.
    ///
    /// Supported nodes: `text`, `code`.
    /// Unsupported: all other variants — yields `tx.wrong_node_type`.
    /// A missing node yields `tx.unknown_node`.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"set_text_overflow","node_id":"body","overflow":"visible"}
    /// ```
    SetTextOverflow {
        /// The stable node `id` to target.
        node_id: String,
        /// The new overflow value: `fit`, `clip`, or `visible`.
        overflow: String,
    },
    /// Create a new EMPTY page (no children) and insert it into the document
    /// body at `index` (0-based) or, when `index` is `None`, append it at the
    /// end.
    ///
    /// `w` and `h` are canonical dimension strings like `"(px)1800"` / `"(pt)90"`
    /// (the same `(unit)value` form parsed by other ops). `background`, when
    /// present, is a token-ref id (e.g. `"color.bg"`) stored as
    /// `PropertyValue::TokenRef` — exactly like [`Op::SetFill`].
    ///
    /// Rejects with `tx.duplicate_id` if a page (or any node) already uses `id`.
    /// Rejects with `tx.invalid_value` if `w`/`h` fail to parse as a dimension.
    /// Rejects with `tx.out_of_range` if `index` is past the end of the page list.
    ///
    /// The new page carries no children, safe-zones, folds, margins, or bleed —
    /// it is a blank canvas. Post-validation still runs over the whole document.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"add_page","id":"page.new","w":"(px)1800","h":"(px)1200","index":1}
    /// ```
    AddPage {
        /// Stable id for the new page (must be unique document-wide).
        id: String,
        /// Page width as a canonical dimension string, e.g. `"(px)1800"`.
        w: String,
        /// Page height as a canonical dimension string, e.g. `"(px)1200"`.
        h: String,
        /// Optional background token-ref id (e.g. `"color.bg"`). `None` = no fill.
        #[serde(default)]
        background: Option<String>,
        /// 0-based insert position. `None` appends at the end.
        #[serde(default)]
        index: Option<usize>,
    },
    /// Remove the page whose id == `page` (and its entire subtree) from the
    /// document body.
    ///
    /// Rejects with `tx.unknown_node` if no page with that id exists.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"delete_page","page":"page.old"}
    /// ```
    DeletePage {
        /// Id of the page to remove.
        page: String,
    },
    /// Reorder the document body's pages to match `order`.
    ///
    /// `order` must be a permutation of the existing page ids: the same set,
    /// with no duplicates and nothing missing or extra. On success the pages are
    /// rearranged so their ids follow `order` exactly.
    ///
    /// Rejects with `tx.invalid_value` if `order` is not a permutation (an id is
    /// missing, extra, duplicated, or unknown).
    ///
    /// JSON example:
    /// ```json
    /// {"op":"reorder_pages","order":["page.b","page.a","page.c"]}
    /// ```
    ReorderPages {
        /// The new full ordering of page ids (a permutation of the existing set).
        order: Vec<String>,
    },
    /// Declare a new asset in the document's `assets` block.
    ///
    /// `kind` must be one of `"image"`, `"svg"`, or `"font"`. `src` is a relative
    /// path to the asset file. `sha256` is an optional content-integrity digest.
    ///
    /// Rejected immediately with `tx.duplicate_id` if an asset with `id` already
    /// exists. Post-validation catches `asset.invalid_src` (absolute paths, `../`
    /// components, URLs) and `asset.invalid_kind` (unrecognized kinds).
    ///
    /// JSON example:
    /// ```json
    /// {"op":"add_asset","id":"asset.logo","kind":"image","src":"images/logo.png","sha256":"abc123"}
    /// ```
    AddAsset {
        /// Globally unique asset id (e.g. `"asset.logo"`).
        id: String,
        /// Asset kind string: `"image"`, `"svg"`, or `"font"`.
        kind: String,
        /// Relative path to the asset file.
        src: String,
        /// Optional SHA-256 hex digest for content integrity.
        #[serde(default)]
        sha256: Option<String>,
    },
    /// Set the asset reference on an `image` node.
    ///
    /// The `asset_id` must reference a declared asset. An unknown `asset_id` is
    /// permitted here (post-validation catches it via `asset.unknown_reference`).
    /// An asset of kind `font` is eagerly rejected with `tx.invalid_value` because
    /// image nodes require an `image` or `svg` asset.
    ///
    /// Rejected with `tx.unknown_node` if `node_id` is not found.
    /// Rejected with `tx.wrong_node_type` if `node_id` is not an `image` node.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"set_asset","node_id":"pic","asset_id":"asset.hero"}
    /// ```
    SetAsset {
        /// The stable `id` of the image node to update.
        node_id: String,
        /// The asset id to assign to the image node's `asset` field.
        asset_id: String,
    },
    /// Evenly distribute a set of nodes along one axis so the gaps between
    /// consecutive nodes are equal, keeping the first and last node's outer
    /// edges fixed (standard "distribute spacing" semantics).
    ///
    /// The nodes are ordered by their current position on the chosen axis
    /// before distributing. Requires ≥ 3 alignable nodes; fewer than three
    /// emits `tx.noop` (consistent with `align_nodes`' degenerate-input
    /// convention) and leaves the document unchanged.
    ///
    /// Only nodes supported by `set_geometry` (`rect`, `ellipse`, `frame`,
    /// `image`, `text`, `code`, `group`) with resolvable `x/y/w/h` are
    /// distributable. A listed node that is missing yields `tx.unknown_node`;
    /// a node found but lacking resolvable geometry yields a
    /// `tx.unsupported_property` warning and is skipped.
    ///
    /// An unknown `axis` value is rejected with `tx.unsupported_property`.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"distribute_nodes","node_ids":["p1","p2","p3"],"axis":"horizontal"}
    /// ```
    DistributeNodes {
        /// Ids of the nodes to distribute.
        node_ids: Vec<String>,
        /// Axis to distribute along: `"horizontal"` or `"vertical"`.
        axis: String,
    },
    /// Create a new design token in the document's `tokens` block.
    ///
    /// `token_type` is one of `"color"`, `"dimension"`, `"number"`,
    /// `"fontFamily"`, `"fontWeight"`. `value` is the literal in string form:
    /// a color/family string (`"#e11d48"`, `"Inter"`), a dimension string
    /// (`"(px)40"`), or a number (`"700"`, `"1.05"`).
    ///
    /// Eagerly rejected with `tx.duplicate_id` if a token with `id` already
    /// exists.  Gradient/shadow/unknown types are rejected with
    /// `tx.invalid_value` (v0: scalar literal token types only; gradient/shadow
    /// tokens must be authored in source).
    ///
    /// JSON example:
    /// ```json
    /// {"op":"create_token","id":"color.brand","type":"color","value":"#e11d48"}
    /// ```
    CreateToken {
        /// Globally unique token id (e.g. `"color.brand"`).
        id: String,
        /// Token type string: `"color"`, `"dimension"`, `"number"`,
        /// `"fontFamily"`, or `"fontWeight"`.
        #[serde(rename = "type")]
        token_type: String,
        /// Literal value in string form appropriate for the declared type.
        value: String,
    },
    /// Replace the literal value of an existing token, preserving its declared
    /// type.
    ///
    /// `value` is parsed against the token's existing `token_type`; a value
    /// that does not parse for that type is rejected with `tx.invalid_value`.
    /// Rejected with `tx.unknown_token` if no token with `id` exists.
    /// Gradient/shadow tokens cannot be updated via this op → `tx.invalid_value`.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"update_token_value","id":"color.brand","value":"#3b82f6"}
    /// ```
    UpdateTokenValue {
        /// The id of the token to update.
        id: String,
        /// New literal value in string form appropriate for the token's existing type.
        value: String,
    },
    /// Set one recognized visual property on a named style to a token reference.
    ///
    /// `property` is a style property key (`fill`, `stroke`, `stroke-width`,
    /// `font-family`, `font-size`, `font-weight`, `line-height`, `radius`,
    /// `padding`, `gap`, `stroke-alignment`); underscore spellings are accepted
    /// and canonicalized. `value` is a token id, stored as
    /// `PropertyValue::TokenRef`.
    ///
    /// Rejected with `tx.unknown_style` if no style with `style_id` exists, and
    /// `tx.unsupported_property` if `property` is not a recognized style key.
    /// Unknown/incompatible token refs are caught by post-validation
    /// (`token.unknown_reference` / `token.incompatible_property`).
    SetStyleProperty {
        /// The id of the style definition to update (matches `style id="…"`).
        style_id: String,
        /// The style property key to set (e.g. `font-family`, `fill`).
        /// Underscore spellings such as `font_family` are accepted.
        property: String,
        /// Token id to store as `PropertyValue::TokenRef` (e.g. `"font.body"`).
        value: String,
    },
    /// Set the `direction` property on a text node. Valid values: `"ltr"`, `"rtl"`.
    /// Any other value is rejected with `tx.invalid_value`. A missing node yields
    /// `tx.unknown_node`; a non-text node yields `tx.wrong_node_type`.
    SetTextDirection {
        /// The stable node `id` to target.
        node: String,
        /// The new direction value: `"ltr"` or `"rtl"`.
        direction: String,
    },
    /// Literal find-and-replace across text node spans and shape label spans,
    /// preserving per-span formatting. `find` is a literal substring (NOT a
    /// regex); all occurrences within each span's text are replaced. When `node`
    /// is given, only that text node or shape is scoped; when omitted, ALL text
    /// nodes and shape labels in the document are scanned.
    ///
    /// `find` must be non-empty (`tx.invalid_value` otherwise). A scoped `node`
    /// that is missing yields `tx.unknown_node`; a scoped node that is neither a
    /// text node nor a shape yields `tx.wrong_node_type`. If no occurrence is
    /// found anywhere in scope, an advisory `tx.noop` is emitted and no node is
    /// recorded as affected.
    ///
    /// **Locked nodes:** a scoped locked node is guarded by the normal lock check
    /// (rejected unless `allow_locked`). In document-wide mode, locked text nodes
    /// and locked shapes are SKIPPED and reported via an advisory
    /// `tx.locked_skipped` (warning) that names them — they are never silently
    /// mutated.
    FindReplaceText {
        /// The literal substring to search for (not a regex). Must be non-empty.
        find: String,
        /// The replacement string (may be empty to delete occurrences).
        replace: String,
        /// When `Some(id)`, only the named text node or shape is scoped.
        /// When `None`, all text nodes and shape labels in the document are scanned.
        #[serde(default)]
        node: Option<String>,
    },
    /// Resize a page (artboard). `w`/`h` are canonical dimension strings like
    /// `"(px)794"` (same form parsed by `add_page`). Rejected with `tx.unknown_node`
    /// if no page with id `page` exists, and `tx.invalid_value` if `w`/`h` fail to
    /// parse or are not finite and > 0.
    ///
    /// NOTE: child node coordinates are NOT reflowed — after shrinking a page,
    /// children may fall outside the new bounds and trigger `off_canvas` advisories
    /// at validation. Repositioning children is a separate concern (set_geometry).
    SetPageSize {
        /// Id of the page to resize.
        page: String,
        /// New page width as a canonical dimension string, e.g. `"(px)794"`.
        w: String,
        /// New page height as a canonical dimension string, e.g. `"(px)1123"`.
        h: String,
    },
    /// Snap a single node's edge (or center) to the boundary of the page that
    /// contains it, with an optional margin inset.
    ///
    /// `edge`: `"left"`, `"right"`, `"top"`, `"bottom"`, `"hcenter"`, `"vcenter"`.
    /// `margin` (default 0) insets the node from that page edge (ignored for the
    /// center edges). For `left`/`top`/`hcenter`/`vcenter` margin is measured from
    /// the low edge; for `right`/`bottom` it is measured from the high edge.
    ///
    /// Computes: left → x = margin; right → x = page_w - node_w - margin;
    /// top → y = margin; bottom → y = page_h - node_h - margin;
    /// hcenter → x = (page_w - node_w)/2; vcenter → y = (page_h - node_h)/2.
    ///
    /// Rejected with `tx.unknown_node` if the node is missing, `tx.unsupported_property`
    /// if `edge` is not one of the six values or the node has no resolvable x/y/w/h
    /// geometry. (Composable: issue two ops — e.g. right + bottom — to snap to a corner.)
    AlignToEdge {
        /// The stable node `id` to snap.
        node: String,
        /// Which edge or centre to snap to: `left`, `right`, `top`, `bottom`,
        /// `hcenter`, or `vcenter`.
        edge: String,
        /// Margin in pixels inset from the page edge. Defaults to 0. Ignored for
        /// `hcenter` and `vcenter`.
        #[serde(default)]
        margin: f64,
    },
    /// Create a new recipe entry in the document's `recipes` block.
    ///
    /// Appends a new [`RecipeDef`](zenith_core::RecipeDef) with the given scalar fields and empty
    /// `params`, `palette`, `expanded`, and `unknown_props`; `source_span` is
    /// `None`. Eagerly rejected with `tx.duplicate_id` if a recipe with `id`
    /// already exists.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"create_recipe","id":"recipe.scatter","kind":"scatter","seed":42}
    /// ```
    CreateRecipe {
        /// Globally unique recipe id (e.g. `"recipe.scatter"`).
        id: String,
        /// Generator kind string (e.g. `"scatter"`, `"aurora"`).
        kind: String,
        /// Optional integer seed for deterministic generation.
        #[serde(default)]
        seed: Option<i64>,
        /// Optional generator version/hash string (e.g. `"aurora@1"`).
        #[serde(default)]
        generator: Option<String>,
        /// Optional frame/page id this recipe applies within.
        #[serde(default)]
        bounds: Option<String>,
        /// Optional detach state: `true` = detached, `false` = linked.
        #[serde(default)]
        detached: Option<bool>,
    },
    /// Replace the scalar fields of an existing recipe, preserving its
    /// `params`, `palette`, `expanded`, and `unknown_props`.
    ///
    /// The fields `kind`, `seed`, `generator`, `bounds`, and `detached` are
    /// replaced with the op's values. `None` for any `Option` field makes that
    /// field absent on the recipe. Rejected with `tx.unknown_recipe` if no
    /// recipe with `id` exists.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"update_recipe","id":"recipe.scatter","kind":"scatter","detached":true}
    /// ```
    UpdateRecipe {
        /// The id of the recipe to update.
        id: String,
        /// New generator kind string.
        kind: String,
        /// New seed value; `null`/absent clears the field.
        #[serde(default)]
        seed: Option<i64>,
        /// New generator version/hash; `null`/absent clears the field.
        #[serde(default)]
        generator: Option<String>,
        /// New bounds frame/page id; `null`/absent clears the field.
        #[serde(default)]
        bounds: Option<String>,
        /// New detach state; `null`/absent clears the field.
        #[serde(default)]
        detached: Option<bool>,
    },
    /// Remove a recipe from the document's `recipes` block by id.
    ///
    /// Rejected with `tx.unknown_recipe` if no recipe with `id` exists.
    ///
    /// JSON example:
    /// ```json
    /// {"op":"delete_recipe","id":"recipe.scatter"}
    /// ```
    DeleteRecipe {
        /// The id of the recipe to remove.
        id: String,
    },
    /// Materialize a `pattern` node into an editable `group` of native shapes —
    /// the "detach to native" path.
    ///
    /// The pattern is replaced in place by a group with the same id and the
    /// pattern's `x`/`y`/`w`/`h` bounds. The group's children are clones of the
    /// pattern's `motif`, one per instance position computed by
    /// `pattern_positions`, each placed at its instance offset within the group.
    /// Because the group translates its children by `x`/`y` exactly as the scene
    /// places live pattern instances, the detached group renders identically to
    /// the live pattern (same instance positions). Child ids are
    /// `<pattern-id>.0`, `<pattern-id>.1`, … in render order.
    ///
    /// Rejected with `tx.unknown_node` if no node with `node` exists.
    /// Rejected with `tx.not_a_pattern` if `node` is not a pattern.
    /// Rejected with `tx.pattern_unresolved_bounds` if the pattern's `w`/`h`
    /// cannot be resolved to a positive pixel size.
    /// Rejected with `tx.pattern_not_expandable` if the layout yields no
    /// instances (unknown kind or a missing required parameter).
    ///
    /// JSON example:
    /// ```json
    /// {"op":"detach_pattern","node":"dots"}
    /// ```
    DetachPattern {
        /// The stable id of the pattern node to detach into a native group.
        node: String,
    },
}

fn default_anchor() -> String {
    "selection".to_owned()
}