@namiml/expo-sdk 3.4.0-dev.202605060437

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 (60) hide show
  1. package/dist/index.cjs +4000 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.ts +151 -0
  4. package/dist/index.mjs +3966 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/nami-expo-nami-iap.tgz +0 -0
  7. package/package.json +92 -0
  8. package/src/adapters/expo-device.adapter.ts +106 -0
  9. package/src/adapters/expo-purchase.adapter.ts +79 -0
  10. package/src/adapters/expo-storage.adapter.ts +92 -0
  11. package/src/adapters/expo-ui.adapter.ts +57 -0
  12. package/src/adapters/index.ts +33 -0
  13. package/src/amazon-kepler.d.ts +7 -0
  14. package/src/components/NamiView.tsx +1006 -0
  15. package/src/components/PaywallScreen.tsx +245 -0
  16. package/src/components/TemplateRenderer.tsx +243 -0
  17. package/src/components/containers/NamiBackgroundContainer.tsx +103 -0
  18. package/src/components/containers/NamiCarousel.tsx +217 -0
  19. package/src/components/containers/NamiCollapseContainer.tsx +116 -0
  20. package/src/components/containers/NamiContainer.tsx +315 -0
  21. package/src/components/containers/NamiContentContainer.tsx +140 -0
  22. package/src/components/containers/NamiFooter.tsx +35 -0
  23. package/src/components/containers/NamiHeader.tsx +45 -0
  24. package/src/components/containers/NamiProductContainer.tsx +248 -0
  25. package/src/components/containers/NamiRepeatingGrid.tsx +81 -0
  26. package/src/components/containers/NamiResponsiveGrid.tsx +75 -0
  27. package/src/components/containers/NamiStack.tsx +69 -0
  28. package/src/components/elements/NamiButton.tsx +285 -0
  29. package/src/components/elements/NamiCountdownTimer.tsx +123 -0
  30. package/src/components/elements/NamiImage.tsx +177 -0
  31. package/src/components/elements/NamiPlayPauseButton.tsx +93 -0
  32. package/src/components/elements/NamiProgressBar.tsx +90 -0
  33. package/src/components/elements/NamiProgressIndicator.tsx +41 -0
  34. package/src/components/elements/NamiQRCode.tsx +51 -0
  35. package/src/components/elements/NamiRadioButton.tsx +62 -0
  36. package/src/components/elements/NamiSegmentPicker.tsx +67 -0
  37. package/src/components/elements/NamiSegmentPickerItem.tsx +184 -0
  38. package/src/components/elements/NamiSpacer.tsx +23 -0
  39. package/src/components/elements/NamiSymbol.tsx +104 -0
  40. package/src/components/elements/NamiText.tsx +311 -0
  41. package/src/components/elements/NamiToggleButton.tsx +102 -0
  42. package/src/components/elements/NamiToggleSwitch.tsx +64 -0
  43. package/src/components/elements/NamiVideo.kepler.tsx +638 -0
  44. package/src/components/elements/NamiVideo.tsx +133 -0
  45. package/src/components/elements/NamiVolumeButton.tsx +93 -0
  46. package/src/context/FocusContext.tsx +169 -0
  47. package/src/context/PaywallContext.tsx +343 -0
  48. package/src/global.d.ts +5 -0
  49. package/src/index.ts +62 -0
  50. package/src/nami.ts +24 -0
  51. package/src/react-native-qrcode-svg.d.ts +4 -0
  52. package/src/utils/actionHandler.ts +281 -0
  53. package/src/utils/fonts.ts +359 -0
  54. package/src/utils/iconMap.ts +67 -0
  55. package/src/utils/impression.ts +39 -0
  56. package/src/utils/rendering.ts +197 -0
  57. package/src/utils/smartText.ts +148 -0
  58. package/src/utils/styles.ts +668 -0
  59. package/src/utils/tvFocus.ts +31 -0
  60. package/src/utils/videoControls.ts +49 -0
