@k9kbdev/roblox-css 0.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.
Files changed (55) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +245 -0
  3. package/default.project.json +6 -0
  4. package/out/index.d.ts +35 -0
  5. package/out/init.luau +57 -0
  6. package/out/logger.d.ts +23 -0
  7. package/out/logger.luau +73 -0
  8. package/out/primitives/Box.d.ts +23 -0
  9. package/out/primitives/Box.luau +103 -0
  10. package/out/primitives/Button.d.ts +62 -0
  11. package/out/primitives/Button.luau +170 -0
  12. package/out/primitives/Image.d.ts +37 -0
  13. package/out/primitives/Image.luau +79 -0
  14. package/out/primitives/InlineText.d.ts +25 -0
  15. package/out/primitives/InlineText.luau +273 -0
  16. package/out/primitives/Input.d.ts +59 -0
  17. package/out/primitives/Input.luau +126 -0
  18. package/out/primitives/MotionBox.d.ts +15 -0
  19. package/out/primitives/MotionBox.luau +69 -0
  20. package/out/primitives/MotionButton.d.ts +15 -0
  21. package/out/primitives/MotionButton.luau +146 -0
  22. package/out/primitives/MotionImage.d.ts +13 -0
  23. package/out/primitives/MotionImage.luau +70 -0
  24. package/out/primitives/MotionText.d.ts +12 -0
  25. package/out/primitives/MotionText.luau +116 -0
  26. package/out/primitives/MotionUIScale.d.ts +9 -0
  27. package/out/primitives/MotionUIScale.luau +48 -0
  28. package/out/primitives/ScrollBox.d.ts +25 -0
  29. package/out/primitives/ScrollBox.luau +69 -0
  30. package/out/primitives/Text.d.ts +50 -0
  31. package/out/primitives/Text.luau +139 -0
  32. package/out/primitives/usePercentageConstraints.d.ts +3 -0
  33. package/out/primitives/usePercentageConstraints.luau +112 -0
  34. package/out/primitives/useVariantResolver.d.ts +13 -0
  35. package/out/primitives/useVariantResolver.luau +260 -0
  36. package/out/styles/CSSTypes.d.ts +96 -0
  37. package/out/styles/ParentSizeContext.d.ts +6 -0
  38. package/out/styles/ParentSizeContext.luau +13 -0
  39. package/out/styles/colorParser.d.ts +28 -0
  40. package/out/styles/colorParser.luau +229 -0
  41. package/out/styles/dimensionParser.d.ts +49 -0
  42. package/out/styles/dimensionParser.luau +205 -0
  43. package/out/styles/gradientParser.d.ts +9 -0
  44. package/out/styles/gradientParser.luau +434 -0
  45. package/out/styles/namedColors.d.ts +7 -0
  46. package/out/styles/namedColors.luau +162 -0
  47. package/out/styles/transitions.d.ts +18 -0
  48. package/out/styles/transitions.luau +19 -0
  49. package/out/styles/webStyle.d.ts +74 -0
  50. package/out/styles/webStyle.luau +973 -0
  51. package/out/types.d.ts +4 -0
  52. package/out/types.luau +3 -0
  53. package/out/utils/parseInlineImages.d.ts +20 -0
  54. package/out/utils/parseInlineImages.luau +93 -0
  55. package/package.json +56 -0
