onejs-react 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.
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Components
2
+ export {
3
+ View,
4
+ Label,
5
+ Button,
6
+ TextField,
7
+ Toggle,
8
+ Slider,
9
+ ScrollView,
10
+ Image,
11
+ ListView,
12
+ } from './components';
13
+
14
+ // Renderer
15
+ export { render, unmount } from './renderer';
16
+
17
+ // Responsive Design
18
+ export {
19
+ ScreenProvider,
20
+ useBreakpoint,
21
+ useScreenSize,
22
+ useResponsive,
23
+ useMediaQuery,
24
+ BREAKPOINTS,
25
+ } from './screen';
26
+
27
+ export type {
28
+ ScreenContextValue,
29
+ ScreenProviderProps,
30
+ BreakpointName,
31
+ } from './screen';
32
+
33
+ // Types
34
+ export type {
35
+ ViewStyle,
36
+ PointerEventData,
37
+ KeyEventData,
38
+ ChangeEventData,
39
+ PointerEventHandler,
40
+ KeyEventHandler,
41
+ ChangeEventHandler,
42
+ FocusEventHandler,
43
+ BaseProps,
44
+ ViewProps,
45
+ LabelProps,
46
+ ButtonProps,
47
+ TextFieldProps,
48
+ ToggleProps,
49
+ SliderProps,
50
+ ScrollViewProps,
51
+ ImageProps,
52
+ VisualElement,
53
+ ListViewProps,
54
+ } from './types';
@@ -0,0 +1,73 @@
1
+ import Reconciler from 'react-reconciler';
2
+ import type { ReactNode } from 'react';
3
+ import { hostConfig, type Container } from './host-config';
4
+
5
+ declare const console: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
6
+
7
+ // Create the reconciler
8
+ const reconciler = Reconciler(hostConfig);
9
+
10
+ // Inject into dev tools (helps with proper initialization)
11
+ reconciler.injectIntoDevTools({
12
+ bundleType: 1, // 0 for prod, 1 for dev
13
+ version: '19.0.0',
14
+ rendererPackageName: 'onejs-react',
15
+ });
16
+
17
+ // Track roots for hot reload / re-render
18
+ const roots = new Map<Container, ReturnType<typeof reconciler.createContainer>>();
19
+
20
+ export function render(element: ReactNode, container: Container): void {
21
+ console.log('[onejs-react] render() called');
22
+ let root = roots.get(container);
23
+
24
+ if (!root) {
25
+ console.log('[onejs-react] creating new container');
26
+ root = reconciler.createContainer(
27
+ container,
28
+ 0, // LegacyRoot (0) vs ConcurrentRoot (1)
29
+ null, // hydrationCallbacks
30
+ false, // isStrictMode
31
+ null, // concurrentUpdatesByDefaultOverride
32
+ '', // identifierPrefix
33
+ (error: Error) => console.error('[OneJS React] Recoverable error:', error),
34
+ null // transitionCallbacks
35
+ );
36
+ roots.set(container, root);
37
+ }
38
+
39
+ console.log('[onejs-react] calling updateContainer');
40
+ reconciler.updateContainer(element, root, null, () => {
41
+ console.log('[onejs-react] updateContainer callback fired');
42
+ });
43
+
44
+ // Try to flush synchronous work
45
+ console.log('[onejs-react] attempting to flush sync work');
46
+ try {
47
+ // flushSync may be exported differently - try flushSyncWork first
48
+ if (typeof (reconciler as any).flushSyncWork === 'function') {
49
+ (reconciler as any).flushSyncWork();
50
+ console.log('[onejs-react] flushSyncWork completed');
51
+ } else if (typeof (reconciler as any).flushSync === 'function') {
52
+ (reconciler as any).flushSync(() => {});
53
+ console.log('[onejs-react] flushSync completed');
54
+ } else {
55
+ console.log('[onejs-react] no sync flush method available, relying on microtasks');
56
+ }
57
+ } catch (e) {
58
+ console.log('[onejs-react] sync flush failed, relying on microtasks:', e);
59
+ }
60
+ }
61
+
62
+ export function unmount(container: Container): void {
63
+ const root = roots.get(container);
64
+ if (root) {
65
+ reconciler.updateContainer(null, root, null, () => {});
66
+ roots.delete(container);
67
+ }
68
+ }
69
+
70
+ // Export for testing/debugging
71
+ export function getRoot(container: Container) {
72
+ return roots.get(container);
73
+ }
package/src/screen.tsx ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Responsive design system for OneJS
3
+ *
4
+ * Provides React context and hooks for responsive breakpoints.
5
+ * Uses event-driven updates via GeometryChangedEvent (not polling).
6
+ *
7
+ * Mobile-first: At 1400px width, sm/md/lg/xl are all active (not just xl).
8
+ */
9
+
10
+ import { createContext, useContext, useState, useEffect, ReactNode } from "react"
11
+
12
+ // Globals from QuickJS environment
13
+ declare const __root: {
14
+ __csHandle: number
15
+ resolvedStyle: {
16
+ width: number
17
+ height: number
18
+ }
19
+ AddToClassList: (className: string) => void
20
+ RemoveFromClassList: (className: string) => void
21
+ }
22
+
23
+ declare const __eventAPI: {
24
+ addEventListener: (element: unknown, eventType: string, callback: Function) => void
25
+ removeEventListener: (element: unknown, eventType: string, callback: Function) => void
26
+ }
27
+
28
+ // Breakpoint definitions (Tailwind v3 defaults)
29
+ export const BREAKPOINTS = {
30
+ sm: 640,
31
+ md: 768,
32
+ lg: 1024,
33
+ xl: 1280,
34
+ "2xl": 1536,
35
+ } as const
36
+
37
+ export type BreakpointName = keyof typeof BREAKPOINTS | "base"
38
+
39
+ export interface ScreenContextValue {
40
+ /** Current viewport width in pixels */
41
+ width: number
42
+ /** Current viewport height in pixels */
43
+ height: number
44
+ /** Current breakpoint name (highest matching) */
45
+ breakpoint: BreakpointName
46
+ /** True if viewport >= 640px */
47
+ isSm: boolean
48
+ /** True if viewport >= 768px */
49
+ isMd: boolean
50
+ /** True if viewport >= 1024px */
51
+ isLg: boolean
52
+ /** True if viewport >= 1280px */
53
+ isXl: boolean
54
+ /** True if viewport >= 1536px */
55
+ is2xl: boolean
56
+ }
57
+
58
+ const ScreenContext = createContext<ScreenContextValue | null>(null)
59
+
60
+ /**
61
+ * Calculate breakpoint state from viewport width
62
+ */
63
+ function calculateBreakpoints(width: number, height: number): ScreenContextValue {
64
+ const isSm = width >= BREAKPOINTS.sm
65
+ const isMd = width >= BREAKPOINTS.md
66
+ const isLg = width >= BREAKPOINTS.lg
67
+ const isXl = width >= BREAKPOINTS.xl
68
+ const is2xl = width >= BREAKPOINTS["2xl"]
69
+
70
+ // Determine current breakpoint (highest matching)
71
+ let breakpoint: BreakpointName = "base"
72
+ if (is2xl) breakpoint = "2xl"
73
+ else if (isXl) breakpoint = "xl"
74
+ else if (isLg) breakpoint = "lg"
75
+ else if (isMd) breakpoint = "md"
76
+ else if (isSm) breakpoint = "sm"
77
+
78
+ return { width, height, breakpoint, isSm, isMd, isLg, isXl, is2xl }
79
+ }
80
+
81
+ /**
82
+ * Apply breakpoint classes to root element (mobile-first cascading)
83
+ */
84
+ function applyBreakpointClasses(screen: ScreenContextValue) {
85
+ // Remove all breakpoint classes first
86
+ __root.RemoveFromClassList("sm")
87
+ __root.RemoveFromClassList("md")
88
+ __root.RemoveFromClassList("lg")
89
+ __root.RemoveFromClassList("xl")
90
+ __root.RemoveFromClassList("2xl")
91
+
92
+ // Mobile-first: apply ALL matching breakpoints, not just highest
93
+ if (screen.isSm) __root.AddToClassList("sm")
94
+ if (screen.isMd) __root.AddToClassList("md")
95
+ if (screen.isLg) __root.AddToClassList("lg")
96
+ if (screen.isXl) __root.AddToClassList("xl")
97
+ if (screen.is2xl) __root.AddToClassList("2xl")
98
+ }
99
+
100
+ export interface ScreenProviderProps {
101
+ children: ReactNode
102
+ /** Custom breakpoints (optional) */
103
+ breakpoints?: Partial<typeof BREAKPOINTS>
104
+ }
105
+
106
+ /**
107
+ * Provider component for responsive screen context.
108
+ *
109
+ * Wrap your app with this to enable responsive hooks.
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * render(
114
+ * <ScreenProvider>
115
+ * <App />
116
+ * </ScreenProvider>,
117
+ * __root
118
+ * )
119
+ * ```
120
+ */
121
+ export function ScreenProvider({ children }: ScreenProviderProps) {
122
+ // Initialize with current viewport size
123
+ const [screen, setScreen] = useState<ScreenContextValue>(() => {
124
+ const width = __root.resolvedStyle?.width || 0
125
+ const height = __root.resolvedStyle?.height || 0
126
+ return calculateBreakpoints(width, height)
127
+ })
128
+
129
+ useEffect(() => {
130
+ // Apply initial breakpoint classes
131
+ applyBreakpointClasses(screen)
132
+
133
+ // Handle viewport change events from C#
134
+ const handleViewportChange = (evt: { width: number; height: number }) => {
135
+ const newScreen = calculateBreakpoints(evt.width, evt.height)
136
+ setScreen(newScreen)
137
+ applyBreakpointClasses(newScreen)
138
+ }
139
+
140
+ // Listen for viewport changes on root element
141
+ __eventAPI.addEventListener(__root, "viewportchange", handleViewportChange)
142
+
143
+ return () => {
144
+ __eventAPI.removeEventListener(__root, "viewportchange", handleViewportChange)
145
+ }
146
+ }, [])
147
+
148
+ return (
149
+ <ScreenContext.Provider value={screen}>
150
+ {children}
151
+ </ScreenContext.Provider>
152
+ )
153
+ }
154
+
155
+ /**
156
+ * Hook to get the current breakpoint name.
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * function Component() {
161
+ * const breakpoint = useBreakpoint()
162
+ * return <Label text={`Current: ${breakpoint}`} />
163
+ * }
164
+ * ```
165
+ */
166
+ export function useBreakpoint(): BreakpointName {
167
+ const ctx = useContext(ScreenContext)
168
+ if (!ctx) {
169
+ throw new Error("useBreakpoint must be used within ScreenProvider")
170
+ }
171
+ return ctx.breakpoint
172
+ }
173
+
174
+ /**
175
+ * Hook to get the current viewport size.
176
+ *
177
+ * @example
178
+ * ```tsx
179
+ * function Component() {
180
+ * const { width, height } = useScreenSize()
181
+ * return <Label text={`${width}x${height}`} />
182
+ * }
183
+ * ```
184
+ */
185
+ export function useScreenSize(): { width: number; height: number } {
186
+ const ctx = useContext(ScreenContext)
187
+ if (!ctx) {
188
+ throw new Error("useScreenSize must be used within ScreenProvider")
189
+ }
190
+ return { width: ctx.width, height: ctx.height }
191
+ }
192
+
193
+ /**
194
+ * Hook to get all responsive state.
195
+ *
196
+ * @example
197
+ * ```tsx
198
+ * function Component() {
199
+ * const { isMd, isLg, breakpoint } = useResponsive()
200
+ * return (
201
+ * <View>
202
+ * {isLg && <Sidebar />}
203
+ * <Content />
204
+ * </View>
205
+ * )
206
+ * }
207
+ * ```
208
+ */
209
+ export function useResponsive(): ScreenContextValue {
210
+ const ctx = useContext(ScreenContext)
211
+ if (!ctx) {
212
+ throw new Error("useResponsive must be used within ScreenProvider")
213
+ }
214
+ return ctx
215
+ }
216
+
217
+ /**
218
+ * Hook to check if a specific breakpoint is active.
219
+ *
220
+ * @example
221
+ * ```tsx
222
+ * function Component() {
223
+ * const isDesktop = useMediaQuery("lg")
224
+ * return isDesktop ? <DesktopLayout /> : <MobileLayout />
225
+ * }
226
+ * ```
227
+ */
228
+ export function useMediaQuery(breakpoint: keyof typeof BREAKPOINTS): boolean {
229
+ const ctx = useContext(ScreenContext)
230
+ if (!ctx) {
231
+ throw new Error("useMediaQuery must be used within ScreenProvider")
232
+ }
233
+
234
+ switch (breakpoint) {
235
+ case "sm": return ctx.isSm
236
+ case "md": return ctx.isMd
237
+ case "lg": return ctx.isLg
238
+ case "xl": return ctx.isXl
239
+ case "2xl": return ctx.is2xl
240
+ default: return false
241
+ }
242
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Style value parsing utilities for OneJS React
3
+ *
4
+ * Converts friendly style values (numbers, strings like "100px", "#ff0000")
5
+ * into Unity UI Toolkit compatible values.
6
+ */
7
+
8
+ // Unity UIElements types accessed via CS global
9
+ declare const CS: {
10
+ UnityEngine: {
11
+ Color: new (r: number, g: number, b: number, a: number) => CSColor;
12
+ UIElements: {
13
+ Length: new (value: number, unit?: number) => CSLength;
14
+ LengthUnit: { Pixel: number; Percent: number };
15
+ StyleKeyword: { Auto: number; None: number; Initial: number };
16
+ };
17
+ };
18
+ };
19
+
20
+ interface CSColor {
21
+ r: number;
22
+ g: number;
23
+ b: number;
24
+ a: number;
25
+ }
26
+
27
+ interface CSLength {
28
+ value: number;
29
+ unit: number;
30
+ }
31
+
32
+ // Named CSS colors (common subset)
33
+ const NAMED_COLORS: Record<string, [number, number, number, number]> = {
34
+ transparent: [0, 0, 0, 0],
35
+ black: [0, 0, 0, 1],
36
+ white: [1, 1, 1, 1],
37
+ red: [1, 0, 0, 1],
38
+ green: [0, 0.502, 0, 1], // CSS green is #008000
39
+ blue: [0, 0, 1, 1],
40
+ yellow: [1, 1, 0, 1],
41
+ cyan: [0, 1, 1, 1],
42
+ magenta: [1, 0, 1, 1],
43
+ orange: [1, 0.647, 0, 1],
44
+ purple: [0.502, 0, 0.502, 1],
45
+ pink: [1, 0.753, 0.796, 1],
46
+ brown: [0.647, 0.165, 0.165, 1],
47
+ gray: [0.502, 0.502, 0.502, 1],
48
+ grey: [0.502, 0.502, 0.502, 1],
49
+ silver: [0.753, 0.753, 0.753, 1],
50
+ gold: [1, 0.843, 0, 1],
51
+ navy: [0, 0, 0.502, 1],
52
+ teal: [0, 0.502, 0.502, 1],
53
+ olive: [0.502, 0.502, 0, 1],
54
+ maroon: [0.502, 0, 0, 1],
55
+ aqua: [0, 1, 1, 1],
56
+ lime: [0, 1, 0, 1],
57
+ fuchsia: [1, 0, 1, 1],
58
+ }
59
+
60
+ // Style properties that expect length values
61
+ const LENGTH_PROPERTIES = new Set([
62
+ "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
63
+ "top", "right", "bottom", "left",
64
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
65
+ "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
66
+ "flexBasis",
67
+ "borderWidth", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
68
+ "borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius",
69
+ "fontSize",
70
+ ])
71
+
72
+ // Style properties that expect color values
73
+ const COLOR_PROPERTIES = new Set([
74
+ "color", "backgroundColor",
75
+ "borderColor", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor",
76
+ ])
77
+
78
+ // Style properties that are plain numbers (no Length wrapper needed)
79
+ const NUMBER_PROPERTIES = new Set([
80
+ "flexGrow", "flexShrink", "opacity",
81
+ ])
82
+
83
+ // Enum properties - these get passed through as-is (C# handles conversion)
84
+ const ENUM_PROPERTIES = new Set([
85
+ "flexDirection", "flexWrap", "alignItems", "alignSelf", "alignContent", "justifyContent",
86
+ "position", "overflow", "display", "visibility", "whiteSpace",
87
+ "unityTextAlign", "fontStyle",
88
+ ])
89
+
90
+ /**
91
+ * Parse a length value from various formats
92
+ * @param value - number, "100", "100px", "50%", "auto"
93
+ * @returns Unity Length struct or StyleKeyword
94
+ */
95
+ export function parseLength(value: number | string): CSLength | number | null {
96
+ if (typeof value === "number") {
97
+ return new CS.UnityEngine.UIElements.Length(value, CS.UnityEngine.UIElements.LengthUnit.Pixel)
98
+ }
99
+
100
+ if (typeof value !== "string") return null
101
+
102
+ const trimmed = value.trim().toLowerCase()
103
+
104
+ // Handle keywords
105
+ if (trimmed === "auto") {
106
+ return CS.UnityEngine.UIElements.StyleKeyword.Auto
107
+ }
108
+ if (trimmed === "none") {
109
+ return CS.UnityEngine.UIElements.StyleKeyword.None
110
+ }
111
+ if (trimmed === "initial") {
112
+ return CS.UnityEngine.UIElements.StyleKeyword.Initial
113
+ }
114
+
115
+ // Parse numeric values with units
116
+ const match = trimmed.match(/^(-?[\d.]+)(px|%)?$/)
117
+ if (match) {
118
+ const num = parseFloat(match[1])
119
+ if (isNaN(num)) return null
120
+
121
+ const unitStr = match[2]
122
+ const unit = unitStr === "%"
123
+ ? CS.UnityEngine.UIElements.LengthUnit.Percent
124
+ : CS.UnityEngine.UIElements.LengthUnit.Pixel
125
+
126
+ return new CS.UnityEngine.UIElements.Length(num, unit)
127
+ }
128
+
129
+ return null
130
+ }
131
+
132
+ /**
133
+ * Parse a hex color component (1 or 2 characters)
134
+ */
135
+ function parseHexComponent(hex: string): number {
136
+ if (hex.length === 1) {
137
+ hex = hex + hex // Expand shorthand: "f" -> "ff"
138
+ }
139
+ return parseInt(hex, 16) / 255
140
+ }
141
+
142
+ /**
143
+ * Parse a color value from various formats
144
+ * @param value - "#fff", "#ffffff", "#ffffffff", "rgb(255,0,0)", "rgba(255,0,0,0.5)", "red"
145
+ * @returns Unity Color struct or null if invalid
146
+ */
147
+ export function parseColor(value: string): CSColor | null {
148
+ if (typeof value !== "string") return null
149
+
150
+ const trimmed = value.trim().toLowerCase()
151
+
152
+ // Named colors
153
+ if (NAMED_COLORS[trimmed]) {
154
+ const [r, g, b, a] = NAMED_COLORS[trimmed]
155
+ return new CS.UnityEngine.Color(r, g, b, a)
156
+ }
157
+
158
+ // Hex colors: #rgb, #rgba, #rrggbb, #rrggbbaa
159
+ if (trimmed.startsWith("#")) {
160
+ const hex = trimmed.slice(1)
161
+
162
+ if (hex.length === 3) {
163
+ // #rgb
164
+ const r = parseHexComponent(hex[0])
165
+ const g = parseHexComponent(hex[1])
166
+ const b = parseHexComponent(hex[2])
167
+ return new CS.UnityEngine.Color(r, g, b, 1)
168
+ }
169
+
170
+ if (hex.length === 4) {
171
+ // #rgba
172
+ const r = parseHexComponent(hex[0])
173
+ const g = parseHexComponent(hex[1])
174
+ const b = parseHexComponent(hex[2])
175
+ const a = parseHexComponent(hex[3])
176
+ return new CS.UnityEngine.Color(r, g, b, a)
177
+ }
178
+
179
+ if (hex.length === 6) {
180
+ // #rrggbb
181
+ const r = parseHexComponent(hex.slice(0, 2))
182
+ const g = parseHexComponent(hex.slice(2, 4))
183
+ const b = parseHexComponent(hex.slice(4, 6))
184
+ return new CS.UnityEngine.Color(r, g, b, 1)
185
+ }
186
+
187
+ if (hex.length === 8) {
188
+ // #rrggbbaa
189
+ const r = parseHexComponent(hex.slice(0, 2))
190
+ const g = parseHexComponent(hex.slice(2, 4))
191
+ const b = parseHexComponent(hex.slice(4, 6))
192
+ const a = parseHexComponent(hex.slice(6, 8))
193
+ return new CS.UnityEngine.Color(r, g, b, a)
194
+ }
195
+
196
+ return null
197
+ }
198
+
199
+ // rgb(r, g, b) or rgba(r, g, b, a)
200
+ const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/)
201
+ if (rgbMatch) {
202
+ const r = parseInt(rgbMatch[1]) / 255
203
+ const g = parseInt(rgbMatch[2]) / 255
204
+ const b = parseInt(rgbMatch[3]) / 255
205
+ const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1
206
+ return new CS.UnityEngine.Color(
207
+ Math.min(1, Math.max(0, r)),
208
+ Math.min(1, Math.max(0, g)),
209
+ Math.min(1, Math.max(0, b)),
210
+ Math.min(1, Math.max(0, a))
211
+ )
212
+ }
213
+
214
+ // rgb with percentages: rgb(100%, 0%, 0%)
215
+ const rgbPercentMatch = trimmed.match(/^rgba?\s*\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\s*\)$/)
216
+ if (rgbPercentMatch) {
217
+ const r = parseFloat(rgbPercentMatch[1]) / 100
218
+ const g = parseFloat(rgbPercentMatch[2]) / 100
219
+ const b = parseFloat(rgbPercentMatch[3]) / 100
220
+ const a = rgbPercentMatch[4] !== undefined ? parseFloat(rgbPercentMatch[4]) : 1
221
+ return new CS.UnityEngine.Color(
222
+ Math.min(1, Math.max(0, r)),
223
+ Math.min(1, Math.max(0, g)),
224
+ Math.min(1, Math.max(0, b)),
225
+ Math.min(1, Math.max(0, a))
226
+ )
227
+ }
228
+
229
+ return null
230
+ }
231
+
232
+ /**
233
+ * Parse a style value based on the property name
234
+ * @param key - Style property name (e.g., "width", "backgroundColor")
235
+ * @param value - Raw value from React style object
236
+ * @returns Parsed value suitable for Unity UI Toolkit
237
+ */
238
+ export function parseStyleValue(key: string, value: unknown): unknown {
239
+ if (value === undefined || value === null) return value
240
+
241
+ // Length properties
242
+ if (LENGTH_PROPERTIES.has(key)) {
243
+ if (typeof value === "number") {
244
+ return new CS.UnityEngine.UIElements.Length(value, CS.UnityEngine.UIElements.LengthUnit.Pixel)
245
+ }
246
+ if (typeof value === "string") {
247
+ const parsed = parseLength(value)
248
+ if (parsed !== null) return parsed
249
+ }
250
+ // Fall through to return original value
251
+ }
252
+
253
+ // Color properties
254
+ if (COLOR_PROPERTIES.has(key)) {
255
+ if (typeof value === "string") {
256
+ const parsed = parseColor(value)
257
+ if (parsed !== null) return parsed
258
+ }
259
+ // Could already be a Color object, pass through
260
+ }
261
+
262
+ // Plain number properties - pass through as-is
263
+ if (NUMBER_PROPERTIES.has(key)) {
264
+ return value
265
+ }
266
+
267
+ // Enum properties - pass through as-is (C# handles string -> enum)
268
+ if (ENUM_PROPERTIES.has(key)) {
269
+ return value
270
+ }
271
+
272
+ // Unknown property - pass through unchanged
273
+ return value
274
+ }
275
+
276
+ /**
277
+ * Check if a property is a length property
278
+ */
279
+ export function isLengthProperty(key: string): boolean {
280
+ return LENGTH_PROPERTIES.has(key)
281
+ }
282
+
283
+ /**
284
+ * Check if a property is a color property
285
+ */
286
+ export function isColorProperty(key: string): boolean {
287
+ return COLOR_PROPERTIES.has(key)
288
+ }