@@ -0,0 +1,668 @@
1
+ import { Platform } from 'react-native';
2
+ import type { ViewStyle, TextStyle, ImageStyle } from 'react-native';
3
+ import type { TBaseComponent, BorderSideType, TContainerPosition } from '@namiml/sdk-core';
4
+ import { inferFontNameVariant, resolveFontDescriptor } from './fonts';
5
+
6
+ export type NamiStyle = ViewStyle & TextStyle & Omit<ImageStyle, 'overflow'>;
7
+
8
+ const HEX_ALPHA_REGEX = /^#([0-9a-f]{8})$/i;
9
+ const HEX_SHORT_ALPHA_REGEX = /^#([0-9a-f]{4})$/i;
10
+ const HSLA_REGEX = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i;
11
+
12
+ export function parseColor(color: any): string | undefined {
13
+ if (!color) return undefined;
14
+ if (typeof color === 'string') {
15
+ const trimmed = color.trim();
16
+ if (!trimmed) return undefined;
17
+ if (trimmed.toLowerCase().includes('gradient(')) return undefined;
18
+ return normalizeColorString(trimmed);
19
+ }
20
+ if (color.rgba) {
21
+ const { r, g, b, a } = color.rgba;
22
+ return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a ?? 1})`;
23
+ }
24
+ if (color.hex) return normalizeColorString(color.hex);
25
+ return undefined;
26
+ }
27
+
28
+ function normalizeColorString(input: string): string {
29
+ const value = input.trim();
30
+ if (!value) return value;
31
+
32
+ const match = value.match(/^rgba?\((.+)\)$/i);
33
+ if (match) {
34
+ const inside = match[1];
35
+ const [base, alphaRaw] = inside.split('/').map((v) => v.trim());
36
+ const parts = base.split(/[\s,]+/).filter(Boolean);
37
+ const nums = parts.map((p) => p.trim());
38
+ const r = nums[0];
39
+ const g = nums[1];
40
+ const b = nums[2];
41
+ const a = alphaRaw ?? nums[3];
42
+
43
+ if (r != null && g != null && b != null) {
44
+ if (a != null) {
45
+ return `rgba(${r},${g},${b},${a})`;
46
+ }
47
+ return `rgb(${r},${g},${b})`;
48
+ }
49
+ }
50
+
51
+ const hexAlphaMatch = value.match(HEX_ALPHA_REGEX);
52
+ if (hexAlphaMatch) {
53
+ const hex = hexAlphaMatch[1];
54
+ const r = parseInt(hex.slice(0, 2), 16);
55
+ const g = parseInt(hex.slice(2, 4), 16);
56
+ const b = parseInt(hex.slice(4, 6), 16);
57
+ const a = parseInt(hex.slice(6, 8), 16) / 255;
58
+ return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`;
59
+ }
60
+
61
+ const hexShortAlphaMatch = value.match(HEX_SHORT_ALPHA_REGEX);
62
+ if (hexShortAlphaMatch) {
63
+ const hex = hexShortAlphaMatch[1];
64
+ const r = parseInt(hex[0] + hex[0], 16);
65
+ const g = parseInt(hex[1] + hex[1], 16);
66
+ const b = parseInt(hex[2] + hex[2], 16);
67
+ const a = parseInt(hex[3] + hex[3], 16) / 255;
68
+ return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`;
69
+ }
70
+
71
+ const hslaMatch = value.match(HSLA_REGEX);
72
+ if (hslaMatch) {
73
+ const h = parseFloat(hslaMatch[1]);
74
+ const s = parseFloat(hslaMatch[2]);
75
+ const l = parseFloat(hslaMatch[3]);
76
+ const a = hslaMatch[4] != null ? parseFloat(hslaMatch[4]) : undefined;
77
+ const [r, g, b] = hslToRgb(h, s, l);
78
+ if (a != null && !Number.isNaN(a)) {
79
+ return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`;
80
+ }
81
+ return `rgb(${r},${g},${b})`;
82
+ }
83
+
84
+ return value;
85
+ }
86
+
87
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
88
+ const hh = ((h % 360) + 360) % 360;
89
+ const ss = Math.max(0, Math.min(100, s)) / 100;
90
+ const ll = Math.max(0, Math.min(100, l)) / 100;
91
+ const c = (1 - Math.abs(2 * ll - 1)) * ss;
92
+ const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
93
+ const m = ll - c / 2;
94
+ let r1 = 0;
95
+ let g1 = 0;
96
+ let b1 = 0;
97
+ if (hh < 60) {
98
+ r1 = c; g1 = x; b1 = 0;
99
+ } else if (hh < 120) {
100
+ r1 = x; g1 = c; b1 = 0;
101
+ } else if (hh < 180) {
102
+ r1 = 0; g1 = c; b1 = x;
103
+ } else if (hh < 240) {
104
+ r1 = 0; g1 = x; b1 = c;
105
+ } else if (hh < 300) {
106
+ r1 = x; g1 = 0; b1 = c;
107
+ } else {
108
+ r1 = c; g1 = 0; b1 = x;
109
+ }
110
+ const r = Math.round((r1 + m) * 255);
111
+ const g = Math.round((g1 + m) * 255);
112
+ const b = Math.round((b1 + m) * 255);
113
+ return [r, g, b];
114
+ }
115
+
116
+ export function isLinearGradient(value?: string): boolean {
117
+ if (!value || typeof value !== 'string') return false;
118
+ return value.trim().toLowerCase().startsWith('linear-gradient(');
119
+ }
120
+
121
+ export type LinearGradientSpec = {
122
+ colors: string[];
123
+ locations?: number[];
124
+ angle?: number;
125
+ };
126
+
127
+ function splitGradientArgs(input: string): string[] {
128
+ const out: string[] = [];
129
+ let current = '';
130
+ let depth = 0;
131
+ for (let i = 0; i < input.length; i++) {
132
+ const ch = input[i];
133
+ if (ch === '(') depth += 1;
134
+ if (ch === ')') depth = Math.max(0, depth - 1);
135
+ if (ch === ',' && depth === 0) {
136
+ out.push(current.trim());
137
+ current = '';
138
+ continue;
139
+ }
140
+ current += ch;
141
+ }
142
+ if (current.trim()) out.push(current.trim());
143
+ return out;
144
+ }
145
+
146
+ function parseAngleToken(token: string): number | undefined {
147
+ const t = token.trim().toLowerCase();
148
+ if (t.endsWith('deg')) {
149
+ const num = parseFloat(t.replace('deg', '').trim());
150
+ return Number.isFinite(num) ? num : undefined;
151
+ }
152
+ if (t.startsWith('to ')) {
153
+ // CSS gradient directions
154
+ if (t.includes('right') && t.includes('top')) return 45;
155
+ if (t.includes('right') && t.includes('bottom')) return 135;
156
+ if (t.includes('left') && t.includes('bottom')) return 225;
157
+ if (t.includes('left') && t.includes('top')) return 315;
158
+ if (t.includes('right')) return 90;
159
+ if (t.includes('left')) return 270;
160
+ if (t.includes('bottom')) return 180;
161
+ if (t.includes('top')) return 0;
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ export function parseSize(value: any, scaleFactor: number = 1): number | undefined {
167
+ if (value === undefined || value === null) return undefined;
168
+ if (typeof value === 'number') return value * scaleFactor;
169
+ if (typeof value === 'string') {
170
+ const num = parseFloat(value);
171
+ if (!isNaN(num)) return num * scaleFactor;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ export function parseSizeOrPercent(value: any, scaleFactor: number = 1): number | string | undefined {
177
+ if (value === undefined || value === null) return undefined;
178
+ if (typeof value === 'string' && value.endsWith('%')) return value;
179
+ return parseSize(value, scaleFactor);
180
+ }
181
+
182
+ export function flexDirectionFromConfig(dir?: string): ViewStyle['flexDirection'] {
183
+ switch (dir) {
184
+ case 'horizontal': return 'row';
185
+ case 'vertical': return 'column';
186
+ case 'horizontal-reverse': return 'row-reverse';
187
+ case 'vertical-reverse': return 'column-reverse';
188
+ default: return 'column';
189
+ }
190
+ }
191
+
192
+ const ALIGNMENT_VALUE_MAP: Record<string, ViewStyle['alignItems']> = {
193
+ top: 'flex-start',
194
+ left: 'flex-start',
195
+ right: 'flex-end',
196
+ bottom: 'flex-end',
197
+ start: 'flex-start',
198
+ end: 'flex-end',
199
+ leading: 'flex-start',
200
+ trailing: 'flex-end',
201
+ center: 'center',
202
+ stretch: 'stretch',
203
+ };
204
+
205
+ const JUSTIFY_VALUE_MAP: Record<string, ViewStyle['justifyContent']> = {
206
+ spaceBetween: 'space-between',
207
+ 'space-between': 'space-between',
208
+ spaceAround: 'space-around',
209
+ 'space-around': 'space-around',
210
+ spaceEvenly: 'space-evenly',
211
+ 'space-evenly': 'space-evenly',
212
+ top: 'flex-start',
213
+ bottom: 'flex-end',
214
+ left: 'flex-start',
215
+ right: 'flex-end',
216
+ start: 'flex-start',
217
+ end: 'flex-end',
218
+ leading: 'flex-start',
219
+ trailing: 'flex-end',
220
+ center: 'center',
221
+ };
222
+
223
+ function mapAlignmentValue(align?: string): ViewStyle['alignItems'] | undefined {
224
+ if (!align) return undefined;
225
+ return ALIGNMENT_VALUE_MAP[align] ?? undefined;
226
+ }
227
+
228
+ function mapJustifyValue(align?: string): ViewStyle['justifyContent'] | undefined {
229
+ if (!align) return undefined;
230
+ return JUSTIFY_VALUE_MAP[align] ?? (ALIGNMENT_VALUE_MAP[align] as ViewStyle['justifyContent'] | undefined);
231
+ }
232
+
233
+ function mapPositionAlignment(align?: string): ViewStyle['alignSelf'] {
234
+ return mapAlignmentValue(align);
235
+ }
236
+
237
+ export function paddingAndMarginStyles(component: TBaseComponent, scaleFactor: number): ViewStyle {
238
+ const s: ViewStyle = {};
239
+ if (component.leftPadding != null) s.paddingLeft = parseSize(component.leftPadding, scaleFactor);
240
+ if (component.rightPadding != null) s.paddingRight = parseSize(component.rightPadding, scaleFactor);
241
+ if (component.topPadding != null) s.paddingTop = parseSize(component.topPadding, scaleFactor);
242
+ if (component.bottomPadding != null) s.paddingBottom = parseSize(component.bottomPadding, scaleFactor);
243
+ if (component.leftMargin != null) s.marginLeft = parseSize(component.leftMargin, scaleFactor);
244
+ if (component.rightMargin != null) s.marginRight = parseSize(component.rightMargin, scaleFactor);
245
+ if (component.topMargin != null) s.marginTop = parseSize(component.topMargin, scaleFactor);
246
+ if (component.bottomMargin != null) s.marginBottom = parseSize(component.bottomMargin, scaleFactor);
247
+ return s;
248
+ }
249
+
250
+ export function borderStyles(component: TBaseComponent, scaleFactor: number, inFocusedState: boolean = false): ViewStyle {
251
+ const s: ViewStyle = {};
252
+ const bw = parseSize(
253
+ inFocusedState ? (component.focusedBorderWidth ?? component.borderWidth) : component.borderWidth,
254
+ scaleFactor
255
+ );
256
+ const bc = parseColor(
257
+ inFocusedState ? (component.focusedBorderColor ?? component.borderColor) : component.borderColor
258
+ );
259
+ const br = parseSize(
260
+ inFocusedState ? (component.focusedBorderRadius ?? component.borderRadius) : component.borderRadius,
261
+ scaleFactor
262
+ );
263
+ const roundBorders = inFocusedState
264
+ ? (component.focusedRoundBorders ?? component.roundBorders)
265
+ : component.roundBorders;
266
+
267
+ if (roundBorders?.length) {
268
+ const mapping: Record<string, keyof ViewStyle> = {
269
+ upperLeft: 'borderTopLeftRadius',
270
+ upperRight: 'borderTopRightRadius',
271
+ lowerLeft: 'borderBottomLeftRadius',
272
+ lowerRight: 'borderBottomRightRadius',
273
+ };
274
+ for (const corner of roundBorders) {
275
+ const key = mapping[corner];
276
+ if (key && br != null) (s as any)[key] = br;
277
+ }
278
+ } else if (br != null) {
279
+ s.borderRadius = br;
280
+ }
281
+
282
+ const sides: BorderSideType[] = inFocusedState
283
+ ? (component.focusedBorders ?? component.borders ?? [])
284
+ : (component.borders ?? []);
285
+ if (sides.length > 0 && bw != null) {
286
+ const sideMap: Record<BorderSideType, [keyof ViewStyle, keyof ViewStyle]> = {
287
+ top: ['borderTopWidth', 'borderTopColor'],
288
+ bottom: ['borderBottomWidth', 'borderBottomColor'],
289
+ left: ['borderLeftWidth', 'borderLeftColor'],
290
+ right: ['borderRightWidth', 'borderRightColor'],
291
+ };
292
+ for (const side of sides) {
293
+ const [wKey, cKey] = sideMap[side];
294
+ (s as any)[wKey] = bw;
295
+ if (bc) (s as any)[cKey] = bc;
296
+ }
297
+ } else if (bw != null) {
298
+ s.borderWidth = bw;
299
+ if (bc) s.borderColor = bc;
300
+ } else if (bc) {
301
+ s.borderColor = bc;
302
+ }
303
+
304
+ return s;
305
+ }
306
+
307
+ function parseDropShadow(value?: string): { x: number; y: number; blur: number; color?: string } | null {
308
+ if (!value || typeof value !== 'string') return null;
309
+ const match = value.trim().match(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(.+)/);
310
+ if (!match) return null;
311
+ const x = parseFloat(match[1]);
312
+ const y = parseFloat(match[2]);
313
+ const blur = parseFloat(match[3]);
314
+ const color = parseColor(match[4].trim());
315
+ return { x, y, blur, color: color ?? undefined };
316
+ }
317
+
318
+ export function shadowStyles(component: TBaseComponent): ViewStyle {
319
+ const parsed = parseDropShadow(component.dropShadow);
320
+ if (!parsed) return {};
321
+ const { x, y, blur, color } = parsed;
322
+ return {
323
+ ...Platform.select({
324
+ ios: {
325
+ shadowColor: color ?? 'rgba(0,0,0,0.5)',
326
+ shadowOffset: { width: x, height: y },
327
+ shadowOpacity: 1,
328
+ shadowRadius: blur,
329
+ },
330
+ android: {
331
+ elevation: blur ? Math.ceil(blur / 2) : 4,
332
+ },
333
+ }),
334
+ };
335
+ }
336
+
337
+ export function sizeStyles(
338
+ component: TBaseComponent,
339
+ scaleFactor: number,
340
+ parentDirection?: string
341
+ ): ViewStyle {
342
+ const s: ViewStyle = {};
343
+ const rawW = component.width ?? (component as any).fixedWidth;
344
+ const rawH = component.height ?? (component as any).fixedHeight;
345
+ const w = parseSizeOrPercent(rawW, scaleFactor);
346
+ const h = parseSizeOrPercent(rawH, scaleFactor);
347
+
348
+ if (w != null) s.width = w as any;
349
+ if (h != null) s.height = h as any;
350
+ s.maxWidth = '100%';
351
+
352
+ if (rawW === 'fitContent') {
353
+ s.flexShrink = 0;
354
+ s.flexGrow = 0;
355
+ }
356
+
357
+ if (rawH === 'fitContent') {
358
+ s.flexGrow = 0;
359
+ }
360
+
361
+ if (parentDirection === 'horizontal' && rawW == null) {
362
+ s.flex = 1;
363
+ s.flexShrink = 1;
364
+ (s as any).minWidth = 0;
365
+ } else if (typeof rawW === 'string' && rawW.trim().endsWith('%')) {
366
+ s.flexShrink = 1;
367
+ (s as any).minWidth = 0;
368
+ }
369
+
370
+ if (typeof rawH === 'string' && rawH.trim().endsWith('%')) {
371
+ s.flexShrink = 1;
372
+ }
373
+
374
+ const componentId = String((component as any).id ?? '');
375
+ const componentType = String((component as any).namiComponentType ?? '');
376
+ const isDividerLike = componentType === 'divider' || /^divider/i.test(componentId);
377
+ if (isDividerLike && parentDirection === 'horizontal' && rawH === '100%') {
378
+ (s as any).height = undefined;
379
+ s.alignSelf = 'stretch';
380
+ s.flexGrow = 1;
381
+ s.flexShrink = 0;
382
+ }
383
+
384
+ if (!parentDirection && rawW == null && rawH == null) {
385
+ s.alignSelf = 'stretch';
386
+ }
387
+
388
+ return s;
389
+ }
390
+
391
+ export function layoutStyles(component: TBaseComponent, scaleFactor: number): ViewStyle {
392
+ const s: ViewStyle = {};
393
+ s.flexDirection = flexDirectionFromConfig((component as any).direction);
394
+
395
+ const alignment = (component as any).alignment;
396
+ const horizontal = (component as any).horizontalAlignment;
397
+ const vertical = (component as any).verticalAlignment;
398
+
399
+ const verticalAlign = vertical ? mapAlignmentValue(vertical) : undefined;
400
+ const horizontalAlign = horizontal ? mapAlignmentValue(horizontal) : undefined;
401
+ const fallbackAlign = alignment ? (mapAlignmentValue(alignment) ?? 'center') : 'center';
402
+ const fallbackJustify = alignment ? (mapJustifyValue(alignment) ?? 'center') : 'center';
403
+
404
+ const isRow = s.flexDirection === 'row' || s.flexDirection === 'row-reverse';
405
+
406
+ if (verticalAlign && horizontalAlign) {
407
+ if (isRow) {
408
+ s.alignItems = verticalAlign;
409
+ s.justifyContent = mapJustifyValue(horizontal) ?? fallbackJustify;
410
+ } else {
411
+ s.alignItems = horizontalAlign;
412
+ s.justifyContent = mapJustifyValue(vertical) ?? 'center';
413
+ }
414
+ } else if (!isRow) {
415
+ s.alignItems = horizontalAlign ?? fallbackAlign;
416
+ s.justifyContent = mapJustifyValue(vertical) ?? 'center';
417
+ } else {
418
+ s.alignItems = verticalAlign ?? 'center';
419
+ s.justifyContent = mapJustifyValue(horizontal) ?? fallbackJustify;
420
+ }
421
+
422
+ if ((component as any).grow) s.flexGrow = 1;
423
+
424
+ return s;
425
+ }
426
+
427
+ export function positionStyles(component: TBaseComponent): ViewStyle {
428
+ const pos = (component as any).position as TContainerPosition | undefined;
429
+ if (!pos) return { position: 'relative' };
430
+
431
+ const [alignment, spot] = pos.split('-');
432
+ const s: ViewStyle = { position: 'absolute' };
433
+ const alignSelf = mapPositionAlignment(alignment);
434
+ if (alignSelf) s.alignSelf = alignSelf;
435
+
436
+ if (spot === 'top' || spot === 'bottom' || spot === 'left' || spot === 'right') {
437
+ (s as any)[spot] = 0;
438
+ }
439
+
440
+ return s;
441
+ }
442
+
443
+ export function backgroundColorStyle(component: TBaseComponent, inFocusedState: boolean = false): ViewStyle {
444
+ const fill = typeof component.fillColor === 'string' ? component.fillColor : undefined;
445
+ const focusedFill = typeof (component as any).focusedFillColor === 'string' ? (component as any).focusedFillColor : undefined;
446
+ const useFocused = inFocusedState && focusedFill;
447
+ const primary = useFocused ? focusedFill : fill;
448
+ const fallback = useFocused ? (component as any).focusedFillColorFallback : component.fillColorFallback;
449
+ const gradientSource = primary ?? fill;
450
+ if (isLinearGradient(gradientSource)) {
451
+ const fallbackColor = parseColor(fallback);
452
+ return fallbackColor ? { backgroundColor: fallbackColor } : { backgroundColor: 'transparent' };
453
+ }
454
+ const bg = parseColor(primary) ?? parseColor(fallback);
455
+ if (bg) return { backgroundColor: bg };
456
+ return {};
457
+ }
458
+
459
+ export function transformStyles(component: TBaseComponent, scaleFactor: number): ViewStyle {
460
+ const transforms: Array<{ translateX: number } | { translateY: number }> = [];
461
+ const mx = parseSize((component as any).moveX, scaleFactor);
462
+ const my = parseSize((component as any).moveY, scaleFactor);
463
+ if (mx) transforms.push({ translateX: mx });
464
+ if (my) transforms.push({ translateY: my });
465
+ if (transforms.length) return { transform: transforms } as ViewStyle;
466
+ return {};
467
+ }
468
+
469
+ export function applyStyles(component: TBaseComponent, scaleFactor: number = 1, inFocusedState: boolean = false, parentDirection?: string): NamiStyle {
470
+ return {
471
+ ...positionStyles(component),
472
+ ...paddingAndMarginStyles(component, scaleFactor),
473
+ ...borderStyles(component, scaleFactor, inFocusedState),
474
+ ...shadowStyles(component),
475
+ ...sizeStyles(component, scaleFactor, parentDirection),
476
+ ...layoutStyles(component, scaleFactor),
477
+ ...backgroundColorStyle(component, inFocusedState),
478
+ ...transformStyles(component, scaleFactor),
479
+ ...(component.zIndex != null ? { zIndex: component.zIndex } : {}),
480
+ } as NamiStyle;
481
+ }
482
+
483
+ export function focusedStyleOverrides(component: TBaseComponent, scaleFactor: number = 1): ViewStyle {
484
+ return {
485
+ ...backgroundColorStyle(component, true),
486
+ ...borderStyles(component, scaleFactor, true),
487
+ };
488
+ }
489
+
490
+ export function resolveFillImageUrl(fillImage: any): string | undefined {
491
+ if (!fillImage) return undefined;
492
+ if (typeof fillImage === 'string') return fillImage;
493
+ if (typeof fillImage === 'object' && typeof fillImage.url === 'string') return fillImage.url;
494
+ return undefined;
495
+ }
496
+
497
+ export function childSpacingStyle(index: number, component: { spacing?: any; direction?: string }, scaleFactor: number): ViewStyle {
498
+ if (!component?.spacing || index === 0) return {};
499
+ const spacing = parseSize(component.spacing, scaleFactor);
500
+ if (spacing == null) return {};
501
+ const isVertical = component.direction === 'vertical';
502
+ return isVertical ? { marginTop: spacing } : { marginLeft: spacing };
503
+ }
504
+
505
+ export function extractPrefixedStyles(component: Record<string, any>, prefix: string): Record<string, any> {
506
+ const out: Record<string, any> = {};
507
+ if (!component) return out;
508
+ const keys = Object.keys(component);
509
+ for (const key of keys) {
510
+ if (!key.startsWith(prefix)) continue;
511
+ const stripped = key.slice(prefix.length);
512
+ if (!stripped) continue;
513
+ const newKey = stripped.charAt(0).toLowerCase() + stripped.slice(1);
514
+ out[newKey] = component[key];
515
+ }
516
+ return out;
517
+ }
518
+
519
+ export function textStyles(
520
+ component: any,
521
+ scaleFactor: number = 1,
522
+ inFocusedState: boolean = false,
523
+ ): TextStyle {
524
+ const s: TextStyle = {};
525
+ const inferredVariant = inferFontNameVariant(component.fontName ?? component.fontFamily);
526
+ const requestedItalic = component.fontStyle === 'italic' || inferredVariant.italic;
527
+ const requestedBold =
528
+ inferredVariant.bold
529
+ || (
530
+ typeof component.fontWeight === 'string'
531
+ && (
532
+ component.fontWeight === 'bold'
533
+ || Number.parseInt(component.fontWeight, 10) >= 600
534
+ )
535
+ );
536
+ if (component.fontSize)
537
+ s.fontSize = parseSize(component.fontSize, scaleFactor);
538
+ const resolvedFont = resolveFontDescriptor(component.fontName ?? component.fontFamily, {
539
+ italic: requestedItalic,
540
+ bold: requestedBold,
541
+ });
542
+ if (resolvedFont.family) {
543
+ s.fontFamily = resolvedFont.family;
544
+ }
545
+
546
+ const color = inFocusedState
547
+ ? parseColor(component.focusedFontColor ?? component.activeFontColor)
548
+ ?? parseColor(component.fontColor)
549
+ ?? parseColor(component.textColor)
550
+ : parseColor(component.fontColor) ?? parseColor(component.textColor);
551
+ if (color) s.color = color;
552
+
553
+ if (resolvedFont.isHosted) {
554
+ s.fontWeight = 'normal';
555
+ s.fontStyle = 'normal';
556
+ if (shouldApplySyntheticItalic(requestedItalic, resolvedFont.variant)) {
557
+ s.transform = [{ skewX: '-10deg' }];
558
+ }
559
+ } else {
560
+ if (component.fontWeight) s.fontWeight = component.fontWeight;
561
+ if (component.fontStyle) s.fontStyle = component.fontStyle;
562
+ }
563
+ if (component.capitalize) s.textTransform = 'uppercase';
564
+ if (component.alignment) {
565
+ switch (component.alignment) {
566
+ case 'leading': case 'left': s.textAlign = 'left'; break;
567
+ case 'trailing': case 'right': s.textAlign = 'right'; break;
568
+ case 'center': s.textAlign = 'center'; break;
569
+ }
570
+ }
571
+ if (component.strikethrough) s.textDecorationLine = 'line-through';
572
+ if (component.letterSpacing) s.letterSpacing = component.letterSpacing;
573
+ if (component.lineHeight) {
574
+ s.lineHeight = parseSize(component.lineHeight, scaleFactor);
575
+ } else if (
576
+ s.fontSize
577
+ && (component.textType === 'legal' || component.component === 'text-list')
578
+ ) {
579
+ s.lineHeight = Number((s.fontSize * 1.2).toFixed(3));
580
+ }
581
+ // Android/TV adds extra font padding by default; disable for parity with web
582
+ (s as any).includeFontPadding = false;
583
+ const shadow = parseDropShadow(component.dropShadow);
584
+ if (shadow) {
585
+ s.textShadowColor = shadow.color ?? 'rgba(0,0,0,0.5)';
586
+ s.textShadowOffset = { width: shadow.x, height: shadow.y };
587
+ s.textShadowRadius = shadow.blur;
588
+ }
589
+
590
+ return s;
591
+ }
592
+
593
+ export function pickAndApplyBackgroundColor(component: TBaseComponent, inFocusedState: boolean = false): ViewStyle {
594
+ return backgroundColorStyle(component, inFocusedState);
595
+ }
596
+
597
+ export function applyGridStyles(component: TBaseComponent, scaleFactor: number = 1, inFocusedState: boolean = false, parentDirection?: string): NamiStyle {
598
+ return {
599
+ ...positionStyles(component),
600
+ ...paddingAndMarginStyles(component, scaleFactor),
601
+ ...borderStyles(component, scaleFactor, inFocusedState),
602
+ ...shadowStyles(component),
603
+ ...sizeStyles(component, scaleFactor, parentDirection),
604
+ ...backgroundColorStyle(component, inFocusedState),
605
+ ...transformStyles(component, scaleFactor),
606
+ ...(component.zIndex != null ? { zIndex: component.zIndex } : {}),
607
+ } as NamiStyle;
608
+ }
609
+
610
+ export function applySegmentFontStyles(styles: any, scaleFactor: number = 1): TextStyle {
611
+ const s: TextStyle = {};
612
+ const inferredVariant = inferFontNameVariant(styles.fontName ?? styles.fontFamily);
613
+ const requestedItalic = styles.fontStyle === 'italic' || inferredVariant.italic;
614
+ const requestedBold =
615
+ inferredVariant.bold
616
+ || (
617
+ typeof styles.fontWeight === 'string'
618
+ && (
619
+ styles.fontWeight === 'bold'
620
+ || Number.parseInt(styles.fontWeight, 10) >= 600
621
+ )
622
+ );
623
+ if (styles.fontSize != null)
624
+ s.fontSize = parseSize(styles.fontSize, scaleFactor);
625
+ const resolvedFont = resolveFontDescriptor(styles.fontName ?? styles.fontFamily, {
626
+ italic: requestedItalic,
627
+ bold: requestedBold,
628
+ });
629
+ if (resolvedFont.family) {
630
+ s.fontFamily = resolvedFont.family;
631
+ }
632
+ const color = parseColor(styles.fontColor ?? styles.textColor);
633
+ if (color) s.color = color;
634
+ if (resolvedFont.isHosted) {
635
+ s.fontWeight = 'normal';
636
+ s.fontStyle = 'normal';
637
+ if (shouldApplySyntheticItalic(requestedItalic, resolvedFont.variant)) {
638
+ s.transform = [{ skewX: '-10deg' }];
639
+ }
640
+ } else {
641
+ if (styles.fontWeight) s.fontWeight = styles.fontWeight;
642
+ if (styles.fontStyle) s.fontStyle = styles.fontStyle;
643
+ }
644
+ if (styles.alignment) {
645
+ switch (styles.alignment) {
646
+ case 'leading': case 'left': s.textAlign = 'left'; break;
647
+ case 'trailing': case 'right': s.textAlign = 'right'; break;
648
+ case 'center': s.textAlign = 'center'; break;
649
+ }
650
+ }
651
+
652
+ return s;
653
+ }
654
+
655
+ export function applySegmentStyles(styles: any, scaleFactor: number = 1, inFocusedState: boolean = false): ViewStyle {
656
+ return applyStyles(styles as any, scaleFactor, inFocusedState);
657
+ }
658
+
659
+ function shouldApplySyntheticItalic(
660
+ requestedItalic: boolean,
661
+ resolvedVariant?: string,
662
+ ): boolean {
663
+ if (!requestedItalic) {
664
+ return false;
665
+ }
666
+
667
+ return resolvedVariant !== 'italic' && resolvedVariant !== 'boldItalic';
668
+ }
@@ -0,0 +1,31 @@
1
+ import { Platform } from 'react-native';
2
+ import { useEffect } from 'react';
3
+
4
+ type FocusableRef = {
5
+ focus?: () => void;
6
+ requestTVFocus?: () => void;
7
+ };
8
+
9
+ export function useTVPreferredFocus(
10
+ ref: React.RefObject<FocusableRef | null>,
11
+ enabled: boolean,
12
+ ): void {
13
+ useEffect(() => {
14
+ if (!enabled) {
15
+ return;
16
+ }
17
+
18
+ const timer = setTimeout(() => {
19
+ if (typeof ref.current?.requestTVFocus === 'function') {
20
+ ref.current.requestTVFocus();
21
+ return;
22
+ }
23
+ if (Platform.isTV) {
24
+ return;
25
+ }
26
+ ref.current?.focus?.();
27
+ }, 0);
28
+
29
+ return () => clearTimeout(timer);
30
+ }, [enabled, ref]);
31
+ }