@rbxts/app-forge 0.7.0 → 0.7.2-alpha.1

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.
package/README.md CHANGED
@@ -32,14 +32,34 @@ If you’ve ever ended up with tangled UI state, duplicated visibility logic, or
32
32
 
33
33
  ## 📦 Installation
34
34
 
35
+ ### Using **bun**
36
+
35
37
  ```bash
36
38
  bun add @rbxts/app-forge
37
39
  ```
38
40
 
39
- Peer dependencies:
41
+ Peer dependencies (choose one renderer):
40
42
 
41
43
  ```bash
42
- bun add @rbxts/vide @rbxts/loners-pretty-vide-utils
44
+ bun add @rbxts/vide
45
+ # or
46
+ bun add @rbxts/react
47
+ ```
48
+
49
+ ---
50
+
51
+ ### Using **npm**
52
+
53
+ ```bash
54
+ npm install @rbxts/app-forge
55
+ ```
56
+
57
+ Peer dependencies (choose one renderer):
58
+
59
+ ```bash
60
+ npm install @rbxts/vide
61
+ # or
62
+ npm install @rbxts/react
43
63
  ```
44
64
 
45
65
  ---
@@ -127,9 +147,7 @@ forge.mount(
127
147
  ResetOnSpawn={false}
128
148
  />
129
149
  ),
130
- {
131
- props,
132
- },
150
+ props,
133
151
  Players.LocalPlayer.WaitForChild("PlayerGui"),
134
152
  );
135
153
  ```
@@ -157,9 +175,7 @@ forge.mount(
157
175
  ResetOnSpawn={false}
158
176
  />
159
177
  ),
160
- {
161
- props,
162
- },
178
+ props,
163
179
  Players.LocalPlayer.WaitForChild("PlayerGui"),
164
180
  );
165
181
  ```
@@ -177,9 +193,7 @@ This:
177
193
  ```ts
178
194
  forge.mount(
179
195
  () => <screengui ResetOnSpawn={false} />,
180
- {
181
- props: {},
182
- },
196
+ props,
183
197
  playerGui,
184
198
  );
185
199
  ```
@@ -352,7 +366,6 @@ AppForge provides `forge.story` for **isolated rendering**, commonly used with *
352
366
  const forge = new CreateVideForge();
353
367
 
