zaniah 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ece04baf122d918de7ded4ab1ef943d1af3e50d85e23f9b496dafa77d8da3d7
4
- data.tar.gz: 34777228a8ea17ea82cda2edcf76d74f4bec7cc3b6fca6728e01082caba8c43a
3
+ metadata.gz: 905d6e52f6013be2a7b8e0223ece509526a9cde3a6198c8386e8b59903bf2dde
4
+ data.tar.gz: 57db74ac492c60d4d0f310c25596005c89faa706e2c8750cb6322b8091ac3671
5
5
  SHA512:
6
- metadata.gz: 9e9bf5c470a0c4f08c289a22472ae5e5df75c26d51cc9fb45d4896057d06f2bf727d72a409c9f5696e87ae38da3a3ecb32d993bf71c33d24d9c234aa3091e4ad
7
- data.tar.gz: 52ceec0f227e76e2c8096bb5ec9224a3ec314da00d2e6513314ab7294eb406023c73967bc631d4dadf9aedd2931adb7cc38aaebef9f2b5f60e176b323e8075ae
6
+ metadata.gz: 27209b822cd741f79110228e9ddefc63eccf3eee8ffe64f2467570c4dd9fbc126176cf1e39289d8afa3b144577335809a1aa7b77d21deba013050e32eb374fcf
7
+ data.tar.gz: 739e4c67fd1de82b7fce3e0682aa9ecb1380cdf629f315f61387989f64d89379247840e6d691a36e0d17bb148a30a7eb5fa82cd5d749857b6592c864e2880340
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add validated declarative element descriptions with event routing, tree diffs, and keyed surface reuse.
6
+
7
+ ## 0.6.0 — 2026-09-19
8
+
9
+ - Add the validated declarative Describe layer for remote UI trees.
10
+
5
11
  ## 0.5.3 — 2026-09-17
6
12
 
7
13
  - Restore Ruby 4.0/json 3 compatibility for process messages and atlas caches while keeping JSON object additions disabled.
data/README.md CHANGED
@@ -28,14 +28,15 @@ Zaniah is a pure Ruby UI toolkit for building desktop and terminal interfaces.
28
28
  It renders through native GPU APIs, a deterministic headless backend, or an ANSI
29
29
  terminal without extension compilation or a rendering subprocess.
30
30
 
31
- The name comes from the white ground laid before paint or gold leaf: Zaniah is
32
- the drawing surface beneath the editor.
31
+ Zaniah is named for a star in Virgo. The toolkit is the drawing surface beneath
32
+ the editor, providing the native rendering layer for its UI.
33
33
 
34
34
  ## Features
35
35
 
36
36
  - Flex, Grid, sticky, absolute, wrapping, and baseline-aware layouts
37
37
  - Gradients, transforms, paths, SVG, shadows, clipping, and GPU glyph atlases
38
38
  - Retained element state, subscriptions, and non-blocking background tasks
39
+ - Validated declarative element trees with event IDs, minimal diffs, and keyed reuse
39
40
  - Scroll views, inertial input, and uniform or variable-height virtual lists
40
41
  - Stable-ID pointer and keyboard reordering without materializing virtual collections
41
42
  - Stable-ID split-pane grids with fixed, fractional, and minmax tracks
@@ -124,6 +125,7 @@ Native backends use the operating system libraries through Ruby's Fiddle. See
124
125
  - [Accessibility](docs/accessibility.md) — semantic trees, diffs, and native bridges
125
126
  - [Terminal UI](docs/tui.md) — deterministic component degradation
126
127
  - [CPU process workers](docs/process_pool.md) — portable background CPU work
128
+ - [Declarative elements](docs/describe.md) — validated vocabularies, event routing, diffs, and surfaces
127
129
  - [Architecture decisions](docs/adr) — design records and rationale
128
130
  - [RBS declarations](sig) — public API signatures
129
131
 
