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}