354
368
  return forge.story({
355
- forge,
356
369
  props,
357
370
  config: {
358
371
  px: {
@@ -430,7 +443,7 @@ AppForge
430
443
 
431
444
  ## ⚛️ React Support (Planned)
432
445
 
433
- AppForge is designed as a **renderer-agnostic App Manager**.
446
+ AppForge is designed as a **renderer-agnostic App Manager**.b
434
447
 
435
448
  Currently:
436
449
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Scaled pixel unit helper.
3
+ */
4
+ export declare const px: ((value: number) => number) & {
5
+ scale: (value: number) => number;
6
+ even: (value: number) => number;
7
+ floor: (value: number) => number;
8
+ ceil: (value: number) => number;
9
+ };
10
+ /**
11
+ * Initializes global px scaling.
12
+ *
13
+ * Should be called exactly once at app mount.
14
+ */
15
+ export declare function usePx(target?: GuiObject | Camera, baseResolution?: Vector2, minScale?: number): void;
@@ -0,0 +1,117 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ -- Services
4
+ local Workspace = TS.import(script, TS.getModule(script, "@rbxts", "services")).Workspace
5
+ -- React
6
+ local useEffect = TS.import(script, TS.getModule(script, "@rbxts", "react")).useEffect
7
+ --* Default reference resolution for px calculations
8
+ local BASE_RESOLUTION = Vector2.new(1920, 1080)
9
+ --* Minimum allowed scale to prevent unreadable UI
10
+ local MIN_SCALE = 0.5
11
+ --[[
12
+ *
13
+ * Interpolates between width- and height-based scaling.
14
+ * 0 = width-driven, 1 = height-driven
15
+
16
+ ]]
17
+ local DOMINANT_AXIS = 0.5
18
+ local TARGET = Workspace.CurrentCamera
19
+ local SCALE = 1
20
+ local GLOBAL_INITIALIZED = false
21
+ --[[
22
+ *
23
+ * Assigns a call signature to an object.
24
+
25
+ ]]
26
+ local function callable(callback, object)
27
+ return setmetatable(object, {
28
+ __call = function(_, ...)
29
+ local args = { ... }
30
+ return callback(unpack(args))
31
+ end,
32
+ })
33
+ end
34
+ --[[
35
+ *
36
+ * Scaled pixel unit helper.
37
+
38
+ ]]
39
+ local px = callable(function(value)
40
+ return math.round(value * SCALE)
41
+ end, {
42
+ scale = function(value)
43
+ return value * SCALE
44
+ end,
45
+ even = function(value)
46
+ return math.round(value * SCALE * 0.5) * 2
47
+ end,
48
+ floor = function(value)
49
+ return math.floor(value * SCALE)
50
+ end,
51
+ ceil = function(value)
52
+ return math.ceil(value * SCALE)
53
+ end,
54
+ })
55
+ --[[
56
+ *
57
+ * Recalculates the current scale factor.
58
+
59
+ ]]
60
+ local function calculateScale()
61
+ local target = TARGET
62
+ if not target then
63
+ return nil
64
+ end
65
+ local size = if target:IsA("Camera") then target.ViewportSize elseif target:IsA("GuiObject") then target.AbsoluteSize else nil
66
+ if not size then
67
+ return nil
68
+ end
69
+ if BASE_RESOLUTION.X <= 0 or BASE_RESOLUTION.Y <= 0 then
70
+ return nil
71
+ end
72
+ local width = math.log(size.X / BASE_RESOLUTION.X, 2)
73
+ local height = math.log(size.Y / BASE_RESOLUTION.Y, 2)
74
+ local centered = width + (height - width) * DOMINANT_AXIS
75
+ local scale = 2 ^ centered
76
+ SCALE = math.max(scale, MIN_SCALE)
77
+ end
78
+ --[[
79
+ *
80
+ * Initializes global px scaling.
81
+ *
82
+ * Should be called exactly once at app mount.
83
+
84
+ ]]
85
+ local function usePx(target, baseResolution, minScale)
86
+ useEffect(function()
87
+ if GLOBAL_INITIALIZED then
88
+ warn("usePx() may only be called once globally")
89
+ return nil
90
+ end
91
+ GLOBAL_INITIALIZED = true
92
+ if baseResolution then
93
+ BASE_RESOLUTION = baseResolution
94
+ end
95
+ if minScale ~= nil then
96
+ MIN_SCALE = minScale
97
+ end
98
+ if target then
99
+ TARGET = target
100
+ end
101
+ local resolvedTarget = TARGET
102
+ if not resolvedTarget then
103
+ warn("usePx(): no valid target to observe")
104
+ return nil
105
+ end
106
+ local signal = if resolvedTarget:IsA("Camera") then resolvedTarget:GetPropertyChangedSignal("ViewportSize") else resolvedTarget:GetPropertyChangedSignal("AbsoluteSize")
107
+ local connection = signal:Connect(calculateScale)
108
+ calculateScale()
109
+ return function()
110
+ connection:Disconnect()
111
+ end
112
+ end, {})
113
+ end
114
+ return {
115
+ usePx = usePx,
116
+ px = px,
117
+ }
@@ -8,6 +8,10 @@ export default class Renders extends Rules {
8
8
  render: Vide.Node;
9
9
  }>;
10
10
  constructor();
11
+ /**
12
+ * Entry point for mounting renders.
13
+ * Decides render strategy based on props.
14
+ */
11
15
  protected renderMount(this: AppForge, props: Types.Props.Main): Vide.Node;
12
16
  private renderNames;
13
17
  private collectByGroup;
@@ -1,14 +1,14 @@
1
1
  -- Compiled with roblox-ts v3.0.0
2
2
  local TS = _G[script]
3
- -- Services
4
3
  -- Packages
5
- local usePx = TS.import(script, TS.getModule(script, "@rbxts", "loners-pretty-vide-utils").out).usePx
6
4
  local _vide = TS.import(script, TS.getModule(script, "@rbxts", "vide").src)
7
5
  local apply = _vide.apply
8
6
  local create = _vide.create
9
7
  -- Types
10
8
  -- Components
11
9
  local AppRegistry = TS.import(script, script.Parent.Parent, "decorator").AppRegistry
10
+ -- Hooks
11
+ local usePx = TS.import(script, script.Parent.Parent, "hooks", "usePx").usePx
12
12
  -- Classes
13
13
  local Rules = TS.import(script, script.Parent, "rules").default
14
14
  local Renders
@@ -35,22 +35,28 @@ do
35
35
  local render = _binding.render
36
36
  local forge = _binding.forge
37
37
  if not forge.__px then
38
+ local _debug = forge.debug
38
39
  local _result = config
39
40
  if _result ~= nil then
40
- _result = _result.px.target
41
+ _result = _result.px
41
42
  end
43
+ _debug:logTag("px", "global", "Initializing px scaling", _result)
42
44
  local _result_1 = config
43
45
  if _result_1 ~= nil then
44
- _result_1 = _result_1.px.resolution
46
+ _result_1 = _result_1.px.target
45
47
  end
46
48
  local _result_2 = config
47
49
  if _result_2 ~= nil then
48
- _result_2 = _result_2.px.minScale
50
+ _result_2 = _result_2.px.resolution
49
51
  end
50
- usePx(_result, _result_1, _result_2)
52
+ local _result_3 = config
53
+ if _result_3 ~= nil then
54
+ _result_3 = _result_3.px.minScale
55
+ end
56
+ usePx(_result_1, _result_2, _result_3)
51
57
  forge.__px = true
52
58
  else
53
- warn("Rendering twice making a second px")
59
+ forge.debug:logTag("px", "global", "Skipped duplicate px initialization")
54
60
  end
55
61
  if render then
56
62
  local _condition = render.name
@@ -58,34 +64,35 @@ do
58
64
  _condition = render.group
59
65
  end
60
66
  if _condition ~= "" and _condition then
67
+ forge.debug:logTag("render", "global", "Rendering group by name", render)
61
68
  return forge:renderGroupByName(props)
62
- else
63
- local _value = render.names and render.group
64
- if _value ~= "" and _value then
65
- return forge:renderGroupByNames(props)
66
- else
67
- local _result = render
68
- if _result ~= nil then
69
- _result = _result.name
70
- end
71
- if _result ~= "" and _result then
72
- return forge:renderApp(props)
73
- elseif render.names then
74
- return forge:renderApps(props)
75
- else
76
- local _value_1 = render.group
77
- if _value_1 ~= "" and _value_1 then
78
- return forge:renderGroup(props)
79
- end
80
- end
81
- end
69
+ end
70
+ local _value = render.names and render.group
71
+ if _value ~= "" and _value then
72
+ forge.debug:logTag("render", "global", "Rendering group by names", render)
73
+ return forge:renderGroupByNames(props)
74
+ end
75
+ local _value_1 = render.name
76
+ if _value_1 ~= "" and _value_1 then
77
+ forge.debug:logTag("render", render.name, "Rendering single app")
78
+ return forge:renderApp(props)
79
+ end
80
+ if render.names then
81
+ forge.debug:logTag("render", "global", "Rendering multiple apps", render.names)
82
+ return forge:renderApps(props)
83
+ end
84
+ local _value_2 = render.group
85
+ if _value_2 ~= "" and _value_2 then
86
+ forge.debug:logTag("render", "global", "Rendering group", render.group)
87
+ return forge:renderGroup(props)
82
88
  end
83
89
  end
90
+ forge.debug:logTag("render", "global", "Rendering all apps")
84
91
  return self:renderAll(props)
85
92
  end
86
93
  function Renders:renderNames(props, names, forge)
87
94
  if #names == 0 then
88
- error("No app names provided to renderApps")
95
+ error("No app names provided to render", 2)
89
96
  end
90
97
  -- ▼ ReadonlyArray.map ▼
91
98
  local _newValue = table.create(#names)
@@ -141,22 +148,21 @@ do
141
148
  return _result
142
149
  end
143
150
  function Renders:renderApp(props)
144
- local _binding = props
145
- local forge = _binding.forge
146
- local render = _binding.render
147
- local _name = render
151
+ local _name = props.render
148
152
  if _name ~= nil then
149
153
  _name = _name.name
150
154
  end
151
155
  local name = _name
152
156
  if not (name ~= "" and name) then
153
- error("App name is required to create instance")
157
+ error("renderApp requires an app name", 2)
154
158
  end
155
159
  local appClass = AppRegistry[name]
156
160
  if not appClass then
157
- error(`App "{name}" not registered`)
161
+ error(`App "{name}" not registered`, 2)
158
162
  end
159
- if not (forge.loaded[name] ~= nil) then
163
+ self.debug:time("render", name)
164
+ if not (self.loaded[name] ~= nil) then
165
+ self.debug:logTag("render", name, "Creating render instance")
160
166
  local render = appClass.constructor.new(props, name):render()
161
167
  apply(render)({
162
168
  Name = "Render",
@@ -169,19 +175,18 @@ do
169
175
  Size = UDim2.fromScale(1, 1),
170
176
  [0] = render,
171
177
  })
172
- local _loaded = forge.loaded
178
+ local _loaded = self.loaded
173
179
  local _arg1 = {
174
180
  container = container,
175
181
  render = render,
176
182
  }
177
183
  _loaded[name] = _arg1
178
- end
179
- local element = forge.loaded[name]
180
- if not element then
181
- error(`Failed to create instance for app "{name}"`)
184
+ else
185
+ self.debug:logTag("render", name, "Reusing existing render instance")
182
186
  end
183
187
  self:renderRules(name, props)
184
- return element.container
188
+ self.debug:timeEnd("render", name)
189
+ return self.loaded[name].container
185
190
  end
186
191
  function Renders:renderApps(props)
187
192
  local _names = props.render
@@ -190,7 +195,7 @@ do
190
195
  end
191
196
  local names = _names
192
197
  if not names then
193
- error("No app names provided")
198
+ error("renderApps requires app names", 2)
194
199
  end
195
200
  return self:renderNames(props, names, self)
196
201
  end
@@ -201,7 +206,7 @@ do
201
206
  end
202
207
  local group = _group
203
208
  if not (group ~= "" and group) then
204
- error("No group provided")
209
+ error("renderGroup requires a group", 2)
205
210
  end
206
211
  local groups = self:normalizeGroups(group)
207
212
  return self:renderNames(props, self:collectByGroup(groups), self)
@@ -211,7 +216,7 @@ do
211
216
  local group = _binding.group
212
217
  local name = _binding.name
213
218
  if not (group ~= "" and group) or not (name ~= "" and name) then
214
- error("Invalid renderGroupByName")
219
+ error("Invalid renderGroupByName call", 2)
215
220
  end
216
221
  local groups = self:normalizeGroups(group)
217
222
  return self:renderNames(props, self:collectByGroup(groups, function(n)
@@ -223,7 +228,7 @@ do
223
228
  local group = _binding.group
224
229
  local names = _binding.names
225
230
  if not (group ~= "" and group) or not names then
226
- error("Invalid renderGroupByNames")
231
+ error("Invalid renderGroupByNames call", 2)
227
232
  end
228
233
  local groups = self:normalizeGroups(group)
229
234
  return self:renderNames(props, self:collectByGroup(groups, function(n)
@@ -6,36 +6,43 @@ local AppRegistry = TS.import(script, script.Parent.Parent.Parent, "decorator").
6
6
  local function ExclusiveGroupRule(entry, forge)
7
7
  local _entry = entry
8
8
  local entryApp = AppRegistry[_entry]
9
- local _result = entryApp
10
- if _result ~= nil then
11
- _result = _result.rules
12
- if _result ~= nil then
13
- _result = _result.exclusiveGroup
9
+ local _group = entryApp
10
+ if _group ~= nil then
11
+ _group = _group.rules
12
+ if _group ~= nil then
13
+ _group = _group.exclusiveGroup
14
14
  end
15
15
  end
16
- if not (_result ~= "" and _result) then
16
+ local group = _group
17
+ if not (group ~= "" and group) then
17
18
  return nil
18
19
  end
19
- local group = entryApp.rules.exclusiveGroup
20
- local entrySource = forge:getSource(entry)()
21
- if not entrySource then
20
+ local entryVisible = forge:getSource(entry)()
21
+ if not entryVisible then
22
22
  return nil
23
23
  end
24
+ forge.debug:logTag("rules", entry, "Exclusive group activated", group)
24
25
  -- ▼ ReadonlyMap.forEach ▼
25
26
  local _callback = function(app, name)
26
27
  if name == entry then
27
28
  return nil
28
29
  end
29
- local _result_1 = app.rules
30
- if _result_1 ~= nil then
31
- _result_1 = _result_1.exclusiveGroup
30
+ local _result = app.rules
31
+ if _result ~= nil then
32
+ _result = _result.exclusiveGroup
32
33
  end
33
- if _result_1 ~= group then
34
+ if _result ~= group then
34
35
  return nil
35
36
  end
36
- if forge:getSource(name)() then
37
- forge:close(name, false)
37
+ local visible = forge:getSource(name)()
38
+ if not visible then
39
+ return nil
38
40
  end
41
+ forge.debug:logTag("rules", entry, "Closing app due to exclusive group", {
42
+ closed = name,
43
+ group = group,
44
+ })
45
+ forge:close(name, false)
39
46
  end
40
47
  for _k, _v in AppRegistry do
41
48
  _callback(_v, _k, AppRegistry)
@@ -25,36 +25,40 @@ do
25
25
  local _name = name
26
26
  local appClass = AppRegistry[_name]
27
27
  if not appClass then
28
- error(`Failed to get class for app: {name} for renderRules`)
28
+ error(`renderRules: App "{name}" not registered`, 2)
29
29
  end
30
- local _result = appClass.rules
31
- if _result ~= nil then
32
- _result = _result.parent
30
+ local rules = appClass.rules
31
+ if not rules then
32
+ return nil
33
33
  end
34
- local _condition = _result
34
+ -- Parent Anchor
35
+ local _condition = rules.parent
35
36
  if _condition ~= "" and _condition then
36
- _condition = not appClass.rules.detach
37
+ _condition = not rules.detach
37
38
  end
38
39
  if _condition ~= "" and _condition then
39
- self:anchor(name, appClass.rules.parent, props)
40
- end
41
- local _result_1 = appClass.rules
42
- if _result_1 ~= nil then
43
- _result_1 = _result_1.index
40
+ self.debug:logTag("rules", name, "Applying parent anchor", {
41
+ parent = rules.parent,
42
+ })
43
+ self:anchor(name, rules.parent, props)
44
44
  end
45
- if _result_1 ~= 0 and _result_1 == _result_1 and _result_1 then
46
- self:index(name, appClass.rules.index)
45
+ -- Index
46
+ if rules.index ~= nil then
47
+ self.debug:logTag("rules", name, "Applying ZIndex", rules.index)
48
+ self:index(name, rules.index)
47
49
  end
48
50
  end
49
51
  function Rules:checkRules(name)
50
52
  local _processing = self.processing
51
53
  local _name = name
52
54
  if _processing[_name] ~= nil then
55
+ self.debug:logTag("rules", name, "Skipped rule processing (cycle detected)")
53
56
  return nil
54
57
  end
55
58
  local _processing_1 = self.processing
56
59
  local _name_1 = name
57
60
  _processing_1[_name_1] = true
61
+ self.debug:logTag("rules", name, "Evaluating rules")
58
62
  TS.try(function()
59
63
  ParentRule(name, self)
60
64
  ExclusiveGroupRule(name, self)
@@ -4,7 +4,7 @@ local TS = _G[script]
4
4
  -- Components
5
5
  local AppRegistry = TS.import(script, script.Parent.Parent.Parent, "decorator").AppRegistry
6
6
  local function ParentRule(entry, forge)
7
- local entrySource = forge:getSource(entry)()
7
+ local entryVisible = forge:getSource(entry)()
8
8
  -- ▼ ReadonlyMap.forEach ▼
9
9
  local _callback = function(app, name)
10
10
  local rules = app.rules
@@ -14,10 +14,14 @@ local function ParentRule(entry, forge)
14
14
  if name == entry then
15
15
  return nil
16
16
  end
17
- local childSource = forge:getSource(name)()
18
- if not entrySource and childSource then
19
- forge:close(name, false)
17
+ local childVisible = forge:getSource(name)()
18
+ if entryVisible or not childVisible then
19
+ return nil
20
20
  end
21
+ forge.debug:logTag("rules", entry, "Closing child app (parent closed)", {
22
+ child = name,
23
+ })
24
+ forge:close(name, false)
21
25
  end
22
26
  for _k, _v in AppRegistry do
23
27
  _callback(_v, _k, AppRegistry)
@@ -1,7 +1,7 @@
1
1
  declare const Contexts: {
2
2
  readonly App: import("@rbxts/vide").Context<{
3
3
  forge: import(".").default;
4
- px: typeof import("@rbxts/loners-pretty-vide-utils").px;
4
+ px: typeof import("./hooks/usePx").px;
5
5
  } | undefined>;
6
6
  };
7
7
  export default Contexts;
@@ -0,0 +1,15 @@
1
+ export type DebugTag = string;
2
+ type LogFn = (level: "DEBUG" | "PERF", message: string, data?: unknown, traceback?: string) => void;
3
+ export default class Debugger {
4
+ private readonly log;
5
+ private enabled;
6
+ private timers;
7
+ constructor(log: LogFn);
8
+ enable(tag: DebugTag): void;
9
+ disable(tag: DebugTag): void;
10
+ isEnabled(tag: DebugTag): boolean;
11
+ logTag(tag: DebugTag, app: AppNames, message: string, data?: unknown): void;
12
+ time(tag: DebugTag, app: AppNames): void;
13
+ timeEnd(tag: DebugTag, app: AppNames): void;
14
+ }
15
+ export {};
@@ -0,0 +1,83 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ -- Services
4
+ local RunService = TS.import(script, TS.getModule(script, "@rbxts", "services")).RunService
5
+ local Debugger
6
+ do
7
+ Debugger = setmetatable({}, {
8
+ __tostring = function()
9
+ return "Debugger"
10
+ end,
11
+ })
12
+ Debugger.__index = Debugger
13
+ function Debugger.new(...)
14
+ local self = setmetatable({}, Debugger)
15
+ return self:constructor(...) or self
16
+ end
17
+ function Debugger:constructor(log)
18
+ self.log = log
19
+ self.enabled = {}
20
+ self.timers = {}
21
+ end
22
+ function Debugger:enable(tag)
23
+ local _enabled = self.enabled
24
+ local _tag = tag
25
+ _enabled[_tag] = true
26
+ end
27
+ function Debugger:disable(tag)
28
+ local _enabled = self.enabled
29
+ local _tag = tag
30
+ _enabled[_tag] = nil
31
+ end
32
+ function Debugger:isEnabled(tag)
33
+ local _enabled = self.enabled
34
+ local _tag = tag
35
+ return _enabled[_tag] ~= nil
36
+ end
37
+ function Debugger:logTag(tag, app, message, data)
38
+ if not RunService:IsStudio() then
39
+ return nil
40
+ end
41
+ local _enabled = self.enabled
42
+ local _tag = tag
43
+ if not (_enabled[_tag] ~= nil) then
44
+ return nil
45
+ end
46
+ self.log("DEBUG", `[{tag}][{app}] {message}`, data, debug.traceback(nil, 3))
47
+ end
48
+ function Debugger:time(tag, app)
49
+ if not RunService:IsStudio() then
50
+ return nil
51
+ end
52
+ local _enabled = self.enabled
53
+ local _tag = tag
54
+ if not (_enabled[_tag] ~= nil) then
55
+ return nil
56
+ end
57
+ local _timers = self.timers
58
+ local _arg0 = `{tag}:{app}`
59
+ local _arg1 = os.clock()
60
+ _timers[_arg0] = _arg1
61
+ end
62
+ function Debugger:timeEnd(tag, app)
63
+ if not RunService:IsStudio() then
64
+ return nil
65
+ end
66
+ local _enabled = self.enabled
67
+ local _tag = tag
68
+ if not (_enabled[_tag] ~= nil) then
69
+ return nil
70
+ end
71
+ local key = `{tag}:{app}`
72
+ local start = self.timers[key]
73
+ if start == nil then
74
+ return nil
75
+ end
76
+ self.timers[key] = nil
77
+ local elapsed = os.clock() - start
78
+ self.log("PERF", `[{tag}][{app}] {string.format("%.3fms", elapsed * 1000)}`)
79
+ end
80
+ end
81
+ return {
82
+ default = Debugger,
83
+ }
@@ -0,0 +1,3 @@
1
+ import Debugger from "./debugger";
2
+ import Logger from "./logger";
3
+ export { Debugger, Logger };
@@ -0,0 +1,8 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local Debugger = TS.import(script, script, "debugger").default
4
+ local Logger = TS.import(script, script, "logger").default
5
+ return {
6
+ Debugger = Debugger,
7
+ Logger = Logger,
8
+ }
@@ -0,0 +1,5 @@
1
+ export default class Logger {
2
+ private readonly scope;
3
+ constructor(scope: string);
4
+ log(level: "DEBUG" | "PERF" | "INFO" | "WARN" | "ERROR", message: string, data?: unknown, traceback?: string): void;
5
+ }
@@ -0,0 +1,37 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ -- debug/logger.ts
4
+ local RunService = TS.import(script, TS.getModule(script, "@rbxts", "services")).RunService
5
+ local Logger
6
+ do
7
+ Logger = setmetatable({}, {
8
+ __tostring = function()
9
+ return "Logger"
10
+ end,
11
+ })
12
+ Logger.__index = Logger
13
+ function Logger.new(...)
14
+ local self = setmetatable({}, Logger)
15
+ return self:constructor(...) or self
16
+ end
17
+ function Logger:constructor(scope)
18
+ self.scope = scope
19
+ end
20
+ function Logger:log(level, message, data, traceback)
21
+ if not RunService:IsStudio() then
22
+ return nil
23
+ end
24
+ local prefix = `[{self.scope}][{level}]`
25
+ if data ~= nil then
26
+ print(prefix, message, data)
27
+ else
28
+ print(prefix, message)
29
+ end
30
+ if traceback ~= "" and traceback then
31
+ print(traceback)
32
+ end
33
+ end
34
+ end
35
+ return {
36
+ default = Logger,
37
+ }
@@ -2,7 +2,15 @@ import Vide from "@rbxts/vide";
2
2
  import type Types from "./types";
3
3
  import type AppForge from ".";
4
4
  export declare const AppRegistry: Map<string, Types.AppRegistry.Static>;
5
+ /**
6
+ * Registers a Vide App with AppForge.
7
+ *
8
+ * This runs at definition time and validates static configuration.
9
+ */
5
10
  export declare function App<N extends AppNames>(props: Types.AppRegistry.Props<N>): <T extends new (props: Types.Props.Main, name: AppNames) => Args>(constructor: T) => T;
11
+ /**
12
+ * Base class for all AppForge Apps.
13
+ */
6
14
  export declare abstract class Args {
7
15
  readonly forge: AppForge;
8
16
  readonly props: Types.Props.Class;
@@ -1,15 +1,40 @@
1
1
  -- Compiled with roblox-ts v3.0.0
2
2
  local TS = _G[script]
3
3
  -- Packages
4
- local px = TS.import(script, TS.getModule(script, "@rbxts", "loners-pretty-vide-utils").out).px
5
4
  -- Types
5
+ -- Hooks
6
+ local px = TS.import(script, script.Parent, "hooks", "usePx").px
7
+ -- Debug
8
+ local Logger = TS.import(script, script.Parent, "debug", "logger").default
9
+ local logger = Logger.new("AppRegistry")
6
10
  local AppRegistry = {}
11
+ --[[
12
+ *
13
+ * Registers a Vide App with AppForge.
14
+ *
15
+ * This runs at definition time and validates static configuration.
16
+
17
+ ]]
7
18
  local function App(props)
8
19
  return function(constructor)
9
20
  local _name = props.name
10
21
  if AppRegistry[_name] ~= nil then
11
- error(`Duplicate registered App name "{props.name}"`)
22
+ logger:log("ERROR", "Duplicate App name detected", {
23
+ name = props.name,
24
+ })
25
+ error(`Duplicate registered App name "{props.name}". ` .. `App names must be globally unique.`, 2)
12
26
  end
27
+ local _value = props.name
28
+ if not (_value ~= "" and _value) then
29
+ logger:log("ERROR", "Attempted to register App without a name", props)
30
+ error("App registration failed: missing app name", 2)
31
+ end
32
+ logger:log("DEBUG", "Registering App", {
33
+ name = props.name,
34
+ renderGroup = props.renderGroup,
35
+ visible = props.visible,
36
+ rules = props.rules,
37
+ })
13
38
  local _name_1 = props.name
14
39
  local _arg1 = {
15
40
  constructor = constructor,
@@ -21,6 +46,11 @@ local function App(props)
21
46
  return constructor
22
47
  end
23
48
  end
49
+ --[[
50
+ *
51
+ * Base class for all AppForge Apps.
52
+
53
+ ]]
24
54
  local Args
25
55
  do
26
56
  Args = {}
@@ -28,13 +58,20 @@ do
28
58
  local _binding = props
29
59
  local forge = _binding.forge
30
60
  self.forge = forge
61
+ self.name = name
31
62
  local _object = table.clone(props.props)
32
63
  setmetatable(_object, nil)
33
64
  _object.px = px
34
65
  _object.forge = forge
35
66
  self.props = _object
36
- self.name = name
37
- self.source = forge:getSource(name)
67
+ local src = forge:getSource(name)
68
+ if not src then
69
+ logger:log("ERROR", "Missing visibility source for App", {
70
+ name = name,
71
+ })
72
+ error(`Failed to retrieve visibility source for app "{name}"`, 2)
73
+ end
74
+ self.source = src
38
75
  end
39
76
  end
40
77
  return {
@@ -1,5 +1,5 @@
1
1
  declare const _default: () => {
2
2
  forge: import("..").default;
3
- px: typeof import("@rbxts/loners-pretty-vide-utils").px;
3
+ px: typeof import("./usePx").px;
4
4
  };
5
5
  export default _default;
@@ -2,10 +2,14 @@
2
2
  local TS = _G[script]
3
3
  -- Components
4
4
  local Contexts = TS.import(script, script.Parent.Parent, "context").default
5
+ -- Debug
6
+ local Logger = TS.import(script, script.Parent.Parent, "debug", "logger").default
7
+ local logger = Logger.new("useAppContext")
5
8
  local default = function()
6
9
  local context = Contexts.App()
7
10
  if not context then
8
- error(`Failed to retrieve App Props for Vide {debug.traceback()}`)
11
+ logger:log("ERROR", "Failed to retrieve App context")
12
+ error(`Failed to retrieve App Props for Vide\n{debug.traceback()}`, 2)
9
13
  end
10
14
  return context
11
15
  end
@@ -0,0 +1,17 @@
1
+ type EventLike<T extends Callback = Callback> = {
2
+ Connect(callback: T): ConnectionLike;
3
+ } | {
4
+ connect(callback: T): ConnectionLike;
5
+ } | {
6
+ subscribe(callback: T): ConnectionLike;
7
+ };
8
+ type ConnectionLike = {
9
+ Disconnect(): void;
10
+ } | {
11
+ disconnect(): void;
12
+ } | (() => void);
13
+ /**
14
+ * Subscribes to an event-like object and auto-cleans up.
15
+ */
16
+ export declare function useEventListener<T extends EventLike>(event: T, listener: T extends EventLike<infer U> ? U : never): ReturnType<T>;
17
+ export {};
@@ -0,0 +1,61 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local cleanup = TS.import(script, TS.getModule(script, "@rbxts", "vide").src).cleanup
4
+ -- Debug
5
+ local Logger = TS.import(script, script.Parent.Parent, "debug", "logger").default
6
+ local logger = Logger.new("useEventListener")
7
+ local connect = function(event, callback)
8
+ local _event = event
9
+ if typeof(_event) == "RBXScriptSignal" then
10
+ local connection
11
+ connection = event:Connect(function(...)
12
+ local args = { ... }
13
+ if connection.Connected then
14
+ return callback(unpack(args))
15
+ end
16
+ end)
17
+ return connection
18
+ elseif event.Connect ~= nil then
19
+ return event:Connect(callback)
20
+ elseif event.connect ~= nil then
21
+ return event:connect(callback)
22
+ elseif event.subscribe ~= nil then
23
+ return event:subscribe(callback)
24
+ end
25
+ logger:log("ERROR", "Unsupported event-like object", event)
26
+ error("Event-like object does not have a supported connect method.", 2)
27
+ end
28
+ local disconnect = function(connection)
29
+ local _connection = connection
30
+ if type(_connection) == "function" then
31
+ connection()
32
+ else
33
+ local _connection_1 = connection
34
+ local _condition = typeof(_connection_1) == "RBXScriptConnection"
35
+ if not _condition then
36
+ _condition = connection.Disconnect ~= nil
37
+ end
38
+ if _condition then
39
+ connection:Disconnect()
40
+ elseif connection.disconnect ~= nil then
41
+ connection:disconnect()
42
+ else
43
+ logger:log("WARN", "Unsupported connection-like object during cleanup", connection)
44
+ end
45
+ end
46
+ end
47
+ --[[
48
+ *
49
+ * Subscribes to an event-like object and auto-cleans up.
50
+
51
+ ]]
52
+ local function useEventListener(event, listener)
53
+ local connection = connect(event, listener)
54
+ cleanup(function()
55
+ return disconnect(connection)
56
+ end)
57
+ return connection
58
+ end
59
+ return {
60
+ useEventListener = useEventListener,
61
+ }
@@ -0,0 +1,11 @@
1
+ export declare const px: ((value: number) => number) & {
2
+ scale: (value: number) => number;
3
+ even: (value: number) => number;
4
+ floor: (value: number) => number;
5
+ ceil: (value: number) => number;
6
+ };
7
+ /**
8
+ * Initializes global px scaling.
9
+ * Must be called exactly once.
10
+ */
11
+ export declare function usePx(target?: GuiObject | Camera, baseResolution?: Vector2, minScale?: number): void;
@@ -0,0 +1,103 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ -- Services
4
+ local Workspace = TS.import(script, TS.getModule(script, "@rbxts", "services")).Workspace
5
+ -- Packages
6
+ local source = TS.import(script, TS.getModule(script, "@rbxts", "vide").src).source
7
+ -- Helpers
8
+ local useEventListener = TS.import(script, script.Parent, "useEventListener").useEventListener
9
+ -- Debug
10
+ local Logger = TS.import(script, script.Parent.Parent, "debug", "logger").default
11
+ local logger = Logger.new("usePx")
12
+ --* Default reference resolution for px calculations
13
+ local BASE_RESOLUTION = source(Vector2.new(1920, 1080))
14
+ --* Minimum allowed scale to prevent unreadable UI
15
+ local MIN_SCALE = source(0.5)
16
+ --* 0 = width-based, 1 = height-based
17
+ local DOMINANT_AXIS = 0.5
18
+ local TARGET = source(Workspace.CurrentCamera)
19
+ local SCALE = source(1)
20
+ local INITIALIZED = false
21
+ local function callable(callback, object)
22
+ return setmetatable(object, {
23
+ __call = function(_, ...)
24
+ local args = { ... }
25
+ return callback(unpack(args))
26
+ end,
27
+ })
28
+ end
29
+ local px = callable(function(value)
30
+ return math.round(value * SCALE())
31
+ end, {
32
+ scale = function(value)
33
+ return value * SCALE()
34
+ end,
35
+ even = function(value)
36
+ return math.round(value * SCALE() * 0.5) * 2
37
+ end,
38
+ floor = function(value)
39
+ return math.floor(value * SCALE())
40
+ end,
41
+ ceil = function(value)
42
+ return math.ceil(value * SCALE())
43
+ end,
44
+ })
45
+ local function calculateScale()
46
+ local target = TARGET()
47
+ if not target then
48
+ return nil
49
+ end
50
+ local size = if target:IsA("Camera") then target.ViewportSize elseif target:IsA("GuiObject") then target.AbsoluteSize else nil
51
+ if not size then
52
+ return nil
53
+ end
54
+ local res = BASE_RESOLUTION()
55
+ if res.X <= 0 or res.Y <= 0 then
56
+ return nil
57
+ end
58
+ local min = MIN_SCALE()
59
+ local width = math.log(size.X / res.X, 2)
60
+ local height = math.log(size.Y / res.Y, 2)
61
+ local centered = width + (height - width) * DOMINANT_AXIS
62
+ local scale = 2 ^ centered
63
+ SCALE(math.max(scale, min))
64
+ end
65
+ --[[
66
+ *
67
+ * Initializes global px scaling.
68
+ * Must be called exactly once.
69
+
70
+ ]]
71
+ local function usePx(target, baseResolution, minScale)
72
+ if INITIALIZED then
73
+ logger:log("WARN", "usePx() called more than once")
74
+ return nil
75
+ end
76
+ INITIALIZED = true
77
+ logger:log("DEBUG", "Initializing px scaling", {
78
+ target = target,
79
+ baseResolution = baseResolution,
80
+ minScale = minScale,
81
+ })
82
+ if baseResolution then
83
+ BASE_RESOLUTION(baseResolution)
84
+ end
85
+ if minScale ~= nil then
86
+ MIN_SCALE(minScale)
87
+ end
88
+ if target then
89
+ TARGET(target)
90
+ end
91
+ local resolvedTarget = TARGET()
92
+ if not resolvedTarget then
93
+ logger:log("WARN", "usePx(): no valid target to observe")
94
+ return nil
95
+ end
96
+ local signal = if resolvedTarget:IsA("Camera") then resolvedTarget:GetPropertyChangedSignal("ViewportSize") else resolvedTarget:GetPropertyChangedSignal("AbsoluteSize")
97
+ useEventListener(signal, calculateScale)
98
+ calculateScale()
99
+ end
100
+ return {
101
+ usePx = usePx,
102
+ px = px,
103
+ }
@@ -1,6 +1,7 @@
1
1
  import Vide from "@rbxts/vide";
2
2
  import Renders from "./classes/renders";
3
3
  import Types from "./types";
4
+ import { Logger, Debugger } from "./debug";
4
5
  type Destructor = () => void;
5
6
  type Loaded = {
6
7
  container: Vide.Node;
@@ -8,17 +9,19 @@ type Loaded = {
8
9
  anchor?: Vide.Node;
9
10
  };
10
11
  export default class AppForge extends Renders {
12
+ readonly logger: Logger;
13
+ readonly debug: Debugger;
11
14
  protected sources: Map<string, Vide.Source<boolean>>;
12
15
  protected loaded: Map<string, Loaded>;
13
16
  protected innerMount?: Destructor;
14
17
  protected __px: boolean;
15
18
  constructor();
16
- protected createSource(name: AppNames): typeof Vide.source | undefined;
19
+ protected createSource(name: AppNames): void;
20
+ getSource(name: AppNames): Vide.Source<boolean>;
17
21
  isLoaded(name: AppNames): boolean;
18
22
  bind(name: AppNames, value: Vide.Source<boolean>): void;
19
23
  anchor(name: AppNames, anchorName: AppNames, props: Types.Props.Main): void;
20
24
  index(name: AppNames, index: number): void;
21
- getSource(name: AppNames): Vide.Source<boolean>;
22
25
  set(name: AppNames, value: boolean, rules?: boolean): void;
23
26
  open(name: AppNames, rules?: boolean): void;
24
27
  close(name: AppNames, rules?: boolean): void;
@@ -14,6 +14,10 @@ local untrack = _vide.untrack
14
14
  local Renders = TS.import(script, script, "classes", "renders").default
15
15
  -- Helpers
16
16
  local AppRegistry = TS.import(script, script, "decorator").AppRegistry
17
+ -- Debug
18
+ local _debug = TS.import(script, script, "debug")
19
+ local Logger = _debug.Logger
20
+ local Debugger = _debug.Debugger
17
21
  local AppForge
18
22
  do
19
23
  local super = Renders
@@ -30,6 +34,10 @@ do
30
34
  end
31
35
  function AppForge:constructor()
32
36
  super.constructor(self)
37
+ self.logger = Logger.new("AppForge")
38
+ self.debug = Debugger.new(function(level, msg, data, trace)
39
+ return self.logger:log(level, msg, data, trace)
40
+ end)
33
41
  self.sources = {}
34
42
  self.loaded = {}
35
43
  self.__px = false
@@ -46,22 +54,48 @@ do
46
54
  local _name = name
47
55
  local app = AppRegistry[_name]
48
56
  if not app then
49
- error(`App "{name}" not registered`)
57
+ self.logger:log("ERROR", "App not registered while creating source", {
58
+ name = name,
59
+ })
60
+ return nil
50
61
  end
51
62
  local _sources = self.sources
52
63
  local _name_1 = name
53
64
  if _sources[_name_1] ~= nil then
54
65
  return nil
55
66
  end
56
- local _sources_1 = self.sources
67
+ local _debug_1 = self.debug
57
68
  local _exp = name
69
+ local _object = {}
70
+ local _left = "default"
58
71
  local _condition = app.visible
59
72
  if _condition == nil then
60
73
  _condition = false
61
74
  end
62
- local _arg1 = source(_condition)
63
- _sources_1[_exp] = _arg1
64
- return source
75
+ _object[_left] = _condition
76
+ _debug_1:logTag("state", _exp, "Creating visibility source", _object)
77
+ local _sources_1 = self.sources
78
+ local _exp_1 = name
79
+ local _condition_1 = app.visible
80
+ if _condition_1 == nil then
81
+ _condition_1 = false
82
+ end
83
+ local _arg1 = source(_condition_1)
84
+ _sources_1[_exp_1] = _arg1
85
+ end
86
+ function AppForge:getSource(name)
87
+ local _sources = self.sources
88
+ local _name = name
89
+ if not (_sources[_name] ~= nil) then
90
+ self:createSource(name)
91
+ end
92
+ local _sources_1 = self.sources
93
+ local _name_1 = name
94
+ local src = _sources_1[_name_1]
95
+ if not src then
96
+ error(`AppForge invariant broken: missing visibility source for {name}`, 2)
97
+ end
98
+ return src
65
99
  end
66
100
  function AppForge:isLoaded(name)
67
101
  local _loaded = self.loaded
@@ -70,6 +104,7 @@ do
70
104
  end
71
105
  function AppForge:bind(name, value)
72
106
  if not RunService:IsRunning() then
107
+ self.debug:logTag("state", name, "Binding external visibility source")
73
108
  local _sources = self.sources
74
109
  local _name = name
75
110
  local _value = value
@@ -81,17 +116,26 @@ do
81
116
  end)
82
117
  end)
83
118
  else
84
- warn("forge.bind is used for studio when game isnt running")
119
+ self.logger:log("WARN", "forge.bind called while game is running", {
120
+ name = name,
121
+ })
85
122
  end
86
123
  end
87
124
  function AppForge:anchor(name, anchorName, props)
88
125
  if name == anchorName then
89
- error(`Tried to anchor an App to itself`)
126
+ self.logger:log("ERROR", "Attempted to anchor app to itself", {
127
+ name = name,
128
+ })
129
+ return nil
90
130
  end
91
131
  local _anchorName = anchorName
92
132
  local anchorApp = AppRegistry[_anchorName]
93
133
  if not anchorApp then
94
- error(`Failed to get class for {anchorName} from AppRegistry for anchor`)
134
+ self.logger:log("ERROR", "Anchor parent not registered", {
135
+ child = name,
136
+ parent = anchorName,
137
+ })
138
+ return nil
95
139
  end
96
140
  local _loaded = self.loaded
97
141
  local _name = name
@@ -101,11 +145,17 @@ do
101
145
  end
102
146
  local render = _render
103
147
  if not render then
104
- error(`Failed to get {name} from this.loaded for anchor to {anchorName}`)
148
+ self.debug:logTag("rules", name, "Anchor skipped (child not rendered yet)", {
149
+ parent = anchorName,
150
+ })
151
+ return nil
105
152
  end
153
+ self.debug:logTag("rules", name, "Anchoring to parent", {
154
+ parent = anchorName,
155
+ })
106
156
  local anchor = anchorApp.constructor.new(props, anchorName):render()
107
- for _, children in anchor:GetDescendants() do
108
- children:Destroy()
157
+ for _, child in anchor:GetDescendants() do
158
+ child:Destroy()
109
159
  end
110
160
  apply(anchor)({
111
161
  Name = "Anchor",
@@ -116,7 +166,7 @@ do
116
166
  local _name_1 = name
117
167
  local prev = _loaded_1[_name_1]
118
168
  if not prev then
119
- error(`Failed to retreive prev loaded data for {name}`)
169
+ error(`AppForge invariant broken: missing loaded app for {name}`, 2)
120
170
  end
121
171
  apply(prev.container)({
122
172
  [0] = anchor,
@@ -133,22 +183,19 @@ do
133
183
  local _name = name
134
184
  local loaded = _loaded[_name]
135
185
  if not loaded then
136
- error(`Failed to retreive loaded data for app: {name}`)
186
+ self.logger:log("WARN", "ZIndex skipped (app not loaded)", {
187
+ name = name,
188
+ index = index,
189
+ })
190
+ return nil
137
191
  end
192
+ self.debug:logTag("rules", name, "Applying ZIndex", {
193
+ index = index,
194
+ })
138
195
  apply(loaded.container)({
139
196
  ZIndex = index,
140
197
  })
141
198
  end
142
- function AppForge:getSource(name)
143
- local _sources = self.sources
144
- local _name = name
145
- if not (_sources[_name] ~= nil) then
146
- self:createSource(name)
147
- end
148
- local _sources_1 = self.sources
149
- local _name_1 = name
150
- return _sources_1[_name_1]
151
- end
152
199
  function AppForge:set(name, value, rules)
153
200
  if rules == nil then
154
201
  rules = true
@@ -162,10 +209,21 @@ do
162
209
  local _name_1 = name
163
210
  src = _sources_1[_name_1]
164
211
  end
165
- if src() == value then
212
+ if not src then
213
+ self.logger:log("ERROR", "Failed to set visibility (missing source)", {
214
+ name = name,
215
+ })
216
+ return nil
217
+ end
218
+ local prev = src()
219
+ if prev == value then
166
220
  return nil
167
221
  end
168
222
  src(value)
223
+ self.debug:logTag("state", name, "Visibility changed", {
224
+ from = prev,
225
+ to = value,
226
+ })
169
227
  if rules then
170
228
  self:checkRules(name)
171
229
  end
@@ -189,6 +247,7 @@ do
189
247
  self:set(name, not self:getSource(name)(), rules)
190
248
  end
191
249
  function AppForge:story(props)
250
+ self.debug:logTag("lifecycle", "story", "Creating story mount")
192
251
  local Container = create("Frame")({
193
252
  Name = "Story Container",
194
253
  BackgroundTransparency = 1,
@@ -202,6 +261,7 @@ do
202
261
  return Container
203
262
  end
204
263
  function AppForge:mount(callback, props, target)
264
+ self.debug:logTag("lifecycle", "mount", "Mounting AppForge")
205
265
  local Container = callback()
206
266
  self.innerMount = mount(function()
207
267
  apply(Container)({
@@ -212,6 +272,7 @@ do
212
272
  return self.innerMount
213
273
  end
214
274
  function AppForge:unMount()
275
+ self.debug:logTag("lifecycle", "unmount", "Unmounting AppForge")
215
276
  local _result = self.innerMount
216
277
  if _result ~= nil then
217
278
  _result()
@@ -26,7 +26,7 @@ declare namespace Types {
26
26
 
27
27
  type Class = AppProps & {
28
28
  forge: AppForge;
29
- px: typeof import("@rbxts/loners-pretty-vide-utils").px;
29
+ px: typeof import("./hooks/usePx").px;
30
30
  };
31
31
  }
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbxts/app-forge",
3
- "version": "0.7.0",
3
+ "version": "0.7.2-alpha.1",
4
4
  "description": "An App Manager for Vide",
5
5
  "main": "out/init.lua",
6
6
  "types": "out/index.d.ts",
@@ -38,11 +38,17 @@
38
38
  "@rbxts/set-timeout": "^1.1.2"
39
39
  },
40
40
  "peerDependencies": {
41
- "@rbxts/loners-pretty-react-hooks": "^0.2.9",
42
- "@rbxts/loners-pretty-vide-utils": "^0.1.7",
43
41
  "@rbxts/react": "^17.3.7-ts.1",
44
42
  "@rbxts/vide": "^0.5.7"
45
43
  },
44
+ "peerDependenciesMeta": {
45
+ "@rbxts/react": {
46
+ "optional": true
47
+ },
48
+ "@rbxts/vide": {
49
+ "optional": true
50
+ }
51
+ },
46
52
  "devDependencies": {
47
53
  "@biomejs/biome": "^2.3.7",
48
54
  "@rbxts/compiler-types": "3.0.0-types.0",