data/docs/describe.md ADDED
@@ -0,0 +1,53 @@
1
+ # Declarative elements
2
+
3
+ `Zaniah::Describe` rebuilds element trees from plain data. The application owns
4
+ the vocabulary, so it decides which node types and properties untrusted or
5
+ remote producers may use.
6
+
7
+ ```ruby
8
+ vocabulary = Zaniah::Describe::Vocabulary.build do
9
+ node :column, props: {gap: :integer} do |props, children|
10
+ Zaniah::Div.new.flex_col.gap(props.fetch(:gap)).children(children)
11
+ end
12
+
13
+ node :button,
14
+ props: {label: :string, on_click: :handler}, children: :none do |props|
15
+ Zaniah::UI::Button.new(props.fetch(:label)).on_click(&props.fetch(:on_click))
16
+ end
17
+ end
18
+
19
+ tree = {
20
+ "type" => "column",
21
+ "props" => {"gap" => 8},
22
+ "children" => [{
23
+ "type" => "button",
24
+ "props" => {"label" => "Save", "on_click" => ["save", 1]},
25
+ "children" => [],
26
+ "key" => "save"
27
+ }]
28
+ }
29
+
30
+ surface = Zaniah::Describe::Surface.new(
31
+ vocabulary: vocabulary,
32
+ on_event: ->(id, payload) { send_event(id, payload) }
33
+ )
34
+ surface.replace(tree)
35
+ window.draw { surface.element }
36
+ ```
37
+
38
+ Property validators include `:string`, `:integer`, `:number`, `:boolean`,
39
+ `:array`, `:hash`, `:symbol`, `:handler`, `:any`, classes, callables, and arrays
40
+ of allowed values. Children may be `:none`, `:one`, `:many`, an exact count,
41
+ or a range. JSON string keys are normalized to symbols, and string values are
42
+ normalized for symbol enums.
43
+
44
+ Handler properties contain an opaque string or array ID. The factory receives
45
+ a callable in its place; invoking it sends the ID and the first payload argument
46
+ to `on_event`.
47
+
48
+ `Zaniah::Describe.diff(previous, current)` returns `Patch` records with
49
+ `:replace`, `:insert`, `:remove`, or `:update` operations. Paths are arrays of
50
+ child indexes from the root, and `:update` contains the complete new property
51
+ map. `Surface#apply` validates and applies a patch set atomically. Its `element`
52
+ reader performs no building or I/O, and unchanged keyed subtrees retain their
53
+ element instances across reordering.
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Describe
5
+ Node = Data.define(:type, :props, :children, :key)
6
+ Patch = Data.define(:op, :path, :node, :props)
7
+ MISSING = Object.new.freeze
8
+ private_constant :MISSING
9
+
10
+ class Vocabulary
11
+ Definition = Data.define(:props, :children, :factory)
12
+
13
+ def self.build(&block)
14
+ raise ArgumentError, "a vocabulary block is required" unless block
15
+ new.tap { |vocabulary| vocabulary.instance_eval(&block) }
16
+ end
17
+
18
+ def initialize
19
+ @definitions = {}
20
+ end
21
+
22
+ def node(type, props: {}, children: :many, &factory)
23
+ type = Describe.send(:normalize_type, type)
24
+ raise ArgumentError, "node #{type.inspect} is already registered" if @definitions.key?(type)
25
+ raise ArgumentError, "node #{type.inspect} needs a factory" unless factory
26
+
27
+ properties = Describe.send(:normalize_props, props)
28
+ properties.each_value { |validator| validate_validator!(validator) }
29
+ validate_children_rule!(children)
30
+ @definitions[type] = Definition.new(properties.freeze, children, factory)
31
+ self
32
+ end
33
+
34
+ def allow?(type)
35
+ @definitions.key?(Describe.send(:normalize_type, type))
36
+ rescue ArgumentError
37
+ false
38
+ end
39
+
40
+ def validate!(value)
41
+ node = Describe.send(:normalize_node, value)
42
+ validate_node!(node)
43
+ end
44
+
45
+ private
46
+
47
+ def definition(type)
48
+ @definitions.fetch(type) { raise ArgumentError, "node type #{type.inspect} is not allowed" }
49
+ end
50
+
51
+ def validate_node!(node)
52
+ entry = definition(node.type)
53
+ unknown = node.props.keys - entry.props.keys
54
+ raise ArgumentError, "unknown properties for #{node.type.inspect}: #{unknown.join(", ")}" unless unknown.empty?
55
+
56
+ props = node.props.each_with_object({}) do |(name, value), validated|
57
+ validator = entry.props.fetch(name)
58
+ value = normalize_property(validator, value)
59
+ raise ArgumentError, "invalid #{name.inspect} property for #{node.type.inspect}" unless valid_property?(validator, value)
60
+ validated[name] = value
61
+ end
62
+ children = node.children.map { |child| validate_node!(child) }.freeze
63
+ validated = Node.new(node.type, props.freeze, children, node.key)
64
+ validate_children!(entry.children, validated)
65
+ duplicate_keys = children.map(&:key).reject(&:nil?).tally.select { |_key, count| count > 1 }.keys
66
+ raise ArgumentError, "duplicate child keys for #{node.type.inspect}: #{duplicate_keys.inspect}" unless duplicate_keys.empty?
67
+ validated
68
+ end
69
+
70
+ def validate_children!(rule, node)
71
+ count = node.children.length
72
+ valid = case rule
73
+ when :none then count.zero?
74
+ when :one then count == 1
75
+ when :many then true
76
+ when Integer then count == rule
77
+ when Range then rule.cover?(count)
78
+ end
79
+ raise ArgumentError, "invalid child count for #{node.type.inspect}" unless valid
80
+ end
81
+
82
+ def validate_children_rule!(rule)
83
+ return if %i[none one many].include?(rule) || rule.is_a?(Integer) || rule.is_a?(Range)
84
+ raise ArgumentError, "children must be :none, :one, :many, an Integer, or a Range"
85
+ end
86
+
87
+ def validate_validator!(validator)
88
+ valid = validator.is_a?(Array) || validator.is_a?(Module) || validator.respond_to?(:call) ||
89
+ %i[any string integer number boolean array hash symbol handler].include?(validator)
90
+ raise ArgumentError, "unsupported property validator #{validator.inspect}" unless valid
91
+ end
92
+
93
+ def valid_property?(validator, value)
94
+ case validator
95
+ when :any then true
96
+ when :string then value.is_a?(String)
97
+ when :integer then value.is_a?(Integer)
98
+ when :number then value.is_a?(Numeric)
99
+ when :boolean then value == true || value == false
100
+ when :array then value.is_a?(Array)
101
+ when :hash then value.is_a?(Hash)
102
+ when :symbol then value.is_a?(Symbol)
103
+ when :handler then value.is_a?(String) || value.is_a?(Array)
104
+ when Array then validator.include?(value)
105
+ when Module then value.is_a?(validator)
106
+ else validator.call(value)
107
+ end
108
+ end
109
+
110
+ def normalize_property(validator, value)
111
+ return value.to_sym if validator == :symbol && value.is_a?(String)
112
+ if validator.is_a?(Array) && value.is_a?(String) && validator.any? { |item| item.is_a?(Symbol) }
113
+ symbol = value.to_sym
114
+ return symbol if validator.include?(symbol)
115
+ end
116
+ value
117
+ end
118
+
119
+ def build_node(node, children, on_event)
120
+ entry = definition(node.type)
121
+ props = node.props.each_with_object({}) do |(name, value), converted|
122
+ converted[name] = entry.props[name] == :handler ? event_handler(value, on_event) : value
123
+ end
124
+ element = entry.factory.call(props.freeze, children.freeze)
125
+ unless element.respond_to?(:request_layout) && element.respond_to?(:prepaint) && element.respond_to?(:paint)
126
+ raise TypeError, "factory for #{node.type.inspect} must return a renderable element"
127
+ end
128
+ element.key(node.key) if !node.key.nil? && element.respond_to?(:key)
129
+ element
130
+ end
131
+
132
+ def event_handler(id, on_event)
133
+ ->(payload = nil, *) { on_event.call(id, payload) }
134
+ end
135
+ private_constant :Definition
136
+ end
137
+
138
+ Mounted = Struct.new(:node, :element, :children)
139
+ private_constant :Mounted
140
+
141
+ class Surface
142
+ def initialize(vocabulary:, on_event:)
143
+ raise ArgumentError, "vocabulary must be a Vocabulary" unless vocabulary.is_a?(Vocabulary)
144
+ raise ArgumentError, "on_event must be callable" unless on_event.respond_to?(:call)
145
+ @vocabulary, @on_event = vocabulary, on_event
146
+ end
147
+
148
+ def replace(value)
149
+ if value.nil?
150
+ @node = @mounted = @element = nil
151
+ else
152
+ node = @vocabulary.validate!(value)
153
+ mounted = Describe.send(:mount, node, @vocabulary, @on_event, nil)
154
+ @node, @mounted, @element = node, mounted, mounted.element
155
+ end
156
+ self
157
+ end
158
+
159
+ def apply(values)
160
+ patches = Array(values).map { |value| Describe.send(:normalize_patch, value) }
161
+ candidate = patches.reduce(@node) { |node, patch| Describe.send(:apply_patch, node, patch) }
162
+ candidate = @vocabulary.validate!(candidate) if candidate
163
+ mounted = candidate && Describe.send(:mount, candidate, @vocabulary, @on_event, @mounted)
164
+ @node, @mounted, @element = candidate, mounted, mounted&.element
165
+ self
166
+ end
167
+
168
+ def element = @element
169
+ def empty? = @element.nil?
170
+ end
171
+
172
+ class << self
173
+ def build(value, vocabulary:, on_event:)
174
+ raise ArgumentError, "vocabulary must be a Vocabulary" unless vocabulary.is_a?(Vocabulary)
175
+ raise ArgumentError, "on_event must be callable" unless on_event.respond_to?(:call)
176
+ node = vocabulary.validate!(value)
177
+ mount(node, vocabulary, on_event, nil).element
178
+ end
179
+
180
+ def diff(previous, current)
181
+ previous = normalize_node(previous) if previous
182
+ current = normalize_node(current) if current
183
+ return [] if previous == current
184
+ return [Patch.new(:insert, [].freeze, current, nil)] unless previous
185
+ return [Patch.new(:remove, [].freeze, nil, nil)] unless current
186
+
187
+ [].tap { |patches| diff_node(previous, current, [], patches) }
188
+ end
189
+
190
+ private
191
+
192
+ def normalize_type(type)
193
+ raise ArgumentError, "node type must be a Symbol or String" unless type.is_a?(Symbol) || type.is_a?(String)
194
+ type.to_sym
195
+ end
196
+
197
+ def normalize_props(value)
198
+ raise ArgumentError, "props must be a Hash" unless value.is_a?(Hash)
199
+ value.each_with_object({}) do |(key, item), props|
200
+ raise ArgumentError, "property names must be Symbols or Strings" unless key.is_a?(Symbol) || key.is_a?(String)
201
+ name = key.to_sym
202
+ raise ArgumentError, "duplicate property #{name.inspect}" if props.key?(name)
203
+ props[name] = item
204
+ end
205
+ end
206
+
207
+ def normalize_node(value)
208
+ return Node.new(value.type, normalize_props(value.props).freeze,
209
+ normalize_children(value.children), value.key) if value.is_a?(Node)
210
+ raise ArgumentError, "node must be a Describe::Node or Hash" unless value.is_a?(Hash)
211
+
212
+ keys = value.keys.map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
213
+ unknown = keys - %i[type props children key]
214
+ raise ArgumentError, "unknown node fields: #{unknown.inspect}" unless unknown.empty?
215
+ type = fetch_field(value, :type)
216
+ props = fetch_field(value, :props, {})
217
+ children = fetch_field(value, :children, [])
218
+ key = fetch_field(value, :key, nil)
219
+ Node.new(normalize_type(type), normalize_props(props).freeze, normalize_children(children), key)
220
+ end
221
+
222
+ def normalize_children(value)
223
+ raise ArgumentError, "children must be an Array" unless value.is_a?(Array)
224
+ value.map { |child| normalize_node(child) }.freeze
225
+ end
226
+
227
+ def fetch_field(hash, name, default = MISSING)
228
+ return hash[name] if hash.key?(name)
229
+ string = name.to_s
230
+ return hash[string] if hash.key?(string)
231
+ raise ArgumentError, "node is missing #{name.inspect}" if default.equal?(MISSING)
232
+ default
233
+ end
234
+
235
+ def mount(node, vocabulary, on_event, previous)
236
+ return previous if previous&.node == node
237
+
238
+ previous_children = previous&.children || []
239
+ keyed = previous_children.each_with_object({}) do |mounted, by_key|
240
+ key = mounted.node.key
241
+ by_key[[mounted.node.type, key]] = mounted unless key.nil?
242
+ end
243
+ children = node.children.each_with_index.map do |child, index|
244
+ match = if child.key.nil?
245
+ previous_children[index] if previous_children[index]&.node&.key.nil?
246
+ else
247
+ keyed[[child.type, child.key]]
248
+ end
249
+ mount(child, vocabulary, on_event, match)
250
+ end
251
+ element = vocabulary.send(:build_node, node, children.map(&:element), on_event)
252
+ Mounted.new(node, element, children)
253
+ end
254
+
255
+ def diff_node(previous, current, path, patches)
256
+ unless previous.type == current.type && previous.key == current.key
257
+ patches << Patch.new(:replace, path.freeze, current, nil)
258
+ return
259
+ end
260
+ patches << Patch.new(:update, path.freeze, nil, current.props) unless previous.props == current.props
261
+ diff_children(previous.children, current.children, path, patches)
262
+ end
263
+
264
+ def diff_children(previous, current, path, patches)
265
+ working = previous.dup
266
+ index = 0
267
+ while index < current.length
268
+ wanted = current[index]
269
+ present = working[index]
270
+ if present && same_slot?(present, wanted)
271
+ diff_node(present, wanted, path + [index], patches)
272
+ working[index] = wanted
273
+ index += 1
274
+ elsif present&.key && !current[index..].any? { |node| same_slot?(present, node) }
275
+ patches << Patch.new(:remove, (path + [index]).freeze, nil, nil)
276
+ working.delete_at(index)
277
+ elsif wanted.key && (from = working.each_index.find { |at| at > index && same_slot?(working[at], wanted) })
278
+ patches << Patch.new(:remove, (path + [from]).freeze, nil, nil)
279
+ working.delete_at(from)
280
+ patches << Patch.new(:insert, (path + [index]).freeze, wanted, nil)
281
+ working.insert(index, wanted)
282
+ index += 1
283
+ elsif wanted.key || present.nil?
284
+ patches << Patch.new(:insert, (path + [index]).freeze, wanted, nil)
285
+ working.insert(index, wanted)
286
+ index += 1
287
+ else
288
+ diff_node(present, wanted, path + [index], patches)
289
+ working[index] = wanted
290
+ index += 1
291
+ end
292
+ end
293
+ (working.length - 1).downto(current.length) do |at|
294
+ patches << Patch.new(:remove, (path + [at]).freeze, nil, nil)
295
+ end
296
+ end
297
+
298
+ def same_slot?(left, right)
299
+ if !left.key.nil? || !right.key.nil?
300
+ !left.key.nil? && left.key == right.key
301
+ else
302
+ left.type == right.type
303
+ end
304
+ end
305
+
306
+ def normalize_patch(value)
307
+ return Patch.new(normalize_operation(value.op), normalize_path(value.path),
308
+ value.node && normalize_node(value.node), value.props && normalize_props(value.props).freeze) if value.is_a?(Patch)
309
+ raise ArgumentError, "patch must be a Describe::Patch or Hash" unless value.is_a?(Hash)
310
+ op = normalize_operation(fetch_patch_field(value, :op))
311
+ path = normalize_path(fetch_patch_field(value, :path))
312
+ node = fetch_patch_field(value, :node, nil)
313
+ props = fetch_patch_field(value, :props, nil)
314
+ Patch.new(op, path, node && normalize_node(node), props && normalize_props(props).freeze)
315
+ end
316
+
317
+ def fetch_patch_field(hash, name, default = MISSING)
318
+ return hash[name] if hash.key?(name)
319
+ return hash[name.to_s] if hash.key?(name.to_s)
320
+ raise ArgumentError, "patch is missing #{name.inspect}" if default.equal?(MISSING)
321
+ default
322
+ end
323
+
324
+ def normalize_operation(value)
325
+ raise ArgumentError, "patch operation must be a Symbol or String" unless value.is_a?(Symbol) || value.is_a?(String)
326
+ value.to_sym
327
+ end
328
+
329
+ def normalize_path(value)
330
+ raise ArgumentError, "patch path must be an Array" unless value.is_a?(Array)
331
+ value.map do |index|
332
+ index = Integer(index)
333
+ raise ArgumentError, "patch path indexes must be non-negative" if index.negative?
334
+ index
335
+ end.freeze
336
+ end
337
+
338
+ def apply_patch(root, patch)
339
+ raise ArgumentError, "unknown patch operation #{patch.op.inspect}" unless %i[replace insert remove update].include?(patch.op)
340
+ case patch.op
341
+ when :replace
342
+ raise ArgumentError, "replace patch needs a node" unless patch.node
343
+ return patch.node if patch.path.empty?
344
+ rewrite_at(root, patch.path) { |_node| patch.node }
345
+ when :insert
346
+ raise ArgumentError, "insert patch needs a node" unless patch.node
347
+ return patch.node if patch.path.empty? && root.nil?
348
+ change_children(root, patch.path, insert: patch.node)
349
+ when :remove
350
+ return nil if patch.path.empty?
351
+ change_children(root, patch.path, remove: true)
352
+ when :update
353
+ raise ArgumentError, "update patch needs props" unless patch.props
354
+ rewrite_at(root, patch.path) { |node| node.with(props: patch.props) }
355
+ end
356
+ end
357
+
358
+ def rewrite_at(node, path, &block)
359
+ raise ArgumentError, "patch path does not exist" unless node
360
+ return yield(node) if path.empty?
361
+ index = path.first
362
+ raise ArgumentError, "patch path does not exist" unless index < node.children.length
363
+ children = node.children.dup
364
+ children[index] = rewrite_at(children[index], path.drop(1), &block)
365
+ node.with(children: children.freeze)
366
+ end
367
+
368
+ def change_children(root, path, insert: nil, remove: false)
369
+ raise ArgumentError, "insert and remove require a child path" if path.empty?
370
+ index = path.last
371
+ rewrite_at(root, path[0...-1]) do |parent|
372
+ children = parent.children.dup
373
+ if remove
374
+ raise ArgumentError, "patch path does not exist" unless index < children.length
375
+ children.delete_at(index)
376
+ else
377
+ raise ArgumentError, "patch insertion is outside the children" unless index <= children.length
378
+ children.insert(index, insert)
379
+ end
380
+ parent.with(children: children.freeze)
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.5.3"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/zaniah.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "zaniah/process_pool"
37
37
  require_relative "zaniah/input"
