@use-voltra/core 2.0.0-rc.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
1
+ import {
2
+ ComponentType,
3
+ ConsumerProps,
4
+ Context,
5
+ ForwardRefExoticComponent,
6
+ FunctionComponent,
7
+ LazyExoticComponent,
8
+ MemoExoticComponent,
9
+ ProviderProps,
10
+ ReactElement,
11
+ ReactNode,
12
+ } from 'react'
13
+ import {
14
+ isContextConsumer,
15
+ isContextProvider,
16
+ isForwardRef,
17
+ isFragment,
18
+ isLazy,
19
+ isMemo,
20
+ isPortal,
21
+ isProfiler,
22
+ isStrictMode,
23
+ isSuspense,
24
+ } from 'react-is'
25
+
26
+ import { isVoltraComponent } from '../jsx/createVoltraComponent.js'
27
+ import { shorten } from '../payload/short-names.js'
28
+ import { VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js'
29
+ import { ContextRegistry, getContextRegistry } from './context-registry.js'
30
+ import { getHooksDispatcher, getReactCurrentDispatcher } from './dispatcher.js'
31
+ import { createElementRegistry, type ElementRegistry, preScanForDuplicates } from './element-registry.js'
32
+ import { flattenStyle } from './flatten-styles.js'
33
+ import { getRenderCache, type RenderCache } from './render-cache.js'
34
+ import { createStylesheetRegistry, type StylesheetRegistry } from './stylesheet-registry.js'
35
+
36
+ export type ComponentRegistry = {
37
+ getComponentId: (name: string) => number
38
+ }
39
+
40
+ type VoltraRenderingContext = {
41
+ registry: ContextRegistry
42
+ stylesheetRegistry?: StylesheetRegistry
43
+ elementRegistry?: ElementRegistry
44
+ duplicates?: Set<ReactNode>
45
+ inStringOnlyContext?: boolean
46
+ componentRegistry: ComponentRegistry
47
+ }
48
+
49
+ function renderNode(element: ReactNode, context: VoltraRenderingContext): VoltraNodeJson {
50
+ if (element === null || element === undefined) {
51
+ return []
52
+ }
53
+
54
+ if (typeof element === 'boolean') {
55
+ if (context.inStringOnlyContext) {
56
+ return ''
57
+ }
58
+ return []
59
+ }
60
+
61
+ if (typeof element === 'string') {
62
+ if (context.inStringOnlyContext) {
63
+ return element
64
+ }
65
+ throw new Error(
66
+ 'Expected a React element, but got "string". Strings are only allowed as children of Text components.'
67
+ )
68
+ }
69
+
70
+ if (typeof element === 'number' || typeof element === 'bigint') {
71
+ if (context.inStringOnlyContext) {
72
+ return String(element)
73
+ }
74
+ throw new Error(`Expected a React element, but got "${typeof element}".`)
75
+ }
76
+
77
+ if (Array.isArray(element)) {
78
+ if (context.inStringOnlyContext) {
79
+ if (element.length === 0) {
80
+ throw new Error('Text component must have at least one child that resolves to a string.')
81
+ }
82
+ const results: string[] = []
83
+ for (const child of element) {
84
+ const result = renderNode(child, context)
85
+ if (Array.isArray(result) && result.length === 0) {
86
+ continue
87
+ }
88
+ if (typeof result !== 'string') {
89
+ throw new Error('Text component children must resolve to strings.')
90
+ }
91
+ results.push(result)
92
+ }
93
+ return results.join('')
94
+ }
95
+ return element.map((child) => renderNode(child, context)).flat() as VoltraNodeJson
96
+ }
97
+
98
+ if (context.duplicates?.has(element) && context.elementRegistry) {
99
+ const existingIndex = context.elementRegistry.isRegistered(element)
100
+ if (existingIndex !== undefined) {
101
+ return { $r: existingIndex } as VoltraElementRef
102
+ }
103
+
104
+ const rendered = renderNodeInternal(element, context)
105
+ const index = context.elementRegistry.register(element, rendered)
106
+ return { $r: index } as VoltraElementRef
107
+ }
108
+
109
+ return renderNodeInternal(element, context)
110
+ }
111
+
112
+ function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext): VoltraNodeJson {
113
+ if (typeof element !== 'object' || element === null) {
114
+ throw new Error(`Expected element-like object with type and props, got ${typeof element}`)
115
+ }
116
+
117
+ if (!('type' in element) || !('props' in element)) {
118
+ throw new Error(`Expected element-like object with type and props, got ${typeof element}`)
119
+ }
120
+
121
+ if (typeof element.type === 'string') {
122
+ throw new Error(`Host component "${element.type}" is not supported in Voltra.`)
123
+ }
124
+
125
+ if (isStrictMode(element)) {
126
+ throw new Error('Strict mode is not supported in Voltra.')
127
+ }
128
+ if (isProfiler(element)) {
129
+ throw new Error('Profiler is not supported in Voltra.')
130
+ }
131
+ if (isSuspense(element)) {
132
+ throw new Error('Suspense is not supported in Voltra.')
133
+ }
134
+ if (isPortal(element) || (element as { type?: unknown }).type === Symbol.for('react.portal')) {
135
+ throw new Error('Portal is not supported in Voltra.')
136
+ }
137
+
138
+ if (isFragment(element) || (element as { type?: unknown }).type === Symbol.for('react.fragment')) {
139
+ const fragmentElement = element as ReactElement<{ children?: ReactNode }>
140
+ return renderNode(fragmentElement.props.children, context)
141
+ }
142
+
143
+ if (isMemo(element)) {
144
+ const memoElement = element as ReactElement<unknown, MemoExoticComponent<ComponentType<unknown>>>
145
+ const { type: memoizedComponent } = memoElement.type
146
+ return renderNode({ ...memoElement, type: memoizedComponent }, context)
147
+ }
148
+
149
+ if (isForwardRef(element)) {
150
+ const forwardRefElement = element as ReactElement<unknown, ForwardRefExoticComponent<ComponentType<unknown>>>
151
+ const { render } = forwardRefElement.type as unknown as { render: (props: unknown) => ReactNode }
152
+ return renderFunctionalComponent(render, forwardRefElement.props, context)
153
+ }
154
+
155
+ if (isLazy(element)) {
156
+ const lazyElement = element as ReactElement<unknown, LazyExoticComponent<ComponentType<unknown>>>
157
+ const lazyType = lazyElement.type as unknown as {
158
+ _init?: (payload: unknown) => ComponentType<unknown>
159
+ _payload?: unknown
160
+ }
161
+
162
+ if (typeof lazyType._init !== 'function') {
163
+ throw new Error('Lazy component could not be resolved by the Voltra renderer.')
164
+ }
165
+
166
+ try {
167
+ const resolvedType = lazyType._init(lazyType._payload)
168
+ return renderNode({ ...lazyElement, type: resolvedType }, context)
169
+ } catch (error) {
170
+ if (error instanceof Promise) {
171
+ throw new Error('Lazy component suspended! Voltra does not support Suspense/Promises.')
172
+ }
173
+
174
+ throw error
175
+ }
176
+ }
177
+
178
+ if (isContextProvider(element)) {
179
+ const contextProviderElement = element as ReactElement<ProviderProps<unknown>>
180
+ const reactContext = contextProviderElement.type as Context<unknown>
181
+ const { value, children } = contextProviderElement.props
182
+ context.registry.pushProvider(reactContext, value)
183
+ const result = renderNode(children, context)
184
+ context.registry.popProvider(reactContext)
185
+ return result
186
+ }
187
+
188
+ if (isContextConsumer(element)) {
189
+ const contextConsumerElement = element as ReactElement<ConsumerProps<unknown>>
190
+ const reactContext = (contextConsumerElement.type as unknown as { _context: Context<unknown> })._context
191
+ const value = context.registry.readContext(reactContext)
192
+ const children = contextConsumerElement.props.children
193
+
194
+ if (typeof children === 'function') {
195
+ return renderNode(children(value), context)
196
+ }
197
+
198
+ throw new Error(`Expected a function as children of a context consumer, but got "${typeof children}".`)
199
+ }
200
+
201
+ if (
202
+ element != null &&
203
+ typeof element === 'object' &&
204
+ 'type' in element &&
205
+ 'props' in element &&
206
+ typeof (element as { type: unknown }).type === 'function'
207
+ ) {
208
+ const reactElement = element as ReactElement
209
+ const componentType = reactElement.type as FunctionComponent<unknown>
210
+
211
+ if (componentType.prototype && 'render' in componentType.prototype) {
212
+ throw new Error('Class components are not supported in Voltra.')
213
+ }
214
+
215
+ if (isVoltraComponent(componentType)) {
216
+ const child = componentType(reactElement.props)
217
+
218
+ if (typeof child !== 'object' || child === null || !('type' in child) || !('props' in child)) {
219
+ throw new Error(`Expected a React element, but got "${typeof child}".`)
220
+ }
221
+
222
+ if (typeof child.type !== 'string') {
223
+ throw new Error(`Expected a string as the type of a React element, but got "${typeof child.type}".`)
224
+ }
225
+
226
+ const { children, ...parameters } = child.props as { children?: ReactNode; [key: string]: unknown }
227
+ const isTextComponent = child.type === 'Text' || child.type === 'AndroidText'
228
+ const childContext: VoltraRenderingContext = {
229
+ ...context,
230
+ inStringOnlyContext: isTextComponent,
231
+ }
232
+ const renderedChildren =
233
+ children !== null && children !== undefined ? renderNode(children, childContext) : isTextComponent ? '' : []
234
+
235
+ const id = typeof parameters.id === 'string' ? parameters.id : undefined
236
+ const { id: _id, ...cleanParameters } = parameters
237
+
238
+ if (isTextComponent) {
239
+ if (typeof renderedChildren !== 'string') {
240
+ throw new Error(
241
+ 'Text component children must resolve to a string. Nested components are allowed, but they must eventually resolve to a string.'
242
+ )
243
+ }
244
+
245
+ const transformedProps = transformProps(cleanParameters, context)
246
+ const hasProps = Object.keys(transformedProps).length > 0
247
+
248
+ return {
249
+ t: context.componentRegistry.getComponentId(child.type),
250
+ ...(id ? { i: id } : {}),
251
+ c: renderedChildren,
252
+ ...(hasProps ? { p: transformedProps } : {}),
253
+ }
254
+ }
255
+
256
+ if (typeof renderedChildren === 'string') {
257
+ throw new Error('Unexpected string in non-Text component children.')
258
+ }
259
+
260
+ const transformedProps = transformProps(cleanParameters, context)
261
+ const hasProps = Object.keys(transformedProps).length > 0
262
+ const hasChildren = Array.isArray(renderedChildren) ? renderedChildren.length > 0 : true
263
+
264
+ return {
265
+ t: context.componentRegistry.getComponentId(child.type),
266
+ ...(id ? { i: id } : {}),
267
+ ...(hasChildren ? { c: renderedChildren } : {}),
268
+ ...(hasProps ? { p: transformedProps } : {}),
269
+ }
270
+ }
271
+
272
+ return renderFunctionalComponent(componentType, reactElement.props, context)
273
+ }
274
+
275
+ throw new Error(
276
+ `Unsupported element type "${String(
277
+ (element as { type?: unknown }).type
278
+ )}". Report this as a bug in the Voltra project.`
279
+ )
280
+ }
281
+
282
+ export const renderFunctionalComponent = <TProps>(
283
+ Component: FunctionComponent<TProps>,
284
+ props: TProps,
285
+ context: VoltraRenderingContext
286
+ ): VoltraNodeJson => {
287
+ const reactDispatcher = getReactCurrentDispatcher()
288
+ const prevHooksDispatcher = reactDispatcher.H
289
+
290
+ try {
291
+ reactDispatcher.H = getHooksDispatcher(context.registry)
292
+ const result = Component(props)
293
+
294
+ if (result instanceof Promise) {
295
+ throw new Error(
296
+ `Component "${
297
+ Component.name || 'Anonymous'
298
+ }" tried to suspend (returned a Promise). Async components are not supported in this synchronous renderer.`
299
+ )
300
+ }
301
+
302
+ return renderNode(result, context)
303
+ } catch (error) {
304
+ if (error instanceof Promise) {
305
+ throw new Error(
306
+ `Component "${Component.name || 'Anonymous'}" suspended! Voltra does not support Suspense/Promises.`
307
+ )
308
+ }
309
+
310
+ throw error
311
+ } finally {
312
+ reactDispatcher.H = prevHooksDispatcher
313
+ }
314
+ }
315
+
316
+ export const renderVariantToJson = (element: ReactNode, componentRegistry: ComponentRegistry): VoltraNodeJson => {
317
+ const registry = getContextRegistry()
318
+ const context: VoltraRenderingContext = {
319
+ registry,
320
+ componentRegistry,
321
+ }
322
+
323
+ return renderNode(element, context)
324
+ }
325
+
326
+ function compressStyleObject(style: any): any {
327
+ if (style === null || style === undefined) {
328
+ return style
329
+ }
330
+
331
+ const flattened = flattenStyle(style)
332
+ const compressed: Record<string, any> = {}
333
+
334
+ for (const [key, value] of Object.entries(flattened)) {
335
+ const shortKey = shorten(key)
336
+
337
+ if (value === null || value === undefined) {
338
+ continue
339
+ }
340
+
341
+ if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
342
+ const compressedNested: Record<string, any> = {}
343
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
344
+ compressedNested[nestedKey] = nestedValue
345
+ }
346
+ compressed[shortKey] = compressedNested
347
+ } else {
348
+ compressed[shortKey] = value
349
+ }
350
+ }
351
+
352
+ return compressed
353
+ }
354
+
355
+ function isReactNode(value: unknown): value is ReactNode {
356
+ if (value === null || value === undefined || value === false || value === true) {
357
+ return false
358
+ }
359
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') {
360
+ return false
361
+ }
362
+ if (Array.isArray(value)) {
363
+ return true
364
+ }
365
+ if (typeof value === 'object' && value !== null && 'type' in value && 'props' in value) {
366
+ return true
367
+ }
368
+ return false
369
+ }
370
+
371
+ export function transformProps(
372
+ props: Record<string, unknown>,
373
+ context: VoltraRenderingContext
374
+ ): Record<string, VoltraPropValue> {
375
+ const transformed: Record<string, VoltraPropValue> = {}
376
+
377
+ for (const [key, value] of Object.entries(props)) {
378
+ if (key === 'style') {
379
+ const shortKey = shorten(key)
380
+ if (context.stylesheetRegistry) {
381
+ const index = context.stylesheetRegistry.registerStyle(value as object)
382
+ transformed[shortKey] = index
383
+ } else {
384
+ transformed[shortKey] = compressStyleObject(value)
385
+ }
386
+ } else if (isReactNode(value)) {
387
+ const serializedComponent = renderNode(value, {
388
+ registry: getContextRegistry(),
389
+ stylesheetRegistry: context.stylesheetRegistry,
390
+ elementRegistry: context.elementRegistry,
391
+ duplicates: context.duplicates,
392
+ inStringOnlyContext: false,
393
+ componentRegistry: context.componentRegistry,
394
+ })
395
+ const shortKey = shorten(key)
396
+ transformed[shortKey] = serializedComponent
397
+ } else {
398
+ const shortKey = shorten(key)
399
+ transformed[shortKey] = value as VoltraPropValue
400
+ }
401
+ }
402
+
403
+ return transformed
404
+ }
405
+
406
+ export const VOLTRA_PAYLOAD_VERSION = 1
407
+
408
+ export const createVoltraRenderer = (componentRegistry: ComponentRegistry) => {
409
+ const rootNodes: { name: string; node: ReactNode }[] = []
410
+
411
+ let duplicates: Set<ReactNode> | undefined
412
+ let stylesheetRegistry: StylesheetRegistry | undefined
413
+ let elementRegistry: ElementRegistry | undefined
414
+ let renderCache: RenderCache | undefined
415
+
416
+ const addRootNode = (name: string, node: ReactNode): void => {
417
+ if (node === null || node === undefined) {
418
+ return
419
+ }
420
+ rootNodes.push({ name, node })
421
+ }
422
+
423
+ const preScanAllNodes = (): Set<ReactNode> => {
424
+ const seen = new Set<ReactNode>()
425
+ const duplicatesSet = new Set<ReactNode>()
426
+
427
+ for (const { node } of rootNodes) {
428
+ if (node && typeof node === 'object') {
429
+ if (seen.has(node)) {
430
+ duplicatesSet.add(node)
431
+ } else {
432
+ seen.add(node)
433
+ }
434
+ }
435
+
436
+ const nodeDuplicates = preScanForDuplicates(node)
437
+ for (const dup of nodeDuplicates) {
438
+ duplicatesSet.add(dup)
439
+ }
440
+ }
441
+
442
+ return duplicatesSet
443
+ }
444
+
445
+ const render = (): Record<string, any> => {
446
+ stylesheetRegistry = createStylesheetRegistry()
447
+ elementRegistry = createElementRegistry()
448
+ duplicates = preScanAllNodes()
449
+
450
+ const renderVariantToJson = (element: ReactNode): VoltraNodeJson => {
451
+ const registry = getContextRegistry()
452
+ const context: VoltraRenderingContext = {
453
+ registry,
454
+ stylesheetRegistry,
455
+ elementRegistry,
456
+ duplicates,
457
+ componentRegistry,
458
+ }
459
+ return renderNode(element, context)
460
+ }
461
+
462
+ renderCache = getRenderCache(renderVariantToJson)
463
+
464
+ const result: Record<string, any> = {
465
+ v: VOLTRA_PAYLOAD_VERSION,
466
+ }
467
+
468
+ for (const { name, node } of rootNodes) {
469
+ if (node !== null && node !== undefined) {
470
+ result[name] = renderCache.getOrRender(node)
471
+ }
472
+ }
473
+
474
+ const sharedElements = elementRegistry.getElements()
475
+ if (sharedElements.length > 0) {
476
+ result.e = sharedElements
477
+ }
478
+
479
+ const styles = stylesheetRegistry.getStyles()
480
+ if (styles.length > 0) {
481
+ result.s = styles
482
+ }
483
+
484
+ return result
485
+ }
486
+
487
+ return {
488
+ addRootNode,
489
+ render,
490
+ }
491
+ }
@@ -0,0 +1,54 @@
1
+ import { shorten } from '../payload/short-names.js'
2
+ import { flattenStyle } from './flatten-styles.js'
3
+
4
+ function compressStyleObject(style: any): any {
5
+ if (style === null || style === undefined) {
6
+ return style
7
+ }
8
+
9
+ const flattened = flattenStyle(style)
10
+ const compressed: Record<string, any> = {}
11
+
12
+ for (const [key, value] of Object.entries(flattened)) {
13
+ const shortKey = shorten(key)
14
+
15
+ if (value === null || value === undefined) {
16
+ continue
17
+ }
18
+
19
+ if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
20
+ const compressedNested: Record<string, any> = {}
21
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
22
+ compressedNested[nestedKey] = nestedValue
23
+ }
24
+ compressed[shortKey] = compressedNested
25
+ } else {
26
+ compressed[shortKey] = value
27
+ }
28
+ }
29
+
30
+ return compressed
31
+ }
32
+
33
+ export type StylesheetRegistry = {
34
+ registerStyle: (styleObject: object) => number
35
+ getStyles: () => Record<string, unknown>[]
36
+ }
37
+
38
+ export const createStylesheetRegistry = (): StylesheetRegistry => {
39
+ const styleToIndex = new Map<object, number>()
40
+ const styles: Record<string, unknown>[] = []
41
+
42
+ return {
43
+ registerStyle: (styleObject: object): number => {
44
+ const existing = styleToIndex.get(styleObject)
45
+ if (existing !== undefined) return existing
46
+
47
+ const index = styles.length
48
+ styleToIndex.set(styleObject, index)
49
+ styles.push(compressStyleObject(styleObject))
50
+ return index
51
+ },
52
+ getStyles: () => styles,
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ import { ReactNode } from 'react'
2
+
3
+ import { VoltraNodeJson } from '../types.js'
4
+
5
+ export type VoltraVariantRenderer = (node: ReactNode) => VoltraNodeJson
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type VoltraPropValue = string | number | boolean | null | VoltraNodeJson
2
+
3
+ export type VoltraElementJson = {
4
+ t: number
5
+ i?: string
6
+ c?: VoltraNodeJson
7
+ p?: Record<string, VoltraPropValue>
8
+ }
9
+
10
+ export type VoltraElementRef = {
11
+ $r: number
12
+ }
13
+
14
+ export type VoltraNodeJson = VoltraElementJson | VoltraElementJson[] | VoltraElementRef | string
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Env shape consumed by Dynamic Widgets.
3
+ *
4
+ * Dynamic Widgets are functions of `(props, env) => JSX`, evaluated inside the
5
+ * Voltra JS runtime (JSC on iOS, Hermes on Android) at every render. The `env` second
6
+ * argument is populated by the native runtime at draw time and carries:
7
+ *
8
+ * - **Runtime device state** (`colorScheme`, `widgetFamily`, etc.) captured per render
9
+ * - **Platform-specific runtime state** (`widgetRenderingMode` on iOS), present only on the
10
+ * platform that has the concept
11
+ * - **AppIntent / user-configured params** under `env.configuration` (TypeScript-typed per
12
+ * widget via the generic parameter)
13
+ * - **Build env** under `env.build.*` — values that don't change between renders inside a
14
+ * process (isDev, Metro URL, app version, Voltra version)
15
+ *
16
+ * The shape mirrors expo-widgets' `WidgetEnvironment` for the runtime device fields, with
17
+ * a Voltra-specific `env.build.*` namespace added for dev-mode tooling.
18
+ *
19
+ * @typeParam TConfig - Shape of `env.configuration` (AppIntent / user-configured params).
20
+ * Defaults to `undefined` for widgets that don't accept user configuration. Widget authors
21
+ * can supply a more specific type per widget for typed access.
22
+ */
23
+ export type WidgetEnvironment<TConfig extends Record<string, unknown> | undefined = undefined> = {
24
+ /** Date the widget is being rendered for. Transported as epoch ms over the JS boundary
25
+ * and reconstructed as `Date` by the runtime entry. */
26
+ date: Date
27
+
28
+ /** Widget size family. iOS values: `systemSmall`, `systemMedium`, `systemLarge`, etc.
29
+ * Android values: synthesized from Glance `LocalSize` (e.g. `"200x200"`). */
30
+ widgetFamily: string
31
+
32
+ /** Current color scheme of the widget's environment. May be `undefined` if the platform
33
+ * doesn't expose it (rare). */
34
+ colorScheme?: 'light' | 'dark'
35
+
36
+ /** BCP-47 locale tag — for example `"en-US"` or `"pl-PL"`. */
37
+ locale?: string
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // iOS-only runtime values
41
+ // Present only when rendering on iOS; `undefined` on Android.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** iOS — rendering mode the widget is being drawn in. `fullColor` on home screen,
45
+ * `accented` on tinted/Liquid Glass widgets (iOS 18+) and watchOS, `vibrant` on lock
46
+ * screen. Maps to SwiftUI `@Environment(\.widgetRenderingMode)`. */
47
+ widgetRenderingMode?: 'fullColor' | 'accented' | 'vibrant'
48
+
49
+ /** iOS — whether the system is drawing a container background behind the widget.
50
+ * Maps to SwiftUI `@Environment(\.showsWidgetContainerBackground)`. iOS 17+. */
51
+ showsWidgetContainerBackground?: boolean
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // System-managed configuration
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** AppIntent / user-configured parameters for this widget. `undefined` for widgets that
58
+ * don't accept user configuration. Typed per widget via the [TConfig] generic. */
59
+ configuration: TConfig
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Build env — static for the process lifetime, supplied by the runtime
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Build / process-level metadata, populated by the runtime once per process. Static for
66
+ * the JS runtime's lifetime; does not change between renders. */
67
+ build: WidgetBuildEnvironment
68
+ }
69
+
70
+ /**
71
+ * Build / process metadata available inside the widget render function. Populated by the
72
+ * native runtime; identical across every render in a process.
73
+ */
74
+ export type WidgetBuildEnvironment = {
75
+ /** True when running against a development build (DEBUG / `__DEV__`). Used to gate
76
+ * dev-mode behaviour like fetching bundles from Metro. */
77
+ isDev: boolean
78
+
79
+ /** URL of the Metro dev server when `isDev` is true. Used by the runtime to fetch widget
80
+ * bundles for hot-reload. `undefined` in release builds. */
81
+ metroUrl?: string
82
+
83
+ /** App version string (`CFBundleShortVersionString` on iOS, `versionName` on Android). */
84
+ appVersion: string
85
+
86
+ /** Voltra package version (`@use-voltra/core`). Surfaces in error reports and lets
87
+ * widgets gate behaviour by compatibility level if needed. */
88
+ voltraVersion: string
89
+ }
90
+
91
+ /**
92
+ * Type guard — returns true when the runtime env is an iOS-platform env.
93
+ *
94
+ * @example
95
+ * if (isIosEnv(env)) {
96
+ * // env.widgetRenderingMode is narrowed to the concrete value (not undefined)
97
+ * }
98
+ */
99
+ export function isIosEnv(
100
+ env: WidgetEnvironment
101
+ ): env is WidgetEnvironment & { widgetRenderingMode: NonNullable<WidgetEnvironment['widgetRenderingMode']> } {
102
+ return env.widgetRenderingMode !== undefined
103
+ }
104
+
105
+ /**
106
+ * Type guard — returns true when the runtime env is an Android-platform env.
107
+ *
108
+ * Android carries no platform-only runtime field (Material You colors are consumed via
109
+ * `AndroidDynamicColors` tokens resolved by the native renderer, not through `env`), so this is
110
+ * the complement of {@link isIosEnv}.
111
+ */
112
+ export function isAndroidEnv(env: WidgetEnvironment): boolean {
113
+ return !isIosEnv(env)
114
+ }