Skip to main content

zenith_tx/
op.rs

1//! Transaction envelope: [`Transaction`] and the [`Op`] enum.
2//!
3//! Deserializes from JSON like:
4//! ```json
5//! {"ops":[
6//!   {"op":"set_text_align","node":"label","align":"center"},
7//!   {"op":"set_fill","node":"box","fill":"color.accent"},
8//!   {"op":"set_stroke","node":"box","stroke":"color.rule"},
9//!   {"op":"set_stroke_width","node":"box","stroke_width":"size.stroke"},
10//!   {"op":"set_visible","node":"box","visible":false},
11//!   {"op":"set_locked","node":"box","locked":true},
12//!   {"op":"set_geometry","node":"r","x":10,"w":200},
13//!   {"op":"set_points","node":"poly","points":[{"x":0,"y":0},{"x":100,"y":0},{"x":50,"y":80}]}
14//! ]}
15//! ```
16
17use crate::TxError;
18
19/// A 2-D vertex used by [`Op::SetPoints`], expressed in pixels.
20///
21/// JSON shape: `{"x": 50.0, "y": 80.0}`
22#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
23pub struct OpPoint {
24    /// X coordinate in document pixels.
25    pub x: f64,
26    /// Y coordinate in document pixels.
27    pub y: f64,
28}
29
30/// A single text span used by [`Op::ReplaceText`].
31///
32/// JSON shape: `{"text":"Hello","fill":"color.brand","italic":true}`.
33/// All fields except `text` are optional and default to `None`/absent.
34/// `fill` and `font_weight` are token ids (like [`Op::SetFill`]), not raw values.
35#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
36pub struct OpSpan {
37    /// The literal text content of this span.
38    pub text: String,
39    /// Token id to set as the per-span fill (e.g. `"color.brand"`). `None` = inherit.
40    #[serde(default)]
41    pub fill: Option<String>,
42    /// Token id to set as the per-span font-weight. `None` = inherit.
43    #[serde(default)]
44    pub font_weight: Option<String>,
45    /// Italic override. `None` = inherit.
46    #[serde(default)]
47    pub italic: Option<bool>,
48    /// Underline decoration. `None` = inherit.
49    #[serde(default)]
50    pub underline: Option<bool>,
51    /// Strikethrough decoration. `None` = inherit.
52    #[serde(default)]
53    pub strikethrough: Option<bool>,
54    /// Vertical alignment (`"super"` / `"sub"`). `None` = baseline (inherit).
55    #[serde(default)]
56    pub vertical_align: Option<String>,
57    /// Footnote reference — the id of a page-level footnote. `None` = no ref.
58    #[serde(default)]
59    pub footnote_ref: Option<String>,
60}
61
62/// Insertion position for [`Op::AddNode`] within a container's children.
63///
64/// JSON shapes: `{"at":"last"}`, `{"at":"first"}`, `{"at":"index","index":2}`,
65/// `{"at":"before","id":"sibling"}`, `{"at":"after","id":"sibling"}`.
66#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Default)]
67#[serde(tag = "at", rename_all = "snake_case")]
68pub enum Position {
69    /// Insert as the last child (topmost in z-order). Default.
70    #[default]
71    Last,
72    /// Insert as the first child (bottommost in z-order).
73    First,
74    /// Insert at an explicit index (clamped to the children length).
75    Index { index: usize },
76    /// Insert immediately before the sibling with this id.
77    Before { id: String },
78    /// Insert immediately after the sibling with this id.
79    After { id: String },
80}
81
82/// Per-transaction permission flags that relax otherwise-enforced guards.
83///
84/// Carried in a transaction's optional `"permissions"` object, e.g.
85/// `{"permissions":{"allow_locked":false,"allow_raw_visual_literals":false}}`.
86/// Both flags default to `false`, so a transaction JSON that omits the
87/// `permissions` key still parses with all guards active.
88#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Default)]
89pub struct Permissions {
90    /// When `true`, mutating ops are allowed to target locked nodes.
91    /// When `false` (default), a guarded op against a locked node is rejected
92    /// with a `node.locked` diagnostic.
93    #[serde(default)]
94    pub allow_locked: bool,
95    /// When `true`, raw (non-token) visual literal values are permitted.
96    #[serde(default)]
97    pub allow_raw_visual_literals: bool,
98}
99
100/// A batch of operations to apply to a document in order.
101#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
102pub struct Transaction {
103    pub ops: Vec<Op>,
104    /// Permission flags relaxing per-op guards. Defaults to all-`false`
105    /// (every guard active) when the `permissions` key is absent from JSON.
106    #[serde(default)]
107    pub permissions: Permissions,
108}
109
110impl Transaction {
111    /// Parse a `Transaction` from a JSON string.
112    pub fn from_json(s: &str) -> Result<Transaction, TxError> {
113        serde_json::from_str(s).map_err(|e| TxError {
114            message: format!("failed to parse transaction JSON: {e}"),
115        })
116    }
117}
118
119/// A single operation within a [`Transaction`].
120///
121/// The `op` field in JSON is the snake_case tag, e.g. `"set_text_align"`.
122#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
123#[serde(tag = "op", rename_all = "snake_case")]
124pub enum Op {
125    /// Set the `align` property on a text node.
126    ///
127    /// Valid values: `start`, `center`, `end`, `justify`.
128    SetTextAlign {
129        /// The stable node `id` to target.
130        node: String,
131        /// The new alignment value.
132        align: String,
133    },
134    /// Move a node one sibling position toward the end (front/top of z-order).
135    ///
136    /// Has no effect if the node is already last in its parent's children.
137    MoveForward {
138        /// The stable node `id` to target.
139        node: String,
140    },
141    /// Move a node one sibling position toward the beginning (back/bottom of z-order).
142    ///
143    /// Has no effect if the node is already first in its parent's children.
144    MoveBackward {
145        /// The stable node `id` to target.
146        node: String,
147    },
148    /// Move a node to the topmost position (last child) in its parent's children.
149    ///
150    /// Has no effect if the node is already the last sibling (frontmost/topmost).
151    MoveToFront {
152        /// The stable node `id` to target.
153        node: String,
154    },
155    /// Move a node to the bottommost position (first child) in its parent's children.
156    ///
157    /// Has no effect if the node is already the first sibling (backmost/bottommost).
158    MoveToBack {
159        /// The stable node `id` to target.
160        node: String,
161    },
162    /// Set the `fill` property on a node that supports fill.
163    ///
164    /// The `fill` value is a token id (e.g. `"color.accent"`); the engine
165    /// wraps it as `PropertyValue::TokenRef(fill)`. Post-validation rejects
166    /// unknown token ids automatically.
167    ///
168    /// Supported nodes: `rect`, `ellipse`, `text`, `polygon`, `polyline`.
169    /// Unsupported: `line`, `frame`, `group`, `image` — yields
170    /// `tx.unsupported_property`.
171    SetFill {
172        /// The stable node `id` to target.
173        node: String,
174        /// Token id to set as the fill (e.g. `"color.brand"`).
175        fill: String,
176    },
177    /// Set the `stroke` (outline color) property on a node that supports stroke.
178    ///
179    /// The `stroke` value is a token id (e.g. `"color.rule"`); the engine wraps it
180    /// as `PropertyValue::TokenRef(stroke)`. Post-validation rejects unknown token
181    /// ids automatically.
182    ///
183    /// Supported nodes: `rect`, `line`, `polygon`, `polyline`.
184    /// Unsupported: `ellipse` (fill-only), `text`, `frame`, `group`, `image` —
185    /// yields `tx.unsupported_property`.
186    SetStroke {
187        /// The stable node `id` to target.
188        node: String,
189        /// Token id to set as the stroke color (e.g. `"color.rule"`).
190        stroke: String,
191    },
192    /// Set the `stroke-width` property on a node that supports stroke.
193    ///
194    /// The value is a **dimension token id** (e.g. `"size.stroke"`), stored as
195    /// `PropertyValue::TokenRef`. A token (not a raw number) is required because
196    /// v0 stroke-width only resolves through dimension tokens at compile time;
197    /// post-validation rejects unknown token ids automatically.
198    ///
199    /// Supported nodes: `rect`, `line`, `polygon`, `polyline`.
200    /// Unsupported: `ellipse`, `text`, `frame`, `group`, `image` — yields
201    /// `tx.unsupported_property`.
202    SetStrokeWidth {
203        /// The stable node `id` to target.
204        node: String,
205        /// Dimension token id to set as the stroke width (e.g. `"size.stroke"`).
206        stroke_width: String,
207    },
208    /// Show or hide a node by setting its `visible` property.
209    ///
210    /// All known node variants except `Unknown` support this property.
211    SetVisible {
212        /// The stable node `id` to target.
213        node: String,
214        /// `false` hides the node; `true` makes it visible.
215        visible: bool,
216    },
217    /// Lock or unlock a node by setting its `locked` property.
218    ///
219    /// All known node variants except `Unknown` support this property.
220    SetLocked {
221        /// The stable node `id` to target.
222        node: String,
223        /// `true` locks the node; `false` unlocks it.
224        locked: bool,
225    },
226    /// Move and/or resize a bbox node by updating its `x`, `y`, `w`, `h`
227    /// geometry fields, and optionally set its `rotate` angle. All five fields
228    /// are optional — only the fields present in the JSON payload are changed;
229    /// omitted fields are left untouched.
230    ///
231    /// Values are in document pixels (`(px)` unit) for `x`/`y`/`w`/`h`.
232    /// `rotate` is in degrees (`(deg)` unit at storage; pass a raw `f64` here).
233    ///
234    /// Supported nodes for x/y/w/h: `rect`, `ellipse`, `frame`, `image`,
235    /// `text`, `code`, `group`, `field`.
236    /// Supported nodes for rotate: `rect`, `ellipse`, `frame`, `image`, `text`,
237    /// `code`, `group`, `polygon`, `polyline`.
238    /// Unsupported for rotate: `line`, `instance`, `field`, `footnote`,
239    /// `unknown` — yields `tx.unsupported_property`.
240    ///
241    /// If all five fields are omitted, an advisory `tx.noop` is emitted and no
242    /// node is recorded as affected.
243    ///
244    /// JSON example (partial — only x, w, and rotate change):
245    /// ```json
246    /// {"op":"set_geometry","node":"r","x":10,"w":200,"rotate":45}
247    /// ```
248    SetGeometry {
249        /// The stable node `id` to target.
250        node: String,
251        /// New left edge in pixels. Omit to leave unchanged.
252        #[serde(default)]
253        x: Option<f64>,
254        /// New top edge in pixels. Omit to leave unchanged.
255        #[serde(default)]
256        y: Option<f64>,
257        /// New width in pixels. Omit to leave unchanged.
258        #[serde(default)]
259        w: Option<f64>,
260        /// New height in pixels. Omit to leave unchanged.
261        #[serde(default)]
262        h: Option<f64>,
263        /// New rotation in degrees. Omit to leave unchanged.
264        #[serde(default)]
265        rotate: Option<f64>,
266    },
267    /// Replace the entire vertex list of a `polygon` or `polyline` node.
268    ///
269    /// Post-validation rejects automatically if the new point count falls
270    /// below the node's minimum (`polygon` needs ≥ 3, `polyline` needs ≥ 2).
271    ///
272    /// Supported nodes: `polygon`, `polyline`.
273    /// Unsupported: all other variants — yields `tx.unsupported_property`.
274    ///
275    /// JSON example:
276    /// ```json
277    /// {"op":"set_points","node":"poly","points":[{"x":0,"y":0},{"x":100,"y":0},{"x":50,"y":80}]}
278    /// ```
279    SetPoints {
280        /// The stable node `id` to target.
281        node: String,
282        /// Replacement vertex list. Each vertex is in document pixels.
283        points: Vec<OpPoint>,
284    },
285    /// Construct a new node from a `.zen` source fragment and insert it into a
286    /// container (a page, group, or frame) at a chosen position.
287    ///
288    /// `source` is a single `.zen` node fragment, e.g.
289    /// `rect id="box" x=(px)10 y=(px)10 w=(px)100 h=(px)80 fill=(token)"color.accent"`.
290    /// It is parsed through the canonical KDL parser, so every node kind, nested
291    /// children (for group/frame), tokens, and properties are supported with no
292    /// per-field mapping. Exactly one top-level node must be present.
293    ///
294    /// Post-validation rejects an incomplete/invalid node automatically (missing
295    /// required geometry, duplicate id, unknown token/asset ref, too few points, …).
296    AddNode {
297        /// Stable id of the container to insert into: a page id, or a group/frame id.
298        parent: String,
299        /// Where among the container's children to insert. Defaults to `last`.
300        #[serde(default)]
301        position: Position,
302        /// A single `.zen` node fragment to construct and insert.
303        source: String,
304    },
305    /// Remove a node (and its subtree) by id from whatever container holds it.
306    ///
307    /// Rejects with `tx.unknown_node` if no node with that id exists.
308    RemoveNode {
309        /// The stable node `id` to remove.
310        node: String,
311    },
312    /// Set the `opacity` of a node (0.0 = fully transparent, 1.0 = fully opaque).
313    ///
314    /// The value is clamped to `[0.0, 1.0]` before being stored.
315    ///
316    /// Supported nodes: all concrete variants (`rect`, `ellipse`, `line`, `text`,
317    /// `code`, `frame`, `group`, `image`, `polygon`, `polyline`).
318    /// Unsupported: `unknown` — yields `tx.unsupported_property`.
319    SetOpacity {
320        /// The stable node `id` to target.
321        node: String,
322        /// New opacity value; clamped to `[0.0, 1.0]`.
323        opacity: f64,
324    },
325    /// Replace the entire span list of a `text` node with a new set of spans.
326    ///
327    /// The `spans` vec fully replaces `TextNode.spans`. Replacing with an empty
328    /// vec is valid and clears all text content. `fill` and `font_weight` in each
329    /// [`OpSpan`] are token ids wrapped as `PropertyValue::TokenRef`; post-validation
330    /// rejects unknown token ids automatically (same as `set_fill`).
331    ///
332    /// Supported nodes: `text`, and `shape` (replaces the shape's owned label
333    /// spans, which use the same span model as a text node).
334    /// Unsupported: all other variants — yields `tx.unsupported_property`.
335    ReplaceText {
336        /// The stable node `id` to target.
337        node: String,
338        /// Replacement span list. Each span's `text` is required; all other fields
339        /// are optional and default to `None` (inherit from node-level styles).
340        spans: Vec<OpSpan>,
341    },
342    /// Duplicate a leaf node, assigning it a new id, and insert the clone
343    /// immediately after the original in the same parent's children.
344    ///
345    /// **v0 scope — leaf nodes only.** Duplicating a container (`frame` or
346    /// `group`) is rejected with `tx.unsupported_property`. A deep-clone would
347    /// copy all descendant ids, producing duplicate ids throughout the subtree;
348    /// re-id'ing an entire subtree is deferred to a future version.
349    ///
350    /// Post-validation catches a `new_id` that collides with an existing node
351    /// id via the `id.duplicate` diagnostic (same as [`Op::AddNode`]).
352    ///
353    /// Rejects with `tx.unknown_node` if `node` does not exist in the document.
354    ///
355    /// JSON example:
356    /// ```json
357    /// {"op":"duplicate_node","node":"box","new_id":"box-copy"}
358    /// ```
359    DuplicateNode {
360        /// The stable id of the node to duplicate.
361        node: String,
362        /// The id to assign to the newly created clone.
363        new_id: String,
364    },
365    /// Duplicate an entire page (and its full subtree), inserting the copy
366    /// immediately after the source page in the document body.
367    ///
368    /// Unlike [`Op::DuplicateNode`] (leaf-only, v0), this performs a deep clone:
369    /// the new page gets `new_id`, and **every descendant node id** in the copy
370    /// is suffixed with `id_suffix` so all ids stay unique. Any page-level
371    /// `safe_zones[].id` is suffixed the same way.
372    ///
373    /// `duplicate_page` only *creates* new content and never mutates the source,
374    /// so it is exempt from lock enforcement.
375    ///
376    /// Rejects with `tx.unknown_node` if no page with id `page` exists.
377    /// Post-validation rejects the transaction if `id_suffix` fails to keep ids
378    /// unique (e.g. an empty suffix) via the `id.duplicate` diagnostic — that is
379    /// the safety net; an empty suffix also emits a helpful advisory.
380    ///
381    /// JSON example:
382    /// ```json
383    /// {"op":"duplicate_page","page":"page.x","new_id":"page.x2","id_suffix":".v2"}
384    /// ```
385    DuplicatePage {
386        /// Source page id to clone.
387        page: String,
388        /// Id for the new (duplicated) page.
389        new_id: String,
390        /// Suffix appended to EVERY descendant node id in the copy (keeps ids unique).
391        id_suffix: String,
392    },
393    /// Wrap a set of sibling nodes inside a new group node.
394    ///
395    /// All `node_ids` must be **direct siblings under the same parent**
396    /// (a page, group, or frame). If any id is not found, or if the ids
397    /// do not all share one common parent, the op is rejected with
398    /// `tx.invalid_parent`.
399    ///
400    /// The new group is inserted at the position of the **earliest** (lowest
401    /// index) member, preserving z-order. The grouped nodes are transferred
402    /// into the new group in their original relative order.
403    ///
404    /// Post-validation catches a `group_id` that collides with an existing
405    /// node id via the `id.duplicate` diagnostic.
406    ///
407    /// **v0 note:** the group is created with `x`/`y` = `None` (no translation
408    /// offset). Children keep their authored coordinates; any visual shift must
409    /// be handled by the caller by adjusting child geometry separately.
410    ///
411    /// JSON example:
412    /// ```json
413    /// {"op":"group","node_ids":["rect1","rect2"],"group_id":"grp-new"}
414    /// ```
415    Group {
416        /// Ids of the nodes to group. Must be ≥ 1 and share a common parent.
417        node_ids: Vec<String>,
418        /// The id to assign to the newly created group node.
419        group_id: String,
420    },
421    /// Dissolve a group node, moving its children up to the group's parent.
422    ///
423    /// The group is replaced in-place by its children (spliced at the group's
424    /// original index), preserving source order.
425    ///
426    /// Rejects with `tx.unknown_node` if `group_id` is not found.
427    /// Rejects with `tx.unsupported_property` ("not a group") if the node is
428    /// not a `group` variant.
429    ///
430    /// **v0 limitation:** the group's own `x`/`y` translation is NOT applied
431    /// to children on ungroup (children keep their authored coordinates). If the
432    /// group had a non-zero `x`/`y` offset, the rendered positions of children
433    /// may shift after ungroup. An advisory is emitted in that case.
434    ///
435    /// JSON example:
436    /// ```json
437    /// {"op":"ungroup","group_id":"grp1"}
438    /// ```
439    Ungroup {
440        /// The id of the group node to dissolve.
441        group_id: String,
442    },
443    /// Move a node to a different container (page, group, or frame).
444    ///
445    /// Rejects with `tx.unknown_node` if `node` is not found.
446    /// Rejects with `tx.invalid_parent` if `new_parent` is not a container
447    /// (page, group, or frame), or if `new_parent` is `node` itself or a
448    /// descendant of `node` (cycle detection).
449    ///
450    /// `position` controls where in the new parent's children the node is
451    /// inserted; defaults to [`Position::Last`] (top of z-order).
452    ///
453    /// JSON example:
454    /// ```json
455    /// {"op":"reparent","node":"rect1","new_parent":"grp1","position":{"at":"last"}}
456    /// ```
457    Reparent {
458        /// The stable id of the node to move.
459        node: String,
460        /// The id of the container to move the node into.
461        new_parent: String,
462        /// Where to insert the node in the new parent. Defaults to `last`.
463        #[serde(default)]
464        position: Position,
465    },
466    /// Align a set of nodes to a common edge or centre along one axis.
467    ///
468    /// `align` controls the alignment target:
469    /// - Horizontal: `"left"`, `"hcenter"`, `"right"`
470    /// - Vertical: `"top"`, `"vcenter"`, `"bottom"`
471    ///
472    /// `anchor` controls the reference rectangle:
473    /// - `"selection"` (default): the union bounding box of all alignable nodes.
474    /// - `"page"`: the page that contains the nodes (0,0 to page w/h).
475    /// - a node id: the bbox of that node.
476    /// - an explicit dimension like `"(px)120"`: align the chosen edge of every
477    ///   listed node to that absolute page coordinate. For the horizontal edges
478    ///   (`left`, `hcenter`, `right`) the value is an X coordinate; for the
479    ///   vertical edges (`top`, `vcenter`, `bottom`) it is a Y coordinate.
480    ///
481    /// Only nodes supported by `set_geometry` (`rect`, `ellipse`, `frame`,
482    /// `image`) with resolvable `x/y/w/h` in px/pt are alignable. Any node
483    /// that lacks full geometry is skipped with a `tx.geometry_unresolved`
484    /// warning; the rest are still aligned.
485    ///
486    /// An unknown `align` value is rejected with `tx.unsupported_property`.
487    /// An unknown `anchor` value is rejected with `tx.unsupported_property`.
488    /// A `"(px)…"` anchor whose dimension cannot be parsed is rejected with
489    /// `tx.invalid_value`.
490    /// Fewer than one alignable node emits `tx.noop`.
491    ///
492    /// JSON example:
493    /// ```json
494    /// {"op":"align_nodes","node_ids":["a","b","caption"],"align":"left","anchor":"(px)120"}
495    /// ```
496    AlignNodes {
497        /// Ids of the nodes to align.
498        node_ids: Vec<String>,
499        /// Which edge or centre to align to: `left`, `hcenter`, `right`,
500        /// `top`, `vcenter`, or `bottom`.
501        align: String,
502        /// Reference rectangle: `"selection"` (union bbox), `"page"`, a node id,
503        /// or an explicit dimension like `"(px)120"`. Defaults to `"selection"`.
504        #[serde(default = "default_anchor")]
505        anchor: String,
506    },
507    /// Set the `overflow` property of a `text` or `code` node.
508    ///
509    /// Valid values: `"fit"`, `"clip"`, `"visible"`. Any other value is rejected
510    /// with `tx.invalid_value`.
511    ///
512    /// Supported nodes: `text`, `code`.
513    /// Unsupported: all other variants — yields `tx.wrong_node_type`.
514    /// A missing node yields `tx.unknown_node`.
515    ///
516    /// JSON example:
517    /// ```json
518    /// {"op":"set_text_overflow","node_id":"body","overflow":"visible"}
519    /// ```
520    SetTextOverflow {
521        /// The stable node `id` to target.
522        node_id: String,
523        /// The new overflow value: `fit`, `clip`, or `visible`.
524        overflow: String,
525    },
526    /// Create a new EMPTY page (no children) and insert it into the document
527    /// body at `index` (0-based) or, when `index` is `None`, append it at the
528    /// end.
529    ///
530    /// `w` and `h` are canonical dimension strings like `"(px)1800"` / `"(pt)90"`
531    /// (the same `(unit)value` form parsed by other ops). `background`, when
532    /// present, is a token-ref id (e.g. `"color.bg"`) stored as
533    /// `PropertyValue::TokenRef` — exactly like [`Op::SetFill`].
534    ///
535    /// Rejects with `tx.duplicate_id` if a page (or any node) already uses `id`.
536    /// Rejects with `tx.invalid_value` if `w`/`h` fail to parse as a dimension.
537    /// Rejects with `tx.out_of_range` if `index` is past the end of the page list.
538    ///
539    /// The new page carries no children, safe-zones, folds, margins, or bleed —
540    /// it is a blank canvas. Post-validation still runs over the whole document.
541    ///
542    /// JSON example:
543    /// ```json
544    /// {"op":"add_page","id":"page.new","w":"(px)1800","h":"(px)1200","index":1}
545    /// ```
546    AddPage {
547        /// Stable id for the new page (must be unique document-wide).
548        id: String,
549        /// Page width as a canonical dimension string, e.g. `"(px)1800"`.
550        w: String,
551        /// Page height as a canonical dimension string, e.g. `"(px)1200"`.
552        h: String,
553        /// Optional background token-ref id (e.g. `"color.bg"`). `None` = no fill.
554        #[serde(default)]
555        background: Option<String>,
556        /// 0-based insert position. `None` appends at the end.
557        #[serde(default)]
558        index: Option<usize>,
559    },
560    /// Remove the page whose id == `page` (and its entire subtree) from the
561    /// document body.
562    ///
563    /// Rejects with `tx.unknown_node` if no page with that id exists.
564    ///
565    /// JSON example:
566    /// ```json
567    /// {"op":"delete_page","page":"page.old"}
568    /// ```
569    DeletePage {
570        /// Id of the page to remove.
571        page: String,
572    },
573    /// Reorder the document body's pages to match `order`.
574    ///
575    /// `order` must be a permutation of the existing page ids: the same set,
576    /// with no duplicates and nothing missing or extra. On success the pages are
577    /// rearranged so their ids follow `order` exactly.
578    ///
579    /// Rejects with `tx.invalid_value` if `order` is not a permutation (an id is
580    /// missing, extra, duplicated, or unknown).
581    ///
582    /// JSON example:
583    /// ```json
584    /// {"op":"reorder_pages","order":["page.b","page.a","page.c"]}
585    /// ```
586    ReorderPages {
587        /// The new full ordering of page ids (a permutation of the existing set).
588        order: Vec<String>,
589    },
590    /// Declare a new asset in the document's `assets` block.
591    ///
592    /// `kind` must be one of `"image"`, `"svg"`, or `"font"`. `src` is a relative
593    /// path to the asset file. `sha256` is an optional content-integrity digest.
594    ///
595    /// Rejected immediately with `tx.duplicate_id` if an asset with `id` already
596    /// exists. Post-validation catches `asset.invalid_src` (absolute paths, `../`
597    /// components, URLs) and `asset.invalid_kind` (unrecognized kinds).
598    ///
599    /// JSON example:
600    /// ```json
601    /// {"op":"add_asset","id":"asset.logo","kind":"image","src":"images/logo.png","sha256":"abc123"}
602    /// ```
603    AddAsset {
604        /// Globally unique asset id (e.g. `"asset.logo"`).
605        id: String,
606        /// Asset kind string: `"image"`, `"svg"`, or `"font"`.
607        kind: String,
608        /// Relative path to the asset file.
609        src: String,
610        /// Optional SHA-256 hex digest for content integrity.
611        #[serde(default)]
612        sha256: Option<String>,
613    },
614    /// Set the asset reference on an `image` node.
615    ///
616    /// The `asset_id` must reference a declared asset. An unknown `asset_id` is
617    /// permitted here (post-validation catches it via `asset.unknown_reference`).
618    /// An asset of kind `font` is eagerly rejected with `tx.invalid_value` because
619    /// image nodes require an `image` or `svg` asset.
620    ///
621    /// Rejected with `tx.unknown_node` if `node_id` is not found.
622    /// Rejected with `tx.wrong_node_type` if `node_id` is not an `image` node.
623    ///
624    /// JSON example:
625    /// ```json
626    /// {"op":"set_asset","node_id":"pic","asset_id":"asset.hero"}
627    /// ```
628    SetAsset {
629        /// The stable `id` of the image node to update.
630        node_id: String,
631        /// The asset id to assign to the image node's `asset` field.
632        asset_id: String,
633    },
634    /// Evenly distribute a set of nodes along one axis so the gaps between
635    /// consecutive nodes are equal, keeping the first and last node's outer
636    /// edges fixed (standard "distribute spacing" semantics).
637    ///
638    /// The nodes are ordered by their current position on the chosen axis
639    /// before distributing. Requires ≥ 3 alignable nodes; fewer than three
640    /// emits `tx.noop` (consistent with `align_nodes`' degenerate-input
641    /// convention) and leaves the document unchanged.
642    ///
643    /// Only nodes supported by `set_geometry` (`rect`, `ellipse`, `frame`,
644    /// `image`, `text`, `code`, `group`) with resolvable `x/y/w/h` are
645    /// distributable. A listed node that is missing yields `tx.unknown_node`;
646    /// a node found but lacking resolvable geometry yields a
647    /// `tx.unsupported_property` warning and is skipped.
648    ///
649    /// An unknown `axis` value is rejected with `tx.unsupported_property`.
650    ///
651    /// JSON example:
652    /// ```json
653    /// {"op":"distribute_nodes","node_ids":["p1","p2","p3"],"axis":"horizontal"}
654    /// ```
655    DistributeNodes {
656        /// Ids of the nodes to distribute.
657        node_ids: Vec<String>,
658        /// Axis to distribute along: `"horizontal"` or `"vertical"`.
659        axis: String,
660    },
661    /// Create a new design token in the document's `tokens` block.
662    ///
663    /// `token_type` is one of `"color"`, `"dimension"`, `"number"`,
664    /// `"fontFamily"`, `"fontWeight"`. `value` is the literal in string form:
665    /// a color/family string (`"#e11d48"`, `"Inter"`), a dimension string
666    /// (`"(px)40"`), or a number (`"700"`, `"1.05"`).
667    ///
668    /// Eagerly rejected with `tx.duplicate_id` if a token with `id` already
669    /// exists.  Gradient/shadow/unknown types are rejected with
670    /// `tx.invalid_value` (v0: scalar literal token types only; gradient/shadow
671    /// tokens must be authored in source).
672    ///
673    /// JSON example:
674    /// ```json
675    /// {"op":"create_token","id":"color.brand","type":"color","value":"#e11d48"}
676    /// ```
677    CreateToken {
678        /// Globally unique token id (e.g. `"color.brand"`).
679        id: String,
680        /// Token type string: `"color"`, `"dimension"`, `"number"`,
681        /// `"fontFamily"`, or `"fontWeight"`.
682        #[serde(rename = "type")]
683        token_type: String,
684        /// Literal value in string form appropriate for the declared type.
685        value: String,
686    },
687    /// Replace the literal value of an existing token, preserving its declared
688    /// type.
689    ///
690    /// `value` is parsed against the token's existing `token_type`; a value
691    /// that does not parse for that type is rejected with `tx.invalid_value`.
692    /// Rejected with `tx.unknown_token` if no token with `id` exists.
693    /// Gradient/shadow tokens cannot be updated via this op → `tx.invalid_value`.
694    ///
695    /// JSON example:
696    /// ```json
697    /// {"op":"update_token_value","id":"color.brand","value":"#3b82f6"}
698    /// ```
699    UpdateTokenValue {
700        /// The id of the token to update.
701        id: String,
702        /// New literal value in string form appropriate for the token's existing type.
703        value: String,
704    },
705    /// Set one recognized visual property on a named style to a token reference.
706    ///
707    /// `property` is a style property key (`fill`, `stroke`, `stroke-width`,
708    /// `font-family`, `font-size`, `font-weight`, `line-height`, `radius`,
709    /// `padding`, `gap`, `stroke-alignment`); underscore spellings are accepted
710    /// and canonicalized. `value` is a token id, stored as
711    /// `PropertyValue::TokenRef`.
712    ///
713    /// Rejected with `tx.unknown_style` if no style with `style_id` exists, and
714    /// `tx.unsupported_property` if `property` is not a recognized style key.
715    /// Unknown/incompatible token refs are caught by post-validation
716    /// (`token.unknown_reference` / `token.incompatible_property`).
717    SetStyleProperty {
718        /// The id of the style definition to update (matches `style id="…"`).
719        style_id: String,
720        /// The style property key to set (e.g. `font-family`, `fill`).
721        /// Underscore spellings such as `font_family` are accepted.
722        property: String,
723        /// Token id to store as `PropertyValue::TokenRef` (e.g. `"font.body"`).
724        value: String,
725    },
726    /// Set the `direction` property on a text node. Valid values: `"ltr"`, `"rtl"`.
727    /// Any other value is rejected with `tx.invalid_value`. A missing node yields
728    /// `tx.unknown_node`; a non-text node yields `tx.wrong_node_type`.
729    SetTextDirection {
730        /// The stable node `id` to target.
731        node: String,
732        /// The new direction value: `"ltr"` or `"rtl"`.
733        direction: String,
734    },
735    /// Literal find-and-replace across text node spans and shape label spans,
736    /// preserving per-span formatting. `find` is a literal substring (NOT a
737    /// regex); all occurrences within each span's text are replaced. When `node`
738    /// is given, only that text node or shape is scoped; when omitted, ALL text
739    /// nodes and shape labels in the document are scanned.
740    ///
741    /// `find` must be non-empty (`tx.invalid_value` otherwise). A scoped `node`
742    /// that is missing yields `tx.unknown_node`; a scoped node that is neither a
743    /// text node nor a shape yields `tx.wrong_node_type`. If no occurrence is
744    /// found anywhere in scope, an advisory `tx.noop` is emitted and no node is
745    /// recorded as affected.
746    ///
747    /// **Locked nodes:** a scoped locked node is guarded by the normal lock check
748    /// (rejected unless `allow_locked`). In document-wide mode, locked text nodes
749    /// and locked shapes are SKIPPED and reported via an advisory
750    /// `tx.locked_skipped` (warning) that names them — they are never silently
751    /// mutated.
752    FindReplaceText {
753        /// The literal substring to search for (not a regex). Must be non-empty.
754        find: String,
755        /// The replacement string (may be empty to delete occurrences).
756        replace: String,
757        /// When `Some(id)`, only the named text node or shape is scoped.
758        /// When `None`, all text nodes and shape labels in the document are scanned.
759        #[serde(default)]
760        node: Option<String>,
761    },
762    /// Resize a page (artboard). `w`/`h` are canonical dimension strings like
763    /// `"(px)794"` (same form parsed by `add_page`). Rejected with `tx.unknown_node`
764    /// if no page with id `page` exists, and `tx.invalid_value` if `w`/`h` fail to
765    /// parse or are not finite and > 0.
766    ///
767    /// NOTE: child node coordinates are NOT reflowed — after shrinking a page,
768    /// children may fall outside the new bounds and trigger `off_canvas` advisories
769    /// at validation. Repositioning children is a separate concern (set_geometry).
770    SetPageSize {
771        /// Id of the page to resize.
772        page: String,
773        /// New page width as a canonical dimension string, e.g. `"(px)794"`.
774        w: String,
775        /// New page height as a canonical dimension string, e.g. `"(px)1123"`.
776        h: String,
777    },
778    /// Snap a single node's edge (or center) to the boundary of the page that
779    /// contains it, with an optional margin inset.
780    ///
781    /// `edge`: `"left"`, `"right"`, `"top"`, `"bottom"`, `"hcenter"`, `"vcenter"`.
782    /// `margin` (default 0) insets the node from that page edge (ignored for the
783    /// center edges). For `left`/`top`/`hcenter`/`vcenter` margin is measured from
784    /// the low edge; for `right`/`bottom` it is measured from the high edge.
785    ///
786    /// Computes: left → x = margin; right → x = page_w - node_w - margin;
787    /// top → y = margin; bottom → y = page_h - node_h - margin;
788    /// hcenter → x = (page_w - node_w)/2; vcenter → y = (page_h - node_h)/2.
789    ///
790    /// Rejected with `tx.unknown_node` if the node is missing, `tx.unsupported_property`
791    /// if `edge` is not one of the six values or the node has no resolvable x/y/w/h
792    /// geometry. (Composable: issue two ops — e.g. right + bottom — to snap to a corner.)
793    AlignToEdge {
794        /// The stable node `id` to snap.
795        node: String,
796        /// Which edge or centre to snap to: `left`, `right`, `top`, `bottom`,
797        /// `hcenter`, or `vcenter`.
798        edge: String,
799        /// Margin in pixels inset from the page edge. Defaults to 0. Ignored for
800        /// `hcenter` and `vcenter`.
801        #[serde(default)]
802        margin: f64,
803    },
804    /// Create a new recipe entry in the document's `recipes` block.
805    ///
806    /// Appends a new [`RecipeDef`](zenith_core::RecipeDef) with the given scalar fields and empty
807    /// `params`, `palette`, `expanded`, and `unknown_props`; `source_span` is
808    /// `None`. Eagerly rejected with `tx.duplicate_id` if a recipe with `id`
809    /// already exists.
810    ///
811    /// JSON example:
812    /// ```json
813    /// {"op":"create_recipe","id":"recipe.scatter","kind":"scatter","seed":42}
814    /// ```
815    CreateRecipe {
816        /// Globally unique recipe id (e.g. `"recipe.scatter"`).
817        id: String,
818        /// Generator kind string (e.g. `"scatter"`, `"aurora"`).
819        kind: String,
820        /// Optional integer seed for deterministic generation.
821        #[serde(default)]
822        seed: Option<i64>,
823        /// Optional generator version/hash string (e.g. `"aurora@1"`).
824        #[serde(default)]
825        generator: Option<String>,
826        /// Optional frame/page id this recipe applies within.
827        #[serde(default)]
828        bounds: Option<String>,
829        /// Optional detach state: `true` = detached, `false` = linked.
830        #[serde(default)]
831        detached: Option<bool>,
832    },
833    /// Replace the scalar fields of an existing recipe, preserving its
834    /// `params`, `palette`, `expanded`, and `unknown_props`.
835    ///
836    /// The fields `kind`, `seed`, `generator`, `bounds`, and `detached` are
837    /// replaced with the op's values. `None` for any `Option` field makes that
838    /// field absent on the recipe. Rejected with `tx.unknown_recipe` if no
839    /// recipe with `id` exists.
840    ///
841    /// JSON example:
842    /// ```json
843    /// {"op":"update_recipe","id":"recipe.scatter","kind":"scatter","detached":true}
844    /// ```
845    UpdateRecipe {
846        /// The id of the recipe to update.
847        id: String,
848        /// New generator kind string.
849        kind: String,
850        /// New seed value; `null`/absent clears the field.
851        #[serde(default)]
852        seed: Option<i64>,
853        /// New generator version/hash; `null`/absent clears the field.
854        #[serde(default)]
855        generator: Option<String>,
856        /// New bounds frame/page id; `null`/absent clears the field.
857        #[serde(default)]
858        bounds: Option<String>,
859        /// New detach state; `null`/absent clears the field.
860        #[serde(default)]
861        detached: Option<bool>,
862    },
863    /// Remove a recipe from the document's `recipes` block by id.
864    ///
865    /// Rejected with `tx.unknown_recipe` if no recipe with `id` exists.
866    ///
867    /// JSON example:
868    /// ```json
869    /// {"op":"delete_recipe","id":"recipe.scatter"}
870    /// ```
871    DeleteRecipe {
872        /// The id of the recipe to remove.
873        id: String,
874    },
875    /// Materialize a `pattern` node into an editable `group` of native shapes —
876    /// the "detach to native" path.
877    ///
878    /// The pattern is replaced in place by a group with the same id and the
879    /// pattern's `x`/`y`/`w`/`h` bounds. The group's children are clones of the
880    /// pattern's `motif`, one per instance position computed by
881    /// `pattern_positions`, each placed at its instance offset within the group.
882    /// Because the group translates its children by `x`/`y` exactly as the scene
883    /// places live pattern instances, the detached group renders identically to
884    /// the live pattern (same instance positions). Child ids are
885    /// `<pattern-id>.0`, `<pattern-id>.1`, … in render order.
886    ///
887    /// Rejected with `tx.unknown_node` if no node with `node` exists.
888    /// Rejected with `tx.not_a_pattern` if `node` is not a pattern.
889    /// Rejected with `tx.pattern_unresolved_bounds` if the pattern's `w`/`h`
890    /// cannot be resolved to a positive pixel size.
891    /// Rejected with `tx.pattern_not_expandable` if the layout yields no
892    /// instances (unknown kind or a missing required parameter).
893    ///
894    /// JSON example:
895    /// ```json
896    /// {"op":"detach_pattern","node":"dots"}
897    /// ```
898    DetachPattern {
899        /// The stable id of the pattern node to detach into a native group.
900        node: String,
901    },
902}
903
904fn default_anchor() -> String {
905    "selection".to_owned()
906}