38
38
  require_relative "zaniah/drag_drop"
39
39
  require_relative "zaniah/element"
40
+ require_relative "zaniah/describe"
40
41
  Zaniah.autoload(:SVG, File.expand_path("zaniah/svg", __dir__))
41
42
  require_relative "zaniah/scroll_state"
42
43
  require_relative "zaniah/scroll_view"
data/sig/describe.rbs ADDED
@@ -0,0 +1,44 @@
1
+ module Zaniah
2
+ module Describe
3
+ type node_input = Node | Hash[Symbol | String, untyped]
4
+ type patch_input = Patch | Hash[Symbol | String, untyped]
5
+ type event_callback = ^(untyped id, untyped payload) -> untyped
6
+
7
+ class Node < Data
8
+ attr_reader type: Symbol
9
+ attr_reader props: Hash[Symbol, untyped]
10
+ attr_reader children: Array[Node]
11
+ attr_reader key: untyped
12
+ def self.new: (Symbol type, Hash[Symbol, untyped] props, Array[Node] children, untyped key) -> Node
13
+ | (type: Symbol, props: Hash[Symbol, untyped], children: Array[Node], key: untyped) -> Node
14
+ end
15
+
16
+ class Patch < Data
17
+ attr_reader op: :replace | :insert | :remove | :update
18
+ attr_reader path: Array[Integer]
19
+ attr_reader node: Node?
20
+ attr_reader props: Hash[Symbol, untyped]?
21
+ def self.new: (:replace | :insert | :remove | :update op, Array[Integer] path, Node? node, Hash[Symbol, untyped]? props) -> Patch
22
+ | (op: :replace | :insert | :remove | :update, path: Array[Integer], node: Node?, props: Hash[Symbol, untyped]?) -> Patch
23
+ end
24
+
25
+ class Vocabulary
26
+ def self.build: () { (Vocabulary) -> void } -> Vocabulary
27
+ def initialize: () -> void
28
+ def node: (Symbol | String type, ?props: Hash[Symbol | String, untyped], ?children: :none | :one | :many | Integer | Range[Integer]) { (Hash[Symbol, untyped] props, Array[_Renderable] children) -> _Renderable } -> self
29
+ def allow?: (Symbol | String type) -> bool
30
+ def validate!: (node_input value) -> Node
31
+ end
32
+
33
+ def self.build: (node_input value, vocabulary: Vocabulary, on_event: event_callback) -> _Renderable
34
+ def self.diff: (node_input? previous, node_input? current) -> Array[Patch]
35
+
36
+ class Surface
37
+ def initialize: (vocabulary: Vocabulary, on_event: event_callback) -> void
38
+ def replace: (node_input? value) -> self
39
+ def apply: (Array[patch_input] patches) -> self
40
+ def element: () -> _Renderable?
41
+ def empty?: () -> bool
42
+ end
43
+ end
44
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: alhena
@@ -87,6 +87,7 @@ files:
87
87
  - docs/adr/README.md
88
88
  - docs/animation.md
89
89
  - docs/components.md
90
+ - docs/describe.md
90
91
  - docs/drag_drop.md
91
92
  - docs/gallery/command-palette-dark.png
92
93
  - docs/gallery/command-palette-high_contrast.png
@@ -195,6 +196,7 @@ files:
195
196
  - lib/zaniah/configuration.rb
196
197
  - lib/zaniah/corners.rb
197
198
  - lib/zaniah/data_compat.rb
199
+ - lib/zaniah/describe.rb
198
200
  - lib/zaniah/device_pixels.rb
199
201
  - lib/zaniah/devtools.rb
200
202
  - lib/zaniah/devtools/hot_reload.rb
@@ -360,6 +362,7 @@ files:
360
362
  - sig/accessibility.rbs
361
363
  - sig/animation.rbs
362
364
  - sig/app.rbs
365
+ - sig/describe.rbs
363
366
  - sig/devtools.rbs
364
367
  - sig/drag_drop.rbs
365
368
  - sig/elements.rbs