dominus-cli 0.6.0 → 2.1.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.
@@ -1,727 +1,857 @@
1
- --[[
2
- UIBuilder: create entire UI trees from a declarative JSON spec in one call.
3
- Handles all type coercion natively in Luau — no round trips needed.
4
- ]]
5
-
6
- local Explorer = require(script.Parent.Explorer)
7
- local Reflection = require(script.Parent.Reflection)
8
-
9
- local UIBuilder = {}
10
-
11
- -- Cache property type maps per ClassName to avoid repeated reflection calls
12
- local propertyTypeCache = {}
13
- local function getPropertyType(className, propName)
14
- if not propertyTypeCache[className] then
15
- propertyTypeCache[className] = Reflection.getPropertyTypes(className)
16
- end
17
- return propertyTypeCache[className][propName]
18
- end
19
-
20
- local function resolveColor(value)
21
- if type(value) == "string" then
22
- if value:sub(1, 1) == "#" then
23
- return Color3.fromHex(value)
24
- end
25
- local r, g, b = value:match("rgb%s*%((%d+)%D+(%d+)%D+(%d+)%)")
26
- if r then
27
- return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
28
- end
29
- local ok, bc = pcall(BrickColor.new, value)
30
- if ok then
31
- return bc.Color
32
- end
33
- end
34
- if type(value) == "table" then
35
- if value[1] then
36
- if value[1] > 1 or value[2] > 1 or value[3] > 1 then
37
- return Color3.fromRGB(value[1], value[2], value[3])
38
- end
39
- return Color3.new(value[1], value[2], value[3])
40
- end
41
- if value.R or value.r then
42
- return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
43
- end
44
- end
45
- return Color3.new(1, 1, 1)
46
- end
47
-
48
- local function resolveUDim2(value)
49
- if type(value) == "table" then
50
- if value.XScale or value.xScale then
51
- return UDim2.new(
52
- value.XScale or value.xScale or 0,
53
- value.XOffset or value.xOffset or 0,
54
- value.YScale or value.yScale or 0,
55
- value.YOffset or value.yOffset or 0
56
- )
57
- end
58
- if value.X and type(value.X) == "table" then
59
- return UDim2.new(
60
- value.X.Scale or value.X[1] or 0,
61
- value.X.Offset or value.X[2] or 0,
62
- value.Y and (value.Y.Scale or value.Y[1]) or 0,
63
- value.Y and (value.Y.Offset or value.Y[2]) or 0
64
- )
65
- end
66
- if value[1] ~= nil then
67
- return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
68
- end
69
- end
70
- if type(value) == "string" then
71
- local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
72
- if a then
73
- return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
74
- end
75
- end
76
- return UDim2.new(0, 0, 0, 0)
77
- end
78
-
79
- local function resolveUDim(value)
80
- if type(value) == "table" then
81
- return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
82
- end
83
- if type(value) == "number" then
84
- return UDim.new(0, value)
85
- end
86
- return UDim.new(0, 0)
87
- end
88
-
89
- local function resolveVector2(value)
90
- if type(value) == "table" then
91
- return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
92
- end
93
- return Vector2.new(0, 0)
94
- end
95
-
96
- local function resolveEnum(instance, key, value)
97
- if type(value) ~= "string" then
98
- return value
99
- end
100
- local curOk, curVal = pcall(function()
101
- return instance[key]
102
- end)
103
- if curOk and typeof(curVal) == "EnumItem" then
104
- local enumType = tostring(curVal.EnumType)
105
- local ok, enumVal = pcall(function()
106
- return Enum[enumType][value]
107
- end)
108
- if ok then
109
- return enumVal
110
- end
111
- end
112
- return value
113
- end
114
-
115
- local function resolveFont(value)
116
- if type(value) == "string" then
117
- local ok, font = pcall(function()
118
- return Enum.Font[value]
119
- end)
120
- if ok then
121
- return font
122
- end
123
- end
124
- return value
125
- end
126
-
127
- local UDIM2_PROPS = {
128
- Size = true,
129
- Position = true,
130
- CellSize = true,
131
- CellPadding = true,
132
- }
133
-
134
- local COLOR3_PROPS = {
135
- BackgroundColor3 = true,
136
- BorderColor3 = true,
137
- TextColor3 = true,
138
- ImageColor3 = true,
139
- PlaceholderColor3 = true,
140
- TextStrokeColor3 = true,
141
- ScrollBarImageColor3 = true,
142
- }
143
-
144
- local function resolveColorSequence(value)
145
- if type(value) == "table" then
146
- -- Array of keypoints: {{time, color}, ...}
147
- if type(value[1]) == "table" then
148
- local keypoints = {}
149
- for _, kp in ipairs(value) do
150
- local t = kp[1] or kp.Time or 0
151
- local c = resolveColor(kp[2] or kp.Color or kp)
152
- table.insert(keypoints, ColorSequenceKeypoint.new(t, c))
153
- end
154
- return ColorSequence.new(keypoints)
155
- end
156
- -- Single color as array [r,g,b] → uniform ColorSequence
157
- if type(value[1]) == "number" and #value == 3 then
158
- local c = resolveColor(value)
159
- return ColorSequence.new(c)
160
- end
161
- end
162
- -- String hex/rgb → uniform ColorSequence
163
- if type(value) == "string" then
164
- return ColorSequence.new(resolveColor(value))
165
- end
166
- return ColorSequence.new(Color3.new(1, 1, 1))
167
- end
168
-
169
- local function resolveNumberSequence(value)
170
- if type(value) == "table" then
171
- -- Array of keypoints: {{time, value, envelope?}, ...}
172
- if type(value[1]) == "table" then
173
- local keypoints = {}
174
- for _, kp in ipairs(value) do
175
- local t = kp[1] or kp.Time or 0
176
- local v = kp[2] or kp.Value or 0
177
- local e = kp[3] or kp.Envelope or 0
178
- table.insert(keypoints, NumberSequenceKeypoint.new(t, v, e))
179
- end
180
- return NumberSequence.new(keypoints)
181
- end
182
- end
183
- if type(value) == "number" then
184
- return NumberSequence.new(value)
185
- end
186
- return NumberSequence.new(0)
187
- end
188
-
189
- local VECTOR2_PROPS = {
190
- AnchorPoint = true,
191
- CanvasSize_v2 = true,
192
- AbsoluteSize = true,
193
- AbsolutePosition = true,
194
- }
195
-
196
- local UDIM_PROPS = {
197
- CornerRadius = true,
198
- Padding = true,
199
- PaddingTop = true,
200
- PaddingBottom = true,
201
- PaddingLeft = true,
202
- PaddingRight = true,
203
- FillDirection = false, -- not UDim
204
- }
205
-
206
- local NUMBER_RANGE_PROPS = {
207
- Range = true,
208
- RotationRange = true,
209
- Size_NR = true, -- particle size range alias
210
- }
211
-
212
- local RECT_PROPS = {
213
- SliceCenter = true,
214
- ImageRectOffset_Rect = true,
215
- }
216
-
217
- local function resolveNumberRange(value)
218
- if type(value) == "table" then
219
- return NumberRange.new(value[1] or 0, value[2] or value[1] or 0)
220
- end
221
- if type(value) == "number" then
222
- return NumberRange.new(value)
223
- end
224
- return NumberRange.new(0)
225
- end
226
-
227
- local function resolveRect(value)
228
- if type(value) == "table" then
229
- if #value == 4 then
230
- return Rect.new(value[1], value[2], value[3], value[4])
231
- end
232
- return Rect.new(
233
- value.Min and value.Min[1] or value.MinX or 0,
234
- value.Min and value.Min[2] or value.MinY or 0,
235
- value.Max and value.Max[1] or value.MaxX or 0,
236
- value.Max and value.Max[2] or value.MaxY or 0
237
- )
238
- end
239
- return Rect.new(0, 0, 0, 0)
240
- end
241
-
242
- -- Disambiguate 2-element arrays: check actual property type on the instance
243
- local function resolve2ElementArray(instance, key, value)
244
- -- First, check our known lookup tables
245
- if VECTOR2_PROPS[key] then
246
- return resolveVector2(value)
247
- end
248
- if UDIM_PROPS[key] then
249
- return resolveUDim(value)
250
- end
251
- if NUMBER_RANGE_PROPS[key] then
252
- return resolveNumberRange(value)
253
- end
254
- -- Fall back to runtime type introspection
255
- local ok, curVal = pcall(function()
256
- return instance[key]
257
- end)
258
- if ok and curVal ~= nil then
259
- local t = typeof(curVal)
260
- if t == "Vector2" then
261
- return resolveVector2(value)
262
- end
263
- if t == "UDim" then
264
- return resolveUDim(value)
265
- end
266
- if t == "NumberRange" then
267
- return resolveNumberRange(value)
268
- end
269
- end
270
- -- Default: assume UDim for 2-element (more common in UI)
271
- return resolveUDim(value)
272
- end
273
-
274
- local function applyProperty(instance, key, value)
275
- -- Special handling by property name
276
- if key == "Size" or key == "Position" then
277
- if instance:IsA("GuiObject") or instance:IsA("UIBase") then
278
- instance[key] = resolveUDim2(value)
279
- return
280
- end
281
- end
282
-
283
- if key == "AnchorPoint" then
284
- if instance:IsA("GuiObject") then
285
- instance[key] = resolveVector2(value)
286
- return
287
- end
288
- end
289
-
290
- if key == "CanvasSize" and instance:IsA("ScrollingFrame") then
291
- instance[key] = resolveUDim2(value)
292
- return
293
- end
294
-
295
- if UDIM_PROPS[key] then
296
- instance[key] = resolveUDim(value)
297
- return
298
- end
299
-
300
- -- Use reflection to distinguish Color3 vs ColorSequence, NumberSequence, etc.
301
- local propType = getPropertyType(instance.ClassName, key)
302
-
303
- if propType == "ColorSequence" then
304
- instance[key] = resolveColorSequence(value)
305
- return
306
- end
307
-
308
- if propType == "NumberSequence" then
309
- instance[key] = resolveNumberSequence(value)
310
- return
311
- end
312
-
313
- if COLOR3_PROPS[key] or propType == "Color3" then
314
- instance[key] = resolveColor(value)
315
- return
316
- end
317
-
318
- -- Reflection-based coercion for types not in hardcoded tables
319
- if propType == "UDim2" and type(value) == "table" then
320
- instance[key] = resolveUDim2(value)
321
- return
322
- end
323
- if propType == "UDim" then
324
- instance[key] = resolveUDim(value)
325
- return
326
- end
327
- if propType == "Vector2" then
328
- instance[key] = resolveVector2(value)
329
- return
330
- end
331
- if propType == "NumberRange" then
332
- instance[key] = resolveNumberRange(value)
333
- return
334
- end
335
- if propType == "Rect" then
336
- instance[key] = resolveRect(value)
337
- return
338
- end
339
-
340
- -- Rect properties (4-element arrays like SliceCenter)
341
- if RECT_PROPS[key] then
342
- instance[key] = resolveRect(value)
343
- return
344
- end
345
-
346
- -- NumberRange properties
347
- if NUMBER_RANGE_PROPS[key] then
348
- instance[key] = resolveNumberRange(value)
349
- return
350
- end
351
-
352
- if key == "Font" and instance:IsA("TextLabel") or instance:IsA("TextButton") or instance:IsA("TextBox") then
353
- instance[key] = resolveFont(value)
354
- return
355
- end
356
-
357
- if key == "FontFace" then
358
- if type(value) == "table" then
359
- local family = value.Family or "rbxasset://fonts/families/SourceSansPro.json"
360
- local weight = Enum.FontWeight.Regular
361
- local style = Enum.FontStyle.Normal
362
- pcall(function()
363
- weight = Enum.FontWeight[value.Weight or "Regular"]
364
- end)
365
- pcall(function()
366
- style = Enum.FontStyle[value.Style or "Normal"]
367
- end)
368
- instance.FontFace = Font.new(family, weight, style)
369
- end
370
- return
371
- end
372
-
373
- -- Enum properties (string values)
374
- if type(value) == "string" then
375
- local resolved = resolveEnum(instance, key, value)
376
- local ok, err = pcall(function()
377
- instance[key] = resolved
378
- end)
379
- if not ok then
380
- -- Maybe it's a direct value
381
- pcall(function()
382
- instance[key] = value
383
- end)
384
- end
385
- return
386
- end
387
-
388
- if UDIM2_PROPS[key] then
389
- pcall(function()
390
- instance[key] = resolveUDim2(value)
391
- end)
392
- return
393
- end
394
-
395
- -- Smart disambiguation for 2-element table arrays
396
- if type(value) == "table" and #value == 2 then
397
- local resolved = resolve2ElementArray(instance, key, value)
398
- pcall(function()
399
- instance[key] = resolved
400
- end)
401
- return
402
- end
403
-
404
- -- 4-element table arrays: try Rect if not already handled as UDim2
405
- if type(value) == "table" and #value == 4 then
406
- -- Check actual property type
407
- local ok, curVal = pcall(function()
408
- return instance[key]
409
- end)
410
- if ok and curVal ~= nil then
411
- local t = typeof(curVal)
412
- if t == "Rect" then
413
- pcall(function()
414
- instance[key] = resolveRect(value)
415
- end)
416
- return
417
- elseif t == "UDim2" then
418
- pcall(function()
419
- instance[key] = resolveUDim2(value)
420
- end)
421
- return
422
- end
423
- end
424
- -- Default 4-element to UDim2
425
- pcall(function()
426
- instance[key] = resolveUDim2(value)
427
- end)
428
- return
429
- end
430
-
431
- -- Direct assignment for numbers, booleans, etc.
432
- pcall(function()
433
- instance[key] = value
434
- end)
435
- end
436
-
437
- local RESERVED_KEYS = {
438
- ClassName = true,
439
- className = true,
440
- Name = true,
441
- name = true,
442
- Children = true,
443
- children = true,
444
- properties = true,
445
- Properties = true,
446
- props = true,
447
- Props = true,
448
- }
449
-
450
- local function buildNode(spec, parent, animate)
451
- local className = spec.ClassName or spec.className or "Frame"
452
- local name = spec.Name or spec.name or className
453
-
454
- local instance = Instance.new(className)
455
- instance.Name = name
456
-
457
- -- Apply flattened top-level properties
458
- for key, value in spec do
459
- if not RESERVED_KEYS[key] then
460
- applyProperty(instance, key, value)
461
- end
462
- end
463
-
464
- -- Also accept a nested "properties" / "Properties" / "props" bag for convenience.
465
- -- This lets callers group properties explicitly without the key being misread
466
- -- as an instance property name.
467
- local propBag = spec.properties or spec.Properties or spec.props or spec.Props
468
- if type(propBag) == "table" then
469
- for key, value in propBag do
470
- if not RESERVED_KEYS[key] then
471
- applyProperty(instance, key, value)
472
- end
473
- end
474
- end
475
-
476
- -- Set parent after properties to avoid unnecessary layout computations
477
- instance.Parent = parent
478
-
479
- -- Build children recursively
480
- local children = spec.Children or spec.children
481
- if children then
482
- for _, childSpec in children do
483
- if animate then
484
- task.wait(0.05)
485
- end
486
- buildNode(childSpec, instance, animate)
487
- end
488
- end
489
-
490
- return instance
491
- end
492
-
493
- function UIBuilder.build(spec)
494
- local parentPath = spec.parent or "StarterGui"
495
- local parent = Explorer.resolveInstance(parentPath)
496
- if not parent then
497
- -- Try as a service
498
- local ok
499
- ok, parent = pcall(function()
500
- return game:GetService(parentPath)
501
- end)
502
- if not ok then
503
- return { success = false, error = "Parent not found: " .. parentPath }
504
- end
505
- end
506
-
507
- -- Clean up existing instance with same name
508
- local existingName = spec.tree and (spec.tree.Name or spec.tree.name) or nil
509
- if existingName then
510
- local existing = parent:FindFirstChild(existingName)
511
- if existing then
512
- existing:Destroy()
513
- end
514
- end
515
-
516
- local tree = spec.tree
517
- if not tree then
518
- return { success = false, error = "No 'tree' provided in spec" }
519
- end
520
-
521
- local animate = false
522
- if spec.animate ~= nil then
523
- animate = spec.animate
524
- end
525
-
526
- local ok, result = pcall(function()
527
- return buildNode(tree, parent, animate)
528
- end)
529
-
530
- if not ok then
531
- return { success = false, error = "Build failed: " .. tostring(result) }
532
- end
533
-
534
- local path = Explorer.getPath(result)
535
- local count = 0
536
- local function countDescendants(inst)
537
- count = count + 1
538
- for _, child in inst:GetChildren() do
539
- countDescendants(child)
540
- end
541
- end
542
- countDescendants(result)
543
-
544
- return { success = true, path = path, instanceCount = count }
545
- end
546
-
547
- --[[
548
- Serialize: convert an existing instance tree into create_ui-compatible JSON.
549
- Reads properties via ReflectionService, converts Roblox types to JSON-friendly values,
550
- and skips default/unchanged values to keep output minimal.
551
- ]]
552
-
553
- local Reflection = require(script.Parent.Reflection)
554
- local ReflectionService = game:GetService("ReflectionService")
555
-
556
- -- Convert a Roblox value to a JSON-friendly representation
557
- local function valueToJson(value, propType)
558
- local t = typeof(value)
559
-
560
- if t == "UDim2" then
561
- return { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset }
562
- end
563
- if t == "UDim" then
564
- return { value.Scale, value.Offset }
565
- end
566
- if t == "Vector2" then
567
- return { value.X, value.Y }
568
- end
569
- if t == "Vector3" then
570
- return { value.X, value.Y, value.Z }
571
- end
572
- if t == "Color3" then
573
- return "#" .. value:ToHex()
574
- end
575
- if t == "BrickColor" then
576
- return value.Name
577
- end
578
- if t == "ColorSequence" then
579
- local kps = {}
580
- for _, kp in value.Keypoints do
581
- table.insert(kps, { kp.Time, "#" .. kp.Value:ToHex() })
582
- end
583
- return kps
584
- end
585
- if t == "NumberSequence" then
586
- local kps = {}
587
- for _, kp in value.Keypoints do
588
- table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
589
- end
590
- return kps
591
- end
592
- if t == "NumberRange" then
593
- return { value.Min, value.Max }
594
- end
595
- if t == "Rect" then
596
- return { value.Min.X, value.Min.Y, value.Max.X, value.Max.Y }
597
- end
598
- if t == "CFrame" then
599
- return { value:GetComponents() }
600
- end
601
- if t == "EnumItem" then
602
- return value.Name
603
- end
604
- if t == "Font" then
605
- return {
606
- Family = value.Family,
607
- Weight = value.Weight.Name,
608
- Style = value.Style.Name,
609
- }
610
- end
611
- if t == "boolean" or t == "number" or t == "string" then
612
- return value
613
- end
614
- -- Fallback: tostring
615
- return tostring(value)
616
- end
617
-
618
- local SERIALIZE_SKIP = {
619
- Parent = true,
620
- ClassName = true,
621
- Name = true,
622
- Archivable = true,
623
- AbsolutePosition = true,
624
- AbsoluteSize = true,
625
- AbsoluteRotation = true,
626
- IsLoaded = true,
627
- }
628
-
629
- local function serializeNode(instance, maxDepth, depth)
630
- if depth > maxDepth then
631
- return nil
632
- end
633
-
634
- local node = {
635
- ClassName = instance.ClassName,
636
- Name = instance.Name,
637
- }
638
-
639
- -- Get non-default properties via reflection
640
- local ok, propData = pcall(function()
641
- return ReflectionService:GetPropertiesOfClass(instance.ClassName)
642
- end)
643
-
644
- if ok and propData then
645
- -- Create a default instance to compare against
646
- local defaultInstance = nil
647
- pcall(function()
648
- defaultInstance = Instance.new(instance.ClassName)
649
- end)
650
-
651
- for _, prop in propData do
652
- if not SERIALIZE_SKIP[prop.Name] and not prop.Name:match("^Absolute") then
653
- local valOk, value = pcall(function()
654
- return instance[prop.Name]
655
- end)
656
- if valOk and value ~= nil then
657
- -- Skip default values
658
- local isDefault = false
659
- if defaultInstance then
660
- local defOk, defVal = pcall(function()
661
- return defaultInstance[prop.Name]
662
- end)
663
- if defOk and defVal == value then
664
- isDefault = true
665
- end
666
- end
667
-
668
- if not isDefault then
669
- local jsonVal = valueToJson(value, prop.ValueType and tostring(prop.ValueType))
670
- if jsonVal ~= nil then
671
- node[prop.Name] = jsonVal
672
- end
673
- end
674
- end
675
- end
676
- end
677
-
678
- if defaultInstance then
679
- pcall(function()
680
- defaultInstance:Destroy()
681
- end)
682
- end
683
- end
684
-
685
- -- Serialize children
686
- local children = instance:GetChildren()
687
- if #children > 0 then
688
- node.Children = {}
689
- for _, child in children do
690
- local childNode = serializeNode(child, maxDepth, depth + 1)
691
- if childNode then
692
- table.insert(node.Children, childNode)
693
- end
694
- end
695
- if #node.Children == 0 then
696
- node.Children = nil
697
- end
698
- end
699
-
700
- return node
701
- end
702
-
703
- function UIBuilder.serialize(spec)
704
- local path = spec.path
705
- if not path then
706
- return { success = false, error = "Missing 'path' parameter" }
707
- end
708
-
709
- local instance = Explorer.resolveInstance(path)
710
- if not instance then
711
- return { success = false, error = "Instance not found: " .. path }
712
- end
713
-
714
- local maxDepth = spec.maxDepth or 50
715
-
716
- local ok, tree = pcall(function()
717
- return serializeNode(instance, maxDepth, 0)
718
- end)
719
-
720
- if not ok then
721
- return { success = false, error = "Serialization failed: " .. tostring(tree) }
722
- end
723
-
724
- return { success = true, tree = tree }
725
- end
726
-
727
- return UIBuilder
1
+ --[[
2
+ UIBuilder: create entire UI trees from a declarative JSON spec in one call.
3
+ Handles all type coercion natively in Luau — no round trips needed.
4
+ ]]
5
+
6
+ local Explorer = require(script.Parent.Explorer)
7
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
8
+ local Reflection = require(script.Parent.Reflection)
9
+ local ValueCodec = require(script.Parent.ValueCodec)
10
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
11
+
12
+ local UIBuilder = {}
13
+
14
+ -- Cache property type maps per ClassName to avoid repeated reflection calls
15
+ local propertyTypeCache = {}
16
+ local function getPropertyType(className, propName)
17
+ if not propertyTypeCache[className] then
18
+ propertyTypeCache[className] = Reflection.getPropertyTypes(className)
19
+ end
20
+ return propertyTypeCache[className][propName]
21
+ end
22
+
23
+ local function resolveColor(value)
24
+ if type(value) == "string" then
25
+ if value:sub(1, 1) == "#" then
26
+ return Color3.fromHex(value)
27
+ end
28
+ local r, g, b = value:match("rgb%s*%((%d+)%D+(%d+)%D+(%d+)%)")
29
+ if r then
30
+ return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
31
+ end
32
+ local ok, bc = pcall(BrickColor.new, value)
33
+ if ok then
34
+ return bc.Color
35
+ end
36
+ end
37
+ if type(value) == "table" then
38
+ if value[1] then
39
+ if value[1] > 1 or value[2] > 1 or value[3] > 1 then
40
+ return Color3.fromRGB(value[1], value[2], value[3])
41
+ end
42
+ return Color3.new(value[1], value[2], value[3])
43
+ end
44
+ if value.R or value.r then
45
+ return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
46
+ end
47
+ end
48
+ return Color3.new(1, 1, 1)
49
+ end
50
+
51
+ local function resolveUDim2(value)
52
+ if type(value) == "table" then
53
+ if value.XScale or value.xScale then
54
+ return UDim2.new(
55
+ value.XScale or value.xScale or 0,
56
+ value.XOffset or value.xOffset or 0,
57
+ value.YScale or value.yScale or 0,
58
+ value.YOffset or value.yOffset or 0
59
+ )
60
+ end
61
+ if value.X and type(value.X) == "table" then
62
+ return UDim2.new(
63
+ value.X.Scale or value.X[1] or 0,
64
+ value.X.Offset or value.X[2] or 0,
65
+ value.Y and (value.Y.Scale or value.Y[1]) or 0,
66
+ value.Y and (value.Y.Offset or value.Y[2]) or 0
67
+ )
68
+ end
69
+ if value[1] ~= nil then
70
+ return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
71
+ end
72
+ end
73
+ if type(value) == "string" then
74
+ local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
75
+ if a then
76
+ return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
77
+ end
78
+ end
79
+ return UDim2.new(0, 0, 0, 0)
80
+ end
81
+
82
+ local function resolveUDim(value)
83
+ if type(value) == "table" then
84
+ return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
85
+ end
86
+ if type(value) == "number" then
87
+ return UDim.new(0, value)
88
+ end
89
+ return UDim.new(0, 0)
90
+ end
91
+
92
+ local function resolveVector2(value)
93
+ if type(value) == "table" then
94
+ return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
95
+ end
96
+ return Vector2.new(0, 0)
97
+ end
98
+
99
+ local function resolveEnum(instance, key, value)
100
+ if type(value) ~= "string" then
101
+ return value
102
+ end
103
+ local curOk, curVal = pcall(function()
104
+ return instance[key]
105
+ end)
106
+ if curOk and typeof(curVal) == "EnumItem" then
107
+ local enumType = tostring(curVal.EnumType)
108
+ local ok, enumVal = pcall(function()
109
+ return Enum[enumType][value]
110
+ end)
111
+ if ok then
112
+ return enumVal
113
+ end
114
+ end
115
+ return value
116
+ end
117
+
118
+ local function resolveFont(value)
119
+ if type(value) == "string" then
120
+ local ok, font = pcall(function()
121
+ return Enum.Font[value]
122
+ end)
123
+ if ok then
124
+ return font
125
+ end
126
+ end
127
+ return value
128
+ end
129
+
130
+ local UDIM2_PROPS = {
131
+ Size = true,
132
+ Position = true,
133
+ CellSize = true,
134
+ CellPadding = true,
135
+ }
136
+
137
+ local COLOR3_PROPS = {
138
+ BackgroundColor3 = true,
139
+ BorderColor3 = true,
140
+ TextColor3 = true,
141
+ ImageColor3 = true,
142
+ PlaceholderColor3 = true,
143
+ TextStrokeColor3 = true,
144
+ ScrollBarImageColor3 = true,
145
+ }
146
+
147
+ local function resolveColorSequence(value)
148
+ if type(value) == "table" then
149
+ -- Array of keypoints: {{time, color}, ...}
150
+ if type(value[1]) == "table" then
151
+ local keypoints = {}
152
+ for _, kp in ipairs(value) do
153
+ local t = kp[1] or kp.Time or 0
154
+ local c = resolveColor(kp[2] or kp.Color or kp)
155
+ table.insert(keypoints, ColorSequenceKeypoint.new(t, c))
156
+ end
157
+ return ColorSequence.new(keypoints)
158
+ end
159
+ -- Single color as array [r,g,b] → uniform ColorSequence
160
+ if type(value[1]) == "number" and #value == 3 then
161
+ local c = resolveColor(value)
162
+ return ColorSequence.new(c)
163
+ end
164
+ end
165
+ -- String hex/rgb → uniform ColorSequence
166
+ if type(value) == "string" then
167
+ return ColorSequence.new(resolveColor(value))
168
+ end
169
+ return ColorSequence.new(Color3.new(1, 1, 1))
170
+ end
171
+
172
+ local function resolveNumberSequence(value)
173
+ if type(value) == "table" then
174
+ -- Array of keypoints: {{time, value, envelope?}, ...}
175
+ if type(value[1]) == "table" then
176
+ local keypoints = {}
177
+ for _, kp in ipairs(value) do
178
+ local t = kp[1] or kp.Time or 0
179
+ local v = kp[2] or kp.Value or 0
180
+ local e = kp[3] or kp.Envelope or 0
181
+ table.insert(keypoints, NumberSequenceKeypoint.new(t, v, e))
182
+ end
183
+ return NumberSequence.new(keypoints)
184
+ end
185
+ end
186
+ if type(value) == "number" then
187
+ return NumberSequence.new(value)
188
+ end
189
+ return NumberSequence.new(0)
190
+ end
191
+
192
+ local VECTOR2_PROPS = {
193
+ AnchorPoint = true,
194
+ CanvasSize_v2 = true,
195
+ AbsoluteSize = true,
196
+ AbsolutePosition = true,
197
+ }
198
+
199
+ local UDIM_PROPS = {
200
+ CornerRadius = true,
201
+ Padding = true,
202
+ PaddingTop = true,
203
+ PaddingBottom = true,
204
+ PaddingLeft = true,
205
+ PaddingRight = true,
206
+ FillDirection = false, -- not UDim
207
+ }
208
+
209
+ local NUMBER_RANGE_PROPS = {
210
+ Range = true,
211
+ RotationRange = true,
212
+ Size_NR = true, -- particle size range alias
213
+ }
214
+
215
+ local RECT_PROPS = {
216
+ SliceCenter = true,
217
+ ImageRectOffset_Rect = true,
218
+ }
219
+
220
+ local function resolveNumberRange(value)
221
+ if type(value) == "table" then
222
+ return NumberRange.new(value[1] or 0, value[2] or value[1] or 0)
223
+ end
224
+ if type(value) == "number" then
225
+ return NumberRange.new(value)
226
+ end
227
+ return NumberRange.new(0)
228
+ end
229
+
230
+ local function resolveRect(value)
231
+ if type(value) == "table" then
232
+ if #value == 4 then
233
+ return Rect.new(value[1], value[2], value[3], value[4])
234
+ end
235
+ return Rect.new(
236
+ value.Min and value.Min[1] or value.MinX or 0,
237
+ value.Min and value.Min[2] or value.MinY or 0,
238
+ value.Max and value.Max[1] or value.MaxX or 0,
239
+ value.Max and value.Max[2] or value.MaxY or 0
240
+ )
241
+ end
242
+ return Rect.new(0, 0, 0, 0)
243
+ end
244
+
245
+ -- Disambiguate 2-element arrays: check actual property type on the instance
246
+ local function resolve2ElementArray(instance, key, value)
247
+ -- First, check our known lookup tables
248
+ if VECTOR2_PROPS[key] then
249
+ return resolveVector2(value)
250
+ end
251
+ if UDIM_PROPS[key] then
252
+ return resolveUDim(value)
253
+ end
254
+ if NUMBER_RANGE_PROPS[key] then
255
+ return resolveNumberRange(value)
256
+ end
257
+ -- Fall back to runtime type introspection
258
+ local ok, curVal = pcall(function()
259
+ return instance[key]
260
+ end)
261
+ if ok and curVal ~= nil then
262
+ local t = typeof(curVal)
263
+ if t == "Vector2" then
264
+ return resolveVector2(value)
265
+ end
266
+ if t == "UDim" then
267
+ return resolveUDim(value)
268
+ end
269
+ if t == "NumberRange" then
270
+ return resolveNumberRange(value)
271
+ end
272
+ end
273
+ -- Default: assume UDim for 2-element (more common in UI)
274
+ return resolveUDim(value)
275
+ end
276
+
277
+ local function applyProperty(instance, key, value)
278
+ -- Special handling by property name
279
+ if key == "Size" or key == "Position" then
280
+ if instance:IsA("GuiObject") or instance:IsA("UIBase") then
281
+ instance[key] = resolveUDim2(value)
282
+ return
283
+ end
284
+ end
285
+
286
+ if key == "AnchorPoint" then
287
+ if instance:IsA("GuiObject") then
288
+ instance[key] = resolveVector2(value)
289
+ return
290
+ end
291
+ end
292
+
293
+ if key == "CanvasSize" and instance:IsA("ScrollingFrame") then
294
+ instance[key] = resolveUDim2(value)
295
+ return
296
+ end
297
+
298
+ if UDIM_PROPS[key] then
299
+ instance[key] = resolveUDim(value)
300
+ return
301
+ end
302
+
303
+ -- Use reflection to distinguish Color3 vs ColorSequence, NumberSequence, etc.
304
+ local propType = getPropertyType(instance.ClassName, key)
305
+
306
+ if propType == "ColorSequence" then
307
+ instance[key] = resolveColorSequence(value)
308
+ return
309
+ end
310
+
311
+ if propType == "NumberSequence" then
312
+ instance[key] = resolveNumberSequence(value)
313
+ return
314
+ end
315
+
316
+ if COLOR3_PROPS[key] or propType == "Color3" then
317
+ instance[key] = resolveColor(value)
318
+ return
319
+ end
320
+
321
+ -- Reflection-based coercion for types not in hardcoded tables
322
+ if propType == "UDim2" and type(value) == "table" then
323
+ instance[key] = resolveUDim2(value)
324
+ return
325
+ end
326
+ if propType == "UDim" then
327
+ instance[key] = resolveUDim(value)
328
+ return
329
+ end
330
+ if propType == "Vector2" then
331
+ instance[key] = resolveVector2(value)
332
+ return
333
+ end
334
+ if propType == "NumberRange" then
335
+ instance[key] = resolveNumberRange(value)
336
+ return
337
+ end
338
+ if propType == "Rect" then
339
+ instance[key] = resolveRect(value)
340
+ return
341
+ end
342
+
343
+ -- Rect properties (4-element arrays like SliceCenter)
344
+ if RECT_PROPS[key] then
345
+ instance[key] = resolveRect(value)
346
+ return
347
+ end
348
+
349
+ -- NumberRange properties
350
+ if NUMBER_RANGE_PROPS[key] then
351
+ instance[key] = resolveNumberRange(value)
352
+ return
353
+ end
354
+
355
+ if key == "Font" and (instance:IsA("TextLabel") or instance:IsA("TextButton") or instance:IsA("TextBox")) then
356
+ instance[key] = resolveFont(value)
357
+ return
358
+ end
359
+
360
+ if key == "FontFace" then
361
+ if type(value) == "table" then
362
+ local family = value.Family or "rbxasset://fonts/families/SourceSansPro.json"
363
+ local weight = Enum.FontWeight.Regular
364
+ local style = Enum.FontStyle.Normal
365
+ pcall(function()
366
+ weight = Enum.FontWeight[value.Weight or "Regular"]
367
+ end)
368
+ pcall(function()
369
+ style = Enum.FontStyle[value.Style or "Normal"]
370
+ end)
371
+ instance.FontFace = Font.new(family, weight, style)
372
+ end
373
+ return
374
+ end
375
+
376
+ -- Enum properties (string values)
377
+ if type(value) == "string" then
378
+ local resolved = resolveEnum(instance, key, value)
379
+ local ok, err = pcall(function()
380
+ instance[key] = resolved
381
+ end)
382
+ if not ok then
383
+ -- Maybe it's a direct value
384
+ pcall(function()
385
+ instance[key] = value
386
+ end)
387
+ end
388
+ return
389
+ end
390
+
391
+ if UDIM2_PROPS[key] then
392
+ pcall(function()
393
+ instance[key] = resolveUDim2(value)
394
+ end)
395
+ return
396
+ end
397
+
398
+ -- Smart disambiguation for 2-element table arrays
399
+ if type(value) == "table" and #value == 2 then
400
+ local resolved = resolve2ElementArray(instance, key, value)
401
+ pcall(function()
402
+ instance[key] = resolved
403
+ end)
404
+ return
405
+ end
406
+
407
+ -- 4-element table arrays: try Rect if not already handled as UDim2
408
+ if type(value) == "table" and #value == 4 then
409
+ -- Check actual property type
410
+ local ok, curVal = pcall(function()
411
+ return instance[key]
412
+ end)
413
+ if ok and curVal ~= nil then
414
+ local t = typeof(curVal)
415
+ if t == "Rect" then
416
+ pcall(function()
417
+ instance[key] = resolveRect(value)
418
+ end)
419
+ return
420
+ elseif t == "UDim2" then
421
+ pcall(function()
422
+ instance[key] = resolveUDim2(value)
423
+ end)
424
+ return
425
+ end
426
+ end
427
+ -- Default 4-element to UDim2
428
+ pcall(function()
429
+ instance[key] = resolveUDim2(value)
430
+ end)
431
+ return
432
+ end
433
+
434
+ -- Direct assignment for numbers, booleans, etc.
435
+ pcall(function()
436
+ instance[key] = value
437
+ end)
438
+ end
439
+
440
+ local RESERVED_KEYS = {
441
+ ClassName = true,
442
+ className = true,
443
+ Name = true,
444
+ name = true,
445
+ Children = true,
446
+ children = true,
447
+ properties = true,
448
+ Properties = true,
449
+ props = true,
450
+ Props = true,
451
+ }
452
+
453
+ local function buildNode(spec, parent, animate)
454
+ local className = spec.ClassName or spec.className or "Frame"
455
+ local name = spec.Name or spec.name or className
456
+
457
+ local instance = Instance.new(className)
458
+ instance.Name = name
459
+
460
+ -- Apply flattened top-level properties
461
+ for key, value in spec do
462
+ if not RESERVED_KEYS[key] then
463
+ applyProperty(instance, key, value)
464
+ end
465
+ end
466
+
467
+ -- Also accept a nested "properties" / "Properties" / "props" bag for convenience.
468
+ -- This lets callers group properties explicitly without the key being misread
469
+ -- as an instance property name.
470
+ local propBag = spec.properties or spec.Properties or spec.props or spec.Props
471
+ if type(propBag) == "table" then
472
+ for key, value in propBag do
473
+ if not RESERVED_KEYS[key] then
474
+ applyProperty(instance, key, value)
475
+ end
476
+ end
477
+ end
478
+
479
+ -- Set parent after properties to avoid unnecessary layout computations
480
+ instance.Parent = parent
481
+
482
+ -- Build children recursively
483
+ local children = spec.Children or spec.children
484
+ if children then
485
+ for _, childSpec in children do
486
+ if animate then
487
+ task.wait(0.05)
488
+ end
489
+ buildNode(childSpec, instance, animate)
490
+ end
491
+ end
492
+
493
+ return instance
494
+ end
495
+
496
+ function UIBuilder.build(spec)
497
+ local parentPath = spec.parent or "StarterGui"
498
+ local parent = Explorer.resolveInstance(parentPath)
499
+ if not parent then
500
+ -- Try as a service
501
+ local ok
502
+ ok, parent = pcall(function()
503
+ return game:GetService(parentPath)
504
+ end)
505
+ if not ok then
506
+ return { success = false, error = "Parent not found: " .. parentPath }
507
+ end
508
+ end
509
+
510
+ -- Clean up existing instance with same name
511
+ local existingName = spec.tree and (spec.tree.Name or spec.tree.name) or nil
512
+ if existingName then
513
+ local existing = parent:FindFirstChild(existingName)
514
+ if existing then
515
+ existing:Destroy()
516
+ end
517
+ end
518
+
519
+ local tree = spec.tree
520
+ if not tree then
521
+ return { success = false, error = "No 'tree' provided in spec" }
522
+ end
523
+
524
+ local animate = false
525
+ if spec.animate ~= nil then
526
+ animate = spec.animate
527
+ end
528
+
529
+ local ok, result = pcall(function()
530
+ return buildNode(tree, parent, animate)
531
+ end)
532
+
533
+ if not ok then
534
+ return { success = false, error = "Build failed: " .. tostring(result) }
535
+ end
536
+
537
+ local path = Explorer.getPath(result)
538
+ local count = 0
539
+ local function countDescendants(inst)
540
+ count = count + 1
541
+ for _, child in inst:GetChildren() do
542
+ countDescendants(child)
543
+ end
544
+ end
545
+ countDescendants(result)
546
+
547
+ return { success = true, path = path, instanceCount = count }
548
+ end
549
+
550
+ --[[
551
+ Serialize: convert an existing instance tree into create_ui-compatible JSON.
552
+ Reads properties via ReflectionService, converts Roblox types to JSON-friendly values,
553
+ and skips default/unchanged values to keep output minimal.
554
+ ]]
555
+
556
+ local Reflection = require(script.Parent.Reflection)
557
+ local ReflectionService = game:GetService("ReflectionService")
558
+
559
+ -- Convert a Roblox value to a JSON-friendly representation
560
+ local function valueToJson(value, propType)
561
+ local t = typeof(value)
562
+
563
+ if t == "UDim2" then
564
+ return { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset }
565
+ end
566
+ if t == "UDim" then
567
+ return { value.Scale, value.Offset }
568
+ end
569
+ if t == "Vector2" then
570
+ return { value.X, value.Y }
571
+ end
572
+ if t == "Vector3" then
573
+ return { value.X, value.Y, value.Z }
574
+ end
575
+ if t == "Color3" then
576
+ return "#" .. value:ToHex()
577
+ end
578
+ if t == "BrickColor" then
579
+ return value.Name
580
+ end
581
+ if t == "ColorSequence" then
582
+ local kps = {}
583
+ for _, kp in value.Keypoints do
584
+ table.insert(kps, { kp.Time, "#" .. kp.Value:ToHex() })
585
+ end
586
+ return kps
587
+ end
588
+ if t == "NumberSequence" then
589
+ local kps = {}
590
+ for _, kp in value.Keypoints do
591
+ table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
592
+ end
593
+ return kps
594
+ end
595
+ if t == "NumberRange" then
596
+ return { value.Min, value.Max }
597
+ end
598
+ if t == "Rect" then
599
+ return { value.Min.X, value.Min.Y, value.Max.X, value.Max.Y }
600
+ end
601
+ if t == "CFrame" then
602
+ return { value:GetComponents() }
603
+ end
604
+ if t == "EnumItem" then
605
+ return value.Name
606
+ end
607
+ if t == "Font" then
608
+ return {
609
+ Family = value.Family,
610
+ Weight = value.Weight.Name,
611
+ Style = value.Style.Name,
612
+ }
613
+ end
614
+ if t == "boolean" or t == "number" or t == "string" then
615
+ return value
616
+ end
617
+ -- Fallback: tostring
618
+ return tostring(value)
619
+ end
620
+
621
+ local SERIALIZE_SKIP = {
622
+ Parent = true,
623
+ ClassName = true,
624
+ Name = true,
625
+ Archivable = true,
626
+ AbsolutePosition = true,
627
+ AbsoluteSize = true,
628
+ AbsoluteRotation = true,
629
+ IsLoaded = true,
630
+ }
631
+
632
+ local function serializeNode(instance, maxDepth, depth)
633
+ if depth > maxDepth then
634
+ return nil
635
+ end
636
+
637
+ local node = {
638
+ ClassName = instance.ClassName,
639
+ Name = instance.Name,
640
+ }
641
+
642
+ -- Get non-default properties via reflection
643
+ local ok, propData = pcall(function()
644
+ return ReflectionService:GetPropertiesOfClass(instance.ClassName)
645
+ end)
646
+
647
+ if ok and propData then
648
+ -- Create a default instance to compare against
649
+ local defaultInstance = nil
650
+ pcall(function()
651
+ defaultInstance = Instance.new(instance.ClassName)
652
+ end)
653
+
654
+ for _, prop in propData do
655
+ if not SERIALIZE_SKIP[prop.Name] and not prop.Name:match("^Absolute") then
656
+ local valOk, value = pcall(function()
657
+ return instance[prop.Name]
658
+ end)
659
+ if valOk and value ~= nil then
660
+ -- Skip default values
661
+ local isDefault = false
662
+ if defaultInstance then
663
+ local defOk, defVal = pcall(function()
664
+ return defaultInstance[prop.Name]
665
+ end)
666
+ if defOk and defVal == value then
667
+ isDefault = true
668
+ end
669
+ end
670
+
671
+ if not isDefault then
672
+ local jsonVal = valueToJson(value, prop.Type and tostring(prop.Type))
673
+ if jsonVal ~= nil then
674
+ node[prop.Name] = jsonVal
675
+ end
676
+ end
677
+ end
678
+ end
679
+ end
680
+
681
+ if defaultInstance then
682
+ pcall(function()
683
+ defaultInstance:Destroy()
684
+ end)
685
+ end
686
+ end
687
+
688
+ -- Serialize children
689
+ local children = instance:GetChildren()
690
+ if #children > 0 then
691
+ node.Children = {}
692
+ for _, child in children do
693
+ local childNode = serializeNode(child, maxDepth, depth + 1)
694
+ if childNode then
695
+ table.insert(node.Children, childNode)
696
+ end
697
+ end
698
+ if #node.Children == 0 then
699
+ node.Children = nil
700
+ end
701
+ end
702
+
703
+ return node
704
+ end
705
+
706
+ function UIBuilder.serialize(spec)
707
+ local path = spec.path
708
+ if not path then
709
+ return { success = false, error = "Missing 'path' parameter" }
710
+ end
711
+
712
+ local instance = Explorer.resolveInstance(path)
713
+ if not instance then
714
+ return { success = false, error = "Instance not found: " .. path }
715
+ end
716
+
717
+ local maxDepth = spec.maxDepth or 50
718
+
719
+ local ok, tree = pcall(function()
720
+ return serializeNode(instance, maxDepth, 0)
721
+ end)
722
+
723
+ if not ok then
724
+ return { success = false, error = "Serialization failed: " .. tostring(tree) }
725
+ end
726
+
727
+ return { success = true, tree = tree }
728
+ end
729
+
730
+ local function buildStrictNode(spec, state, depth)
731
+ assert(type(spec) == "table", "Every UI node must be an object")
732
+ if depth > state.maxDepth then
733
+ error("UI tree exceeds maximum depth of " .. state.maxDepth)
734
+ end
735
+ state.count += 1
736
+ if state.count > state.maxNodes then
737
+ error("UI tree exceeds maximum node count of " .. state.maxNodes)
738
+ end
739
+
740
+ local className = spec.ClassName or spec.className
741
+ assert(type(className) == "string", "Every UI node requires ClassName")
742
+ local instance = Instance.new(className)
743
+ instance.Name = spec.Name or spec.name or className
744
+
745
+ for key, value in spec do
746
+ if not RESERVED_KEYS[key] then
747
+ ValueCodec.setProperty(instance, key, value)
748
+ end
749
+ end
750
+ local properties = spec.properties or spec.Properties or spec.props or spec.Props
751
+ if properties ~= nil then
752
+ assert(type(properties) == "table", "UI node properties must be an object")
753
+ for key, value in properties do
754
+ ValueCodec.setProperty(instance, key, value)
755
+ end
756
+ end
757
+
758
+ local children = spec.Children or spec.children
759
+ if children ~= nil then
760
+ assert(type(children) == "table", "UI node Children must be an array")
761
+ for _, childSpec in children do
762
+ local child = buildStrictNode(childSpec, state, depth + 1)
763
+ child.Parent = instance
764
+ end
765
+ end
766
+ return instance
767
+ end
768
+
769
+ function UIBuilder.buildV2(spec)
770
+ if type(spec.tree) ~= "table" then
771
+ return { success = false, error = "tree is required" }
772
+ end
773
+ local parentRef = spec.parent or { pathSegments = { "StarterGui" } }
774
+ local parent, parentErr = InstanceRegistry.resolve(parentRef)
775
+ if not parent then
776
+ return { success = false, error = "Parent: " .. tostring(parentErr) }
777
+ end
778
+
779
+ local state = {
780
+ count = 0,
781
+ maxDepth = math.clamp(spec.maxDepth or 30, 1, 50),
782
+ maxNodes = math.clamp(spec.maxNodes or 1000, 1, 3000),
783
+ }
784
+ local builtOk, rootOrError = pcall(function()
785
+ return buildStrictNode(spec.tree, state, 0)
786
+ end)
787
+ if not builtOk then
788
+ return { success = false, error = "UI validation failed: " .. tostring(rootOrError) }
789
+ end
790
+ local root = rootOrError
791
+
792
+ local existing = nil
793
+ local duplicateCount = 0
794
+ for _, child in parent:GetChildren() do
795
+ if child.Name == root.Name then
796
+ existing = child
797
+ duplicateCount += 1
798
+ end
799
+ end
800
+ if duplicateCount > 1 then
801
+ root:Destroy()
802
+ return {
803
+ success = false,
804
+ error = "Replacement target is ambiguous because multiple children share the name " .. root.Name,
805
+ }
806
+ end
807
+ if existing and spec.replaceExisting ~= true then
808
+ root:Destroy()
809
+ return {
810
+ success = false,
811
+ error = "A child named " .. root.Name .. " already exists; set replaceExisting=true to replace it",
812
+ }
813
+ end
814
+
815
+ local recording = ChangeHistoryService:TryBeginRecording("Dominus 2: Build UI " .. root.Name)
816
+ if not recording then
817
+ root:Destroy()
818
+ return { success = false, error = "Another plugin recording is already active" }
819
+ end
820
+ local commitOk, commitErr = pcall(function()
821
+ if existing then
822
+ existing:Destroy()
823
+ end
824
+ root.Parent = parent
825
+ end)
826
+ if not commitOk then
827
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
828
+ if root.Parent == nil then
829
+ root:Destroy()
830
+ end
831
+ return { success = false, error = "UI commit failed: " .. tostring(commitErr), rolledBack = true }
832
+ end
833
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
834
+ return {
835
+ success = true,
836
+ root = InstanceRegistry.toRef(root),
837
+ instanceCount = state.count,
838
+ replaced = existing ~= nil,
839
+ }
840
+ end
841
+
842
+ function UIBuilder.snapshotV2(spec)
843
+ local instance, err = InstanceRegistry.resolve(spec.target)
844
+ if not instance then
845
+ return { success = false, error = err }
846
+ end
847
+ local maxDepth = math.clamp(spec.maxDepth or 30, 0, 50)
848
+ local ok, tree = pcall(function()
849
+ return serializeNode(instance, maxDepth, 0)
850
+ end)
851
+ if not ok then
852
+ return { success = false, error = "UI serialization failed: " .. tostring(tree) }
853
+ end
854
+ return { success = true, target = InstanceRegistry.toRef(instance), tree = tree }
855
+ end
856
+
857
+ return UIBuilder