@@ -0,0 +1,973 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ --[[
4
+ *
5
+ * webStyle.ts — Core CSS → Roblox translation engine.
6
+ *
7
+ * Takes a CSSProperties object and produces:
8
+ * 1. Roblox instance properties (Size, BackgroundColor3, etc.)
9
+ * 2. Child instances to inject (UICorner, UIPadding, UIListLayout, UIStroke)
10
+ *
11
+ * This is the heart of the middleware — the function that makes
12
+ * <Box style={{ borderRadius: 10 }}> automatically inject a <uicorner>.
13
+ *
14
+ * Translation mapping:
15
+ * CSS Property → Roblox Output
16
+ * ─────────────────────────────────────────────────────────
17
+ * width / height → Size (UDim2)
18
+ * backgroundColor → BackgroundColor3 + BackgroundTransparency
19
+ * opacity → BackgroundTransparency (inverted: 1 - opacity)
20
+ * borderRadius → <uicorner> child (CornerRadius)
21
+ * padding / padding* → <uipadding> child (PaddingTop/Right/Bottom/Left)
22
+ * display: "flex" → <uilistlayout> child (FillDirection, alignment, gap)
23
+ * border → <uistroke> child (Thickness, Color)
24
+ * position: "absolute"→ Position (UDim2) + AnchorPoint (Vector2)
25
+
26
+ ]]
27
+ local _dimensionParser = TS.import(script, script.Parent, "dimensionParser")
28
+ local parseDimension = _dimensionParser.parseDimension
29
+ local parsePadding = _dimensionParser.parsePadding
30
+ local parseColor = TS.import(script, script.Parent, "colorParser").parseColor
31
+ local _gradientParser = TS.import(script, script.Parent, "gradientParser")
32
+ local parseGradient = _gradientParser.parseGradient
33
+ local isGradientString = _gradientParser.isGradientString
34
+ local React = TS.import(script, TS.getModule(script, "@rbxts", "react"))
35
+ -- Re-export for convenience
36
+ --[[
37
+ *
38
+ * Branded WebStyleResult — the `_parsed` brand ensures only values produced by
39
+ * webStyle() are assignable. A plain `{ props, children }` object will NOT
40
+ * satisfy this type, catching accidental bypasses at compile time.
41
+ *
42
+ * - `props`: Roblox instance properties to spread onto the host element
43
+ * - `children`: UI constraint elements to inject (<uicorner>, <uipadding>, etc.)
44
+
45
+ ]]
46
+ --* Helper to construct a branded WebStyleResult inside this module.
47
+ local function makeWebStyleResult(props, children)
48
+ return {
49
+ props = props,
50
+ children = children,
51
+ }
52
+ end
53
+ --[[
54
+ *
55
+ * Alignment lookup tables — hoisted to module scope to avoid
56
+ * re-creation on every webStyle() call.
57
+ *
58
+ * JUSTIFY_MAP: CSS justifyContent → Roblox HorizontalAlignment
59
+ * "flex-start" → Left
60
+ * "center" → Center
61
+ * "flex-end" → Right
62
+ *
63
+ * ALIGN_MAP: CSS alignItems → Roblox VerticalAlignment
64
+ * "flex-start" → Top
65
+ * "center" → Center
66
+ * "flex-end" → Bottom
67
+
68
+ ]]
69
+ local JUSTIFY_MAP = {
70
+ ["flex-start"] = Enum.HorizontalAlignment.Left,
71
+ center = Enum.HorizontalAlignment.Center,
72
+ ["flex-end"] = Enum.HorizontalAlignment.Right,
73
+ }
74
+ local ALIGN_MAP = {
75
+ ["flex-start"] = Enum.VerticalAlignment.Top,
76
+ center = Enum.VerticalAlignment.Center,
77
+ ["flex-end"] = Enum.VerticalAlignment.Bottom,
78
+ }
79
+ local FONT_WEIGHT_MAP = {
80
+ normal = Enum.FontWeight.Regular,
81
+ bold = Enum.FontWeight.Bold,
82
+ black = Enum.FontWeight.Heavy,
83
+ }
84
+ local FONT_FAMILY_MAP = {
85
+ BuilderSans = "rbxasset://fonts/families/BuilderSans.json",
86
+ Montserrat = "rbxasset://fonts/families/Montserrat.json",
87
+ }
88
+ local DEFAULT_FONT_FAMILY = "rbxasset://fonts/families/BuilderSans.json"
89
+ local TEXT_Y_ALIGN_MAP = {
90
+ top = Enum.TextYAlignment.Top,
91
+ center = Enum.TextYAlignment.Center,
92
+ bottom = Enum.TextYAlignment.Bottom,
93
+ }
94
+ local AUTO_SIZE_MAP = {
95
+ none = Enum.AutomaticSize.None,
96
+ x = Enum.AutomaticSize.X,
97
+ y = Enum.AutomaticSize.Y,
98
+ xy = Enum.AutomaticSize.XY,
99
+ }
100
+ local SHADOW_SIZE_MAP = {
101
+ sm = UDim2.new(1, 10, 1, 10),
102
+ md = UDim2.new(1, 15, 1, 15),
103
+ lg = UDim2.new(1, 20, 1, 20),
104
+ xl = UDim2.new(1, 30, 1, 30),
105
+ ["2xl"] = UDim2.new(1, 40, 1, 40),
106
+ }
107
+ local SHADOW_OFFSET_MAP = {
108
+ sm = 2,
109
+ md = 4,
110
+ lg = 6,
111
+ xl = 10,
112
+ ["2xl"] = 15,
113
+ }
114
+ local SHADOW_SLICE_SCALE_MAP = {
115
+ sm = 0.2,
116
+ md = 0.3,
117
+ lg = 0.4,
118
+ xl = 0.6,
119
+ ["2xl"] = 0.8,
120
+ }
121
+ local SHADOW_OPACITY_MAP = {
122
+ sm = 0.7,
123
+ md = 0.6,
124
+ lg = 0.5,
125
+ xl = 0.45,
126
+ ["2xl"] = 0.4,
127
+ }
128
+ --[[
129
+ *
130
+ * Default shadow asset for boxShadow emulation.
131
+ *
132
+ * This is a 9-slice shadow sprite from the Roblox Creator Marketplace:
133
+ * https://create.roblox.com/store/asset/6015897843
134
+ *
135
+ * SliceCenter is hardcoded to Rect(47, 47, 450, 450) to match this specific
136
+ * asset's padding region. If you change the asset, update SHADOW_SLICE_CENTER
137
+ * accordingly.
138
+ *
139
+ * To use a custom shadow asset project-wide, set `SHADOW_ASSET_ID` and
140
+ * `SHADOW_SLICE_CENTER` before any UI renders.
141
+
142
+ ]]
143
+ local SHADOW_ASSET_ID = "rbxassetid://6015897843"
144
+ local SHADOW_SLICE_CENTER = Rect.new(47, 47, 450, 450)
145
+ --[[
146
+ *
147
+ * Builds a <uilistlayout> element from CSS flex properties.
148
+ *
149
+ * CSS → Roblox mapping:
150
+ * flexDirection → FillDirection (Horizontal | Vertical)
151
+ * justifyContent → main-axis alignment (Horizontal for row, Vertical for column)
152
+ * alignItems → cross-axis alignment (opposite axis from justify)
153
+ * gap → Padding (UDim spacing between items)
154
+ *
155
+ * Always sets SortOrder = LayoutOrder so children render in insertion order.
156
+ * Defaults to column (Vertical) when flexDirection is omitted.
157
+
158
+ ]]
159
+ local function buildListLayout(style)
160
+ local fillDirection = if style.flexDirection == "row" then Enum.FillDirection.Horizontal else Enum.FillDirection.Vertical
161
+ local layoutProps = {
162
+ FillDirection = fillDirection,
163
+ SortOrder = Enum.SortOrder.LayoutOrder,
164
+ }
165
+ if style.flexWrap == "wrap" then
166
+ layoutProps.Wraps = true
167
+ end
168
+ -- justifyContent → main-axis alignment
169
+ if style.justifyContent ~= nil then
170
+ if style.justifyContent == "space-between" or style.justifyContent == "space-around" then
171
+ local flexAlign = if style.justifyContent == "space-between" then Enum.UIFlexAlignment.SpaceBetween else Enum.UIFlexAlignment.SpaceAround
172
+ if style.flexDirection == "row" then
173
+ layoutProps.HorizontalFlex = flexAlign
174
+ else
175
+ layoutProps.VerticalFlex = flexAlign
176
+ end
177
+ else
178
+ if style.flexDirection == "row" then
179
+ layoutProps.HorizontalAlignment = JUSTIFY_MAP[style.justifyContent] or Enum.HorizontalAlignment.Left
180
+ else
181
+ layoutProps.VerticalAlignment = ALIGN_MAP[style.justifyContent] or Enum.VerticalAlignment.Top
182
+ end
183
+ end
184
+ end
185
+ -- alignItems → cross-axis alignment
186
+ if style.alignItems ~= nil then
187
+ if style.alignItems == "stretch" then
188
+ layoutProps.ItemLineAlignment = Enum.ItemLineAlignment.Stretch
189
+ else
190
+ if style.flexDirection == "row" then
191
+ layoutProps.VerticalAlignment = ALIGN_MAP[style.alignItems] or Enum.VerticalAlignment.Top
192
+ else
193
+ layoutProps.HorizontalAlignment = JUSTIFY_MAP[style.alignItems] or Enum.HorizontalAlignment.Left
194
+ end
195
+ end
196
+ end
197
+ -- gap → Padding (space between items)
198
+ if style.gap ~= nil then
199
+ layoutProps.Padding = parseDimension(style.gap) or UDim.new(0, 0)
200
+ end
201
+ layoutProps.key = "uilistlayout"
202
+ return React.createElement("uilistlayout", layoutProps)
203
+ end
204
+ --[[
205
+ *
206
+ * Builds a <uigridlayout> element from CSS grid properties.
207
+ *
208
+ * CSS → Roblox mapping:
209
+ * gridTemplateColumns → CellSize.X
210
+ * gridTemplateRows → CellSize.Y
211
+ * gap → CellPadding
212
+ * justifyContent → HorizontalAlignment
213
+ * alignItems → VerticalAlignment
214
+
215
+ ]]
216
+ local function buildGridLayout(style)
217
+ local layoutProps = {
218
+ SortOrder = Enum.SortOrder.LayoutOrder,
219
+ }
220
+ local fillDirection = if style.flexDirection == "column" then Enum.FillDirection.Vertical else Enum.FillDirection.Horizontal
221
+ layoutProps.FillDirection = fillDirection
222
+ -- gap, rowGap, columnGap -> CellPadding
223
+ local gapX = UDim.new(0, 0)
224
+ local gapY = UDim.new(0, 0)
225
+ local hasGap = false
226
+ if style.gap ~= nil then
227
+ local _gap = style.gap
228
+ local gapParts = if type(_gap) == "string" then string.split(style.gap, " ") else { style.gap }
229
+ gapX = parseDimension(gapParts[1]) or UDim.new(0, 0)
230
+ gapY = if #gapParts > 1 then parseDimension(gapParts[2]) or gapX else gapX
231
+ hasGap = true
232
+ end
233
+ if style.columnGap ~= nil then
234
+ gapX = parseDimension(style.columnGap) or UDim.new(0, 0)
235
+ hasGap = true
236
+ end
237
+ if style.rowGap ~= nil then
238
+ gapY = parseDimension(style.rowGap) or UDim.new(0, 0)
239
+ hasGap = true
240
+ end
241
+ if hasGap then
242
+ layoutProps.CellPadding = UDim2.new(gapX, gapY)
243
+ end
244
+ -- CellSize
245
+ local cellX = if style.gridTemplateColumns ~= nil then parseDimension(style.gridTemplateColumns) or UDim.new(0, 100) else UDim.new(0, 100)
246
+ local cellY = if style.gridTemplateRows ~= nil then parseDimension(style.gridTemplateRows) or UDim.new(0, 100) else UDim.new(0, 100)
247
+ layoutProps.CellSize = UDim2.new(cellX, cellY)
248
+ if style.justifyContent ~= nil then
249
+ layoutProps.HorizontalAlignment = JUSTIFY_MAP[style.justifyContent] or Enum.HorizontalAlignment.Left
250
+ end
251
+ if style.alignItems ~= nil then
252
+ layoutProps.VerticalAlignment = ALIGN_MAP[style.alignItems] or Enum.VerticalAlignment.Top
253
+ end
254
+ layoutProps.key = "uigridlayout"
255
+ return React.createElement("uigridlayout", layoutProps)
256
+ end
257
+ --[[
258
+ *
259
+ * Builds a <uistroke> element from a CSS border shorthand string.
260
+ *
261
+ * Supported format: "<width> <style> <color>"
262
+ * - Width: parsed via parseDimension (e.g. "1px", "2") → Thickness (offset only)
263
+ * - Style: currently ignored (Roblox has no dashed/dotted stroke)
264
+ * - Color: parsed via parseColor (e.g. "#000", "red") → Color (Color3)
265
+ *
266
+ * Examples:
267
+ * "1px solid #ff0000" → Thickness: 1, Color: Color3(1, 0, 0)
268
+ * "2px solid red" → Thickness: 2, Color: Color3(1, 0, 0)
269
+ * "3px" → Thickness: 3, Color: default (no color set)
270
+
271
+ ]]
272
+ local function buildStroke(border, isTextStroke)
273
+ if isTextStroke == nil then
274
+ isTextStroke = false
275
+ end
276
+ local hasSpecifyingWord = false
277
+ local specifyingWords = {
278
+ ["solid"] = true,
279
+ ["dashed"] = true,
280
+ ["dotted"] = true,
281
+ ["double"] = true,
282
+ ["groove"] = true,
283
+ ["ridge"] = true,
284
+ ["inset"] = true,
285
+ ["outset"] = true,
286
+ }
287
+ if not isTextStroke then
288
+ for word in string.gmatch(border, "%a+") do
289
+ local _arg0 = string.lower(word)
290
+ if specifyingWords[_arg0] ~= nil then
291
+ hasSpecifyingWord = true
292
+ break
293
+ end
294
+ end
295
+ if not hasSpecifyingWord then
296
+ return nil
297
+ end
298
+ end
299
+ local strokeProps = {}
300
+ -- Extract thickness: find first space-separated token (e.g., "1px", "2")
301
+ local thicknessStr, rest = string.match(border, "^(%S+)%s*(.*)$")
302
+ if thicknessStr ~= nil then
303
+ local dim = parseDimension(thicknessStr)
304
+ if dim ~= nil then
305
+ strokeProps.Thickness = dim.Offset
306
+ end
307
+ end
308
+ -- After thickness, optionally skip CSS border-style keyword (solid, dashed, etc.)
309
+ -- then treat everything remaining as the color string.
310
+ if rest ~= nil and rest ~= "" then
311
+ local remaining = rest
312
+ -- Try to strip a leading style keyword (solid, dashed, dotted, double, groove, ridge, inset, outset, none)
313
+ local afterStyle = string.match(remaining, "^%s*%a+%s+(.+)$")
314
+ local colorStr = if afterStyle ~= nil then afterStyle else remaining
315
+ local parsed = parseColor(colorStr)
316
+ strokeProps.Color = parsed.color
317
+ end
318
+ strokeProps.ApplyStrokeMode = if isTextStroke then Enum.ApplyStrokeMode.Contextual else Enum.ApplyStrokeMode.Border
319
+ strokeProps.key = if isTextStroke then "uitextstroke" else "uistroke"
320
+ return React.createElement("uistroke", strokeProps)
321
+ end
322
+ --[[
323
+ *
324
+ * Computes Roblox Position (UDim2) and AnchorPoint (Vector2) from CSS
325
+ * absolute positioning properties.
326
+ *
327
+ * CSS → Roblox mapping:
328
+ * left: "10px" → Position.X = UDim(0, 10), AnchorPoint.X = 0
329
+ * right: "10px" → Position.X = UDim(1, -10), AnchorPoint.X = 1
330
+ * top: "50%" → Position.Y = UDim(0.5, 0), AnchorPoint.Y = 0
331
+ * bottom: "20px" → Position.Y = UDim(1, -20), AnchorPoint.Y = 1
332
+ *
333
+ * Priority: left overrides right, top overrides bottom (matching CSS spec).
334
+ * Returns props (not a React element) since positioning modifies the host
335
+ * instance rather than injecting a child constraint.
336
+
337
+ ]]
338
+ local function buildAbsolutePosition(style)
339
+ local anchorX = 0
340
+ local anchorY = 0
341
+ local posX = UDim.new(0, 0)
342
+ local posY = UDim.new(0, 0)
343
+ -- Horizontal: left takes priority over right
344
+ if style.left ~= nil then
345
+ posX = parseDimension(style.left) or UDim.new(0, 0)
346
+ anchorX = 0
347
+ elseif style.right ~= nil then
348
+ local dim = parseDimension(style.right) or UDim.new(0, 0)
349
+ posX = UDim.new(1 - dim.Scale, -dim.Offset)
350
+ anchorX = 1
351
+ end
352
+ -- Vertical: top takes priority over bottom
353
+ if style.top ~= nil then
354
+ posY = parseDimension(style.top) or UDim.new(0, 0)
355
+ anchorY = 0
356
+ elseif style.bottom ~= nil then
357
+ local dim = parseDimension(style.bottom) or UDim.new(0, 0)
358
+ posY = UDim.new(1 - dim.Scale, -dim.Offset)
359
+ anchorY = 1
360
+ end
361
+ return {
362
+ Position = UDim2.new(posX, posY),
363
+ AnchorPoint = Vector2.new(anchorX, anchorY),
364
+ }
365
+ end
366
+ --[[
367
+ *
368
+ * Computes Roblox AnchorPoint (Vector2) from CSS transform-origin.
369
+ *
370
+ * CSS → Roblox mapping:
371
+ * "center" → Vector2(0.5, 0.5)
372
+ * "top left" → Vector2(0, 0)
373
+ * "100% 50%" → Vector2(1, 0.5)
374
+
375
+ ]]
376
+ local function parseTransformOrigin(origin)
377
+ local _exp = string.split(origin, " ")
378
+ -- ▼ ReadonlyArray.filter ▼
379
+ local _newValue = {}
380
+ local _callback = function(p)
381
+ return p ~= ""
382
+ end
383
+ local _length = 0
384
+ for _k, _v in _exp do
385
+ if _callback(_v, _k - 1, _exp) == true then
386
+ _length += 1
387
+ _newValue[_length] = _v
388
+ end
389
+ end
390
+ -- ▲ ReadonlyArray.filter ▲
391
+ local parts = _newValue
392
+ local x = 0.5
393
+ local y = 0.5
394
+ local parseVal = function(val)
395
+ if val == "left" then
396
+ return 0
397
+ end
398
+ if val == "right" then
399
+ return 1
400
+ end
401
+ if val == "top" then
402
+ return 0
403
+ end
404
+ if val == "bottom" then
405
+ return 1
406
+ end
407
+ if val == "center" then
408
+ return 0.5
409
+ end
410
+ local numStr = string.match(val, "^(%-?%d+%.?%d*)%%?$")
411
+ if numStr ~= nil then
412
+ local num = tonumber(numStr)
413
+ if num ~= nil then
414
+ local hasPercent = string.match(val, "%%$")
415
+ if hasPercent ~= nil then
416
+ return num / 100
417
+ end
418
+ return 0
419
+ end
420
+ end
421
+ return 0.5
422
+ end
423
+ if #parts == 2 then
424
+ local isYFirst = parts[1] == "top" or parts[1] == "bottom"
425
+ local isXSecond = parts[2] == "left" or parts[2] == "right"
426
+ if isYFirst or isXSecond then
427
+ y = parseVal(parts[1])
428
+ x = parseVal(parts[2])
429
+ else
430
+ x = parseVal(parts[1])
431
+ y = parseVal(parts[2])
432
+ end
433
+ elseif #parts == 1 then
434
+ local part = parts[1]
435
+ if part == "top" or part == "bottom" then
436
+ x = 0.5
437
+ y = parseVal(part)
438
+ else
439
+ x = parseVal(part)
440
+ y = 0.5
441
+ end
442
+ end
443
+ return Vector2.new(x, y)
444
+ end
445
+ --[[
446
+ *
447
+ * Checks if any constraint property (minWidth, maxWidth, minHeight, maxHeight)
448
+ * contains a percentage value.
449
+
450
+ ]]
451
+ local function hasPercentageScale(style)
452
+ local _condition = style.minWidth ~= nil
453
+ if _condition then
454
+ local _result = parseDimension(style.minWidth)
455
+ if _result ~= nil then
456
+ _result = _result.Scale
457
+ end
458
+ local _condition_1 = _result
459
+ if _condition_1 == nil then
460
+ _condition_1 = 0
461
+ end
462
+ _condition = _condition_1 > 0
463
+ end
464
+ if _condition then
465
+ return true
466
+ end
467
+ local _condition_1 = style.maxWidth ~= nil
468
+ if _condition_1 then
469
+ local _result = parseDimension(style.maxWidth)
470
+ if _result ~= nil then
471
+ _result = _result.Scale
472
+ end
473
+ local _condition_2 = _result
474
+ if _condition_2 == nil then
475
+ _condition_2 = 0
476
+ end
477
+ _condition_1 = _condition_2 > 0
478
+ end
479
+ if _condition_1 then
480
+ return true
481
+ end
482
+ local _condition_2 = style.minHeight ~= nil
483
+ if _condition_2 then
484
+ local _result = parseDimension(style.minHeight)
485
+ if _result ~= nil then
486
+ _result = _result.Scale
487
+ end
488
+ local _condition_3 = _result
489
+ if _condition_3 == nil then
490
+ _condition_3 = 0
491
+ end
492
+ _condition_2 = _condition_3 > 0
493
+ end
494
+ if _condition_2 then
495
+ return true
496
+ end
497
+ local _condition_3 = style.maxHeight ~= nil
498
+ if _condition_3 then
499
+ local _result = parseDimension(style.maxHeight)
500
+ if _result ~= nil then
501
+ _result = _result.Scale
502
+ end
503
+ local _condition_4 = _result
504
+ if _condition_4 == nil then
505
+ _condition_4 = 0
506
+ end
507
+ _condition_3 = _condition_4 > 0
508
+ end
509
+ if _condition_3 then
510
+ return true
511
+ end
512
+ return false
513
+ end
514
+ --[[
515
+ *
516
+ * Translates a CSSProperties object into Roblox-compatible props and child elements.
517
+ *
518
+ * This is the primary entry point for the CSS → Roblox translation layer.
519
+ * Wrapper components (Box, Text, etc.) call this function with their `style` prop
520
+ * and spread the resulting `props` onto the host instance while injecting `children`
521
+ * as UI constraint siblings.
522
+ *
523
+ * @param style - A CSSProperties object from the component's style prop.
524
+ * @returns A branded WebStyleResult containing:
525
+ * - `props`: Record of Roblox instance properties (Size, BackgroundColor3, Position, etc.)
526
+ * - `children`: Array of React elements for UI constraints (<uicorner>, <uipadding>, etc.)
527
+ *
528
+ * @example
529
+ * const result = webStyle({ width: "50%", backgroundColor: "#333", borderRadius: 8 });
530
+ * // result.props → { Size: UDim2(...), BackgroundColor3: Color3(...) }
531
+ * // result.children → [ <uicorner CornerRadius={UDim(0, 8)} /> ]
532
+
533
+ ]]
534
+ local function webStyle(style)
535
+ local props = {}
536
+ local children = {}
537
+ -- Implementation order:
538
+ -- 1. width / height → Size (UDim2)
539
+ if style.width ~= nil or style.height ~= nil then
540
+ local widthIsAuto = style.width == "auto"
541
+ local heightIsAuto = style.height == "auto"
542
+ local _result
543
+ if widthIsAuto then
544
+ _result = UDim.new(1, 0)
545
+ else
546
+ local _condition = style.width
547
+ if _condition == nil then
548
+ _condition = "100%"
549
+ end
550
+ local _condition_1 = parseDimension(_condition)
551
+ if _condition_1 == nil then
552
+ _condition_1 = UDim.new(1, 0)
553
+ end
554
+ _result = _condition_1
555
+ end
556
+ local _result_1
557
+ if heightIsAuto then
558
+ _result_1 = UDim.new(1, 0)
559
+ else
560
+ local _condition = style.height
561
+ if _condition == nil then
562
+ _condition = "100%"
563
+ end
564
+ local _condition_1 = parseDimension(_condition)
565
+ if _condition_1 == nil then
566
+ _condition_1 = UDim.new(1, 0)
567
+ end
568
+ _result_1 = _condition_1
569
+ end
570
+ props.Size = UDim2.new(_result, _result_1)
571
+ -- Set AutomaticSize for auto dimensions (only if autoSize wasn't explicitly set)
572
+ if (widthIsAuto or heightIsAuto) and style.autoSize == nil then
573
+ if widthIsAuto and heightIsAuto then
574
+ props.AutomaticSize = Enum.AutomaticSize.XY
575
+ elseif widthIsAuto then
576
+ props.AutomaticSize = Enum.AutomaticSize.X
577
+ else
578
+ props.AutomaticSize = Enum.AutomaticSize.Y
579
+ end
580
+ end
581
+ end
582
+ -- 2. backgroundColor → BackgroundColor3 + BackgroundTransparency
583
+ if style.backgroundColor ~= nil then
584
+ local parsed = parseColor(style.backgroundColor)
585
+ props.BackgroundColor3 = parsed.color
586
+ props.BackgroundTransparency = parsed.transparency
587
+ end
588
+ -- 2.5. background: "linear-gradient(...)" → inject <uigradient>
589
+ if style.background ~= nil and isGradientString(style.background) then
590
+ -- Extract pixel dimensions from style for corner keyword resolution
591
+ local elemWidth
592
+ local elemHeight
593
+ local _condition = style.width ~= nil
594
+ if _condition then
595
+ local _width = style.width
596
+ _condition = not (type(_width) == "string")
597
+ end
598
+ if _condition then
599
+ elemWidth = style.width
600
+ else
601
+ local _width = style.width
602
+ if type(_width) == "string" then
603
+ local wDim = parseDimension(style.width)
604
+ if wDim ~= nil and wDim.Scale == 0 then
605
+ elemWidth = wDim.Offset
606
+ end
607
+ end
608
+ end
609
+ local _condition_1 = style.height ~= nil
610
+ if _condition_1 then
611
+ local _height = style.height
612
+ _condition_1 = not (type(_height) == "string")
613
+ end
614
+ if _condition_1 then
615
+ elemHeight = style.height
616
+ else
617
+ local _height = style.height
618
+ if type(_height) == "string" then
619
+ local hDim = parseDimension(style.height)
620
+ if hDim ~= nil and hDim.Scale == 0 then
621
+ elemHeight = hDim.Offset
622
+ end
623
+ end
624
+ end
625
+ local gradient = parseGradient(style.background, elemWidth, elemHeight)
626
+ if gradient then
627
+ -- UIGradient multiplies with BackgroundColor3, so default to white
628
+ -- (the multiplicative identity) for CSS parity unless the user
629
+ -- explicitly set backgroundColor.
630
+ if style.backgroundColor == nil then
631
+ props.BackgroundColor3 = Color3.new(1, 1, 1)
632
+ end
633
+ -- CSS gradients are opaque by default — per-pixel alpha from rgba()
634
+ -- colors is already handled by the UIGradient's Transparency sequence.
635
+ props.BackgroundTransparency = 0
636
+ local gradientProps = {
637
+ key = "uigradient",
638
+ Color = gradient.colorSequence,
639
+ Rotation = gradient.rotation,
640
+ }
641
+ if gradient.transparencySequence ~= nil then
642
+ gradientProps.Transparency = gradient.transparencySequence
643
+ end
644
+ local _arg0 = React.createElement("uigradient", gradientProps)
645
+ table.insert(children, _arg0)
646
+ end
647
+ end
648
+ local hasGradient = style.background ~= nil and isGradientString(style.background)
649
+ -- 3. opacity → BackgroundTransparency (INVERTED)
650
+ -- if both backgroundColor transparent and opacity specified, opacity takes precedence
651
+ if style.opacity ~= nil and not hasGradient then
652
+ props.BackgroundTransparency = 1 - style.opacity
653
+ end
654
+ -- 4. borderRadius → inject <uicorner>
655
+ if style.borderRadius ~= nil then
656
+ local radius = parseDimension(style.borderRadius)
657
+ local _arg0 = React.createElement("uicorner", {
658
+ key = "uicorner",
659
+ CornerRadius = radius,
660
+ })
661
+ table.insert(children, _arg0)
662
+ end
663
+ -- 5. padding → inject <uipadding>
664
+ if style.padding ~= nil or style.paddingTop ~= nil or style.paddingRight ~= nil or style.paddingBottom ~= nil or style.paddingLeft ~= nil then
665
+ -- Start from shorthand, then let individual sides override
666
+ local base = if style.padding ~= nil then parsePadding(style.padding) else parsePadding(0)
667
+ local _arg0 = React.createElement("uipadding", {
668
+ key = "uipadding",
669
+ PaddingTop = if style.paddingTop ~= nil then parseDimension(style.paddingTop) else base.top,
670
+ PaddingRight = if style.paddingRight ~= nil then parseDimension(style.paddingRight) else base.right,
671
+ PaddingBottom = if style.paddingBottom ~= nil then parseDimension(style.paddingBottom) else base.bottom,
672
+ PaddingLeft = if style.paddingLeft ~= nil then parseDimension(style.paddingLeft) else base.left,
673
+ })
674
+ table.insert(children, _arg0)
675
+ end
676
+ -- 6. display: "flex" | "grid" | "none" → inject <uilistlayout> / <uigridlayout> or set Visible
677
+ if style.display == "flex" then
678
+ local _arg0 = buildListLayout(style)
679
+ table.insert(children, _arg0)
680
+ elseif style.display == "grid" then
681
+ local _arg0 = buildGridLayout(style)
682
+ table.insert(children, _arg0)
683
+ elseif style.display == "none" then
684
+ props.Visible = false
685
+ end
686
+ -- visibility → Visible (convenience alias, coexists with display: none)
687
+ if style.visibility ~= nil then
688
+ props.Visible = style.visibility ~= "hidden"
689
+ end
690
+ -- 7. border → inject <uistroke>
691
+ if style.border ~= nil then
692
+ local stroke = buildStroke(style.border)
693
+ if stroke ~= nil then
694
+ table.insert(children, stroke)
695
+ end
696
+ end
697
+ -- textStroke → inject <uistroke> with Contextual mode
698
+ if style.textStroke ~= nil then
699
+ local stroke = buildStroke(style.textStroke, true)
700
+ if stroke ~= nil then
701
+ table.insert(children, stroke)
702
+ end
703
+ end
704
+ -- backgroundImage → inject <imagelabel> underlay
705
+ if style.backgroundImage ~= nil then
706
+ local url = string.match(style.backgroundImage, [[url%([\"']?(.-)[\"']?%)]])
707
+ if url ~= nil then
708
+ local cleanUrl = url
709
+ local _arg0 = React.createElement("imagelabel", {
710
+ key = "uibackgroundimage",
711
+ Image = cleanUrl,
712
+ ZIndex = -1,
713
+ Size = UDim2.fromScale(1, 1),
714
+ AnchorPoint = Vector2.new(0.5, 0.5),
715
+ Position = UDim2.fromScale(0.5, 0.5),
716
+ BackgroundTransparency = 1,
717
+ })
718
+ table.insert(children, _arg0)
719
+ end
720
+ end
721
+ -- 8. aspectRatio → inject <uiaspectratioconstraint>
722
+ if style.aspectRatio ~= nil then
723
+ local _arg0 = React.createElement("uiaspectratioconstraint", {
724
+ key = "uiaspectratioconstraint",
725
+ AspectRatio = style.aspectRatio,
726
+ })
727
+ table.insert(children, _arg0)
728
+ end
729
+ -- 9. position: "absolute" → Position + AnchorPoint
730
+ if style.position == "absolute" then
731
+ local abs = buildAbsolutePosition(style)
732
+ props.Position = abs.Position
733
+ props.AnchorPoint = abs.AnchorPoint
734
+ end
735
+ -- transformOrigin → overrides AnchorPoint
736
+ if style.transformOrigin ~= nil then
737
+ props.AnchorPoint = parseTransformOrigin(style.transformOrigin)
738
+ end
739
+ -- 10. zIndex → ZIndex
740
+ if style.zIndex ~= nil then
741
+ props.ZIndex = style.zIndex
742
+ end
743
+ if style.layoutOrder ~= nil then
744
+ props.LayoutOrder = style.layoutOrder
745
+ end
746
+ if style.rotation ~= nil then
747
+ props.Rotation = style.rotation
748
+ end
749
+ -- autoSize (standalone) — explicit autoSize takes precedence over width/height "auto"
750
+ if style.autoSize ~= nil then
751
+ props.AutomaticSize = AUTO_SIZE_MAP[style.autoSize] or Enum.AutomaticSize.None
752
+ end
753
+ -- 11. Size Constraints → inject <uisizeconstraint>
754
+ if not hasPercentageScale(style) and (style.minWidth ~= nil or style.maxWidth ~= nil or style.minHeight ~= nil or style.maxHeight ~= nil) then
755
+ local minW = if style.minWidth ~= nil then (parseDimension(style.minWidth) or UDim.new(0, 0)).Offset else 0
756
+ local minH = if style.minHeight ~= nil then (parseDimension(style.minHeight) or UDim.new(0, 0)).Offset else 0
757
+ local maxW = if style.maxWidth ~= nil then (parseDimension(style.maxWidth) or UDim.new(0, 0)).Offset else math.huge
758
+ local maxH = if style.maxHeight ~= nil then (parseDimension(style.maxHeight) or UDim.new(0, 0)).Offset else math.huge
759
+ local _arg0 = React.createElement("uisizeconstraint", {
760
+ key = "uisizeconstraint",
761
+ MinSize = Vector2.new(minW, minH),
762
+ MaxSize = Vector2.new(maxW, maxH),
763
+ })
764
+ table.insert(children, _arg0)
765
+ end
766
+ -- 12. Typography (color, fontSize, textAlign, whiteSpace)
767
+ if style.color ~= nil then
768
+ local parsedColor = parseColor(style.color)
769
+ props.TextColor3 = parsedColor.color
770
+ if parsedColor.transparent then
771
+ props.TextTransparency = 1
772
+ end
773
+ end
774
+ if style.fontSize ~= nil then
775
+ props.TextSize = style.fontSize
776
+ end
777
+ if style.textAlign ~= nil then
778
+ if style.textAlign == "left" then
779
+ props.TextXAlignment = Enum.TextXAlignment.Left
780
+ elseif style.textAlign == "center" then
781
+ props.TextXAlignment = Enum.TextXAlignment.Center
782
+ elseif style.textAlign == "right" then
783
+ props.TextXAlignment = Enum.TextXAlignment.Right
784
+ end
785
+ end
786
+ if style.textVerticalAlign ~= nil then
787
+ if style.textVerticalAlign == "top" then
788
+ props.TextYAlignment = Enum.TextYAlignment.Top
789
+ elseif style.textVerticalAlign == "center" then
790
+ props.TextYAlignment = Enum.TextYAlignment.Center
791
+ elseif style.textVerticalAlign == "bottom" then
792
+ props.TextYAlignment = Enum.TextYAlignment.Bottom
793
+ end
794
+ end
795
+ if style.whiteSpace ~= nil then
796
+ if style.whiteSpace == "normal" or style.whiteSpace == "pre-wrap" or style.whiteSpace == "pre-line" then
797
+ props.TextWrapped = true
798
+ elseif style.whiteSpace == "nowrap" then
799
+ props.TextWrapped = false
800
+ end
801
+ end
802
+ if style.wordBreak ~= nil then
803
+ if style.wordBreak == "normal" or style.wordBreak == "break-word" or style.wordBreak == "break-all" then
804
+ props.TextWrapped = true
805
+ elseif style.wordBreak == "keep-all" then
806
+ props.TextWrapped = false
807
+ end
808
+ end
809
+ if style.lineHeight ~= nil then
810
+ props.LineHeight = style.lineHeight
811
+ end
812
+ if style.textOverflow == "ellipsis" then
813
+ props.TextTruncate = Enum.TextTruncate.AtEnd
814
+ end
815
+ if style.richText == true then
816
+ props.RichText = true
817
+ end
818
+ if style.fontFamily ~= nil or style.fontWeight ~= nil or style.fontStyle ~= nil then
819
+ local _condition = style.fontFamily
820
+ if _condition == nil then
821
+ _condition = "BuilderSans"
822
+ end
823
+ local familyStr = _condition
824
+ local _condition_1 = FONT_FAMILY_MAP[familyStr]
825
+ if _condition_1 == nil then
826
+ _condition_1 = DEFAULT_FONT_FAMILY
827
+ end
828
+ local familyUri = _condition_1
829
+ local weightStr = if style.fontWeight ~= nil then tostring(style.fontWeight) else "normal"
830
+ local weightEnum = FONT_WEIGHT_MAP[weightStr] or Enum.FontWeight.Regular
831
+ local fontStyleEnum = if style.fontStyle == "italic" then Enum.FontStyle.Italic else Enum.FontStyle.Normal
832
+ props.FontFace = Font.new(familyUri, weightEnum, fontStyleEnum)
833
+ end
834
+ if style.textDecoration ~= nil and style.textDecoration ~= "none" then
835
+ props.RichText = true
836
+ props._textDecoration = style.textDecoration
837
+ end
838
+ if style.textTransform ~= nil and style.textTransform ~= "none" then
839
+ props._textTransform = style.textTransform
840
+ end
841
+ -- 13. overflow → ClipsDescendants
842
+ if style.overflow ~= nil then
843
+ if style.overflow == "hidden" then
844
+ props.ClipsDescendants = true
845
+ elseif style.overflow == "visible" then
846
+ props.ClipsDescendants = false
847
+ end
848
+ end
849
+ if style.objectFit ~= nil then
850
+ if style.objectFit == "cover" then
851
+ props.ScaleType = Enum.ScaleType.Crop
852
+ elseif style.objectFit == "contain" then
853
+ props.ScaleType = Enum.ScaleType.Fit
854
+ elseif style.objectFit == "fill" then
855
+ props.ScaleType = Enum.ScaleType.Stretch
856
+ end
857
+ end
858
+ -- 14. pointerEvents → Interactable / Active
859
+ if style.pointerEvents == "none" then
860
+ props.Interactable = false
861
+ props.Active = false
862
+ end
863
+ -- userSelect → TextSelectable
864
+ if style.userSelect ~= nil then
865
+ if style.userSelect == "text" or style.userSelect == "auto" then
866
+ props.TextSelectable = true
867
+ elseif style.userSelect == "none" then
868
+ props.TextSelectable = false
869
+ end
870
+ end
871
+ -- 15. boxShadow → inject <imagelabel>
872
+ if style.boxShadow ~= nil and style.boxShadow ~= "none" then
873
+ local shadowSize = SHADOW_SIZE_MAP[style.boxShadow] or UDim2.new(1, 20, 1, 20)
874
+ local _condition = SHADOW_OPACITY_MAP[style.boxShadow]
875
+ if _condition == nil then
876
+ _condition = 0.5
877
+ end
878
+ local shadowTransparency = _condition
879
+ local _condition_1 = SHADOW_OFFSET_MAP[style.boxShadow]
880
+ if _condition_1 == nil then
881
+ _condition_1 = 6
882
+ end
883
+ local shadowOffset = _condition_1
884
+ local _condition_2 = SHADOW_SLICE_SCALE_MAP[style.boxShadow]
885
+ if _condition_2 == nil then
886
+ _condition_2 = 0.4
887
+ end
888
+ local sliceScale = _condition_2
889
+ local _arg0 = React.createElement("imagelabel", {
890
+ key = "box-shadow",
891
+ Image = SHADOW_ASSET_ID,
892
+ ScaleType = Enum.ScaleType.Slice,
893
+ SliceCenter = SHADOW_SLICE_CENTER,
894
+ SliceScale = sliceScale,
895
+ BackgroundTransparency = 1,
896
+ ImageColor3 = Color3.new(0, 0, 0),
897
+ ImageTransparency = shadowTransparency,
898
+ ZIndex = -1,
899
+ AnchorPoint = Vector2.new(0.5, 0.5),
900
+ Position = UDim2.new(0.5, 0, 0.5, shadowOffset),
901
+ Size = shadowSize,
902
+ })
903
+ table.insert(children, _arg0)
904
+ end
905
+ -- 16. flexGrow / flexShrink / alignSelf → inject <uiflexitem>
906
+ local _condition = style.flexGrow
907
+ if _condition == nil then
908
+ _condition = 0
909
+ end
910
+ local grow = _condition
911
+ local _condition_1 = style.flexShrink
912
+ if _condition_1 == nil then
913
+ _condition_1 = 0
914
+ end
915
+ local shrink = _condition_1
916
+ if style.flex ~= nil then
917
+ if style.flex == "auto" then
918
+ grow = 1
919
+ shrink = 1
920
+ elseif style.flex == "none" then
921
+ grow = 0
922
+ shrink = 0
923
+ else
924
+ local _flex = style.flex
925
+ if type(_flex) == "number" then
926
+ grow = style.flex
927
+ shrink = style.flex
928
+ else
929
+ local _flex_1 = style.flex
930
+ if type(_flex_1) == "string" then
931
+ local parsed = tonumber(style.flex)
932
+ if parsed ~= nil then
933
+ grow = parsed
934
+ shrink = parsed
935
+ end
936
+ end
937
+ end
938
+ end
939
+ end
940
+ local hasFlexItem = grow > 0 or shrink > 0 or (style.alignSelf ~= nil and style.alignSelf ~= "auto")
941
+ if hasFlexItem then
942
+ if style.display == "grid" then
943
+ warn("[webStyle] flexGrow/flexShrink/alignSelf have no effect inside display: 'grid'. UIFlexItem only works with UIListLayout (display: 'flex').")
944
+ end
945
+ local flexProps = {
946
+ key = "uiflexitem",
947
+ }
948
+ if grow > 0 or shrink > 0 then
949
+ flexProps.FlexMode = Enum.UIFlexMode.Custom
950
+ flexProps.GrowRatio = grow
951
+ flexProps.ShrinkRatio = shrink
952
+ end
953
+ if style.alignSelf ~= nil and style.alignSelf ~= "auto" then
954
+ if style.alignSelf == "flex-start" then
955
+ flexProps.ItemLineAlignment = Enum.ItemLineAlignment.Start
956
+ elseif style.alignSelf == "flex-end" then
957
+ flexProps.ItemLineAlignment = Enum.ItemLineAlignment.End
958
+ elseif style.alignSelf == "center" then
959
+ flexProps.ItemLineAlignment = Enum.ItemLineAlignment.Center
960
+ elseif style.alignSelf == "stretch" then
961
+ flexProps.ItemLineAlignment = Enum.ItemLineAlignment.Stretch
962
+ end
963
+ end
964
+ local _arg0 = React.createElement("uiflexitem", flexProps)
965
+ table.insert(children, _arg0)
966
+ end
967
+ return makeWebStyleResult(props, children)
968
+ end
969
+ return {
970
+ webStyle = webStyle,
971
+ SHADOW_ASSET_ID = SHADOW_ASSET_ID,
972
+ SHADOW_SLICE_CENTER = SHADOW_SLICE_CENTER,
973
+ }