import { Platform } from 'react-native'; import type { ViewStyle, TextStyle, ImageStyle } from 'react-native'; import type { TBaseComponent, BorderSideType, TContainerPosition } from '@namiml/sdk-core'; import { inferFontNameVariant, resolveFontDescriptor } from './fonts'; export type NamiStyle = ViewStyle & TextStyle & Omit; const HEX_ALPHA_REGEX = /^#([0-9a-f]{8})$/i; const HEX_SHORT_ALPHA_REGEX = /^#([0-9a-f]{4})$/i; const HSLA_REGEX = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i; export function parseColor(color: any): string | undefined { if (!color) return undefined; if (typeof color === 'string') { const trimmed = color.trim(); if (!trimmed) return undefined; if (trimmed.toLowerCase().includes('gradient(')) return undefined; return normalizeColorString(trimmed); } if (color.rgba) { const { r, g, b, a } = color.rgba; return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a ?? 1})`; } if (color.hex) return normalizeColorString(color.hex); return undefined; } function normalizeColorString(input: string): string { const value = input.trim(); if (!value) return value; const match = value.match(/^rgba?\((.+)\)$/i); if (match) { const inside = match[1]; const [base, alphaRaw] = inside.split('/').map((v) => v.trim()); const parts = base.split(/[\s,]+/).filter(Boolean); const nums = parts.map((p) => p.trim()); const r = nums[0]; const g = nums[1]; const b = nums[2]; const a = alphaRaw ?? nums[3]; if (r != null && g != null && b != null) { if (a != null) { return `rgba(${r},${g},${b},${a})`; } return `rgb(${r},${g},${b})`; } } const hexAlphaMatch = value.match(HEX_ALPHA_REGEX); if (hexAlphaMatch) { const hex = hexAlphaMatch[1]; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); const a = parseInt(hex.slice(6, 8), 16) / 255; return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`; } const hexShortAlphaMatch = value.match(HEX_SHORT_ALPHA_REGEX); if (hexShortAlphaMatch) { const hex = hexShortAlphaMatch[1]; const r = parseInt(hex[0] + hex[0], 16); const g = parseInt(hex[1] + hex[1], 16); const b = parseInt(hex[2] + hex[2], 16); const a = parseInt(hex[3] + hex[3], 16) / 255; return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`; } const hslaMatch = value.match(HSLA_REGEX); if (hslaMatch) { const h = parseFloat(hslaMatch[1]); const s = parseFloat(hslaMatch[2]); const l = parseFloat(hslaMatch[3]); const a = hslaMatch[4] != null ? parseFloat(hslaMatch[4]) : undefined; const [r, g, b] = hslToRgb(h, s, l); if (a != null && !Number.isNaN(a)) { return `rgba(${r},${g},${b},${Number(a.toFixed(3))})`; } return `rgb(${r},${g},${b})`; } return value; } function hslToRgb(h: number, s: number, l: number): [number, number, number] { const hh = ((h % 360) + 360) % 360; const ss = Math.max(0, Math.min(100, s)) / 100; const ll = Math.max(0, Math.min(100, l)) / 100; const c = (1 - Math.abs(2 * ll - 1)) * ss; const x = c * (1 - Math.abs(((hh / 60) % 2) - 1)); const m = ll - c / 2; let r1 = 0; let g1 = 0; let b1 = 0; if (hh < 60) { r1 = c; g1 = x; b1 = 0; } else if (hh < 120) { r1 = x; g1 = c; b1 = 0; } else if (hh < 180) { r1 = 0; g1 = c; b1 = x; } else if (hh < 240) { r1 = 0; g1 = x; b1 = c; } else if (hh < 300) { r1 = x; g1 = 0; b1 = c; } else { r1 = c; g1 = 0; b1 = x; } const r = Math.round((r1 + m) * 255); const g = Math.round((g1 + m) * 255); const b = Math.round((b1 + m) * 255); return [r, g, b]; } export function isLinearGradient(value?: string): boolean { if (!value || typeof value !== 'string') return false; return value.trim().toLowerCase().startsWith('linear-gradient('); } export type LinearGradientSpec = { colors: string[]; locations?: number[]; angle?: number; }; function splitGradientArgs(input: string): string[] { const out: string[] = []; let current = ''; let depth = 0; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === '(') depth += 1; if (ch === ')') depth = Math.max(0, depth - 1); if (ch === ',' && depth === 0) { out.push(current.trim()); current = ''; continue; } current += ch; } if (current.trim()) out.push(current.trim()); return out; } function parseAngleToken(token: string): number | undefined { const t = token.trim().toLowerCase(); if (t.endsWith('deg')) { const num = parseFloat(t.replace('deg', '').trim()); return Number.isFinite(num) ? num : undefined; } if (t.startsWith('to ')) { // CSS gradient directions if (t.includes('right') && t.includes('top')) return 45; if (t.includes('right') && t.includes('bottom')) return 135; if (t.includes('left') && t.includes('bottom')) return 225; if (t.includes('left') && t.includes('top')) return 315; if (t.includes('right')) return 90; if (t.includes('left')) return 270; if (t.includes('bottom')) return 180; if (t.includes('top')) return 0; } return undefined; } export function parseSize(value: any, scaleFactor: number = 1): number | undefined { if (value === undefined || value === null) return undefined; if (typeof value === 'number') return value * scaleFactor; if (typeof value === 'string') { const num = parseFloat(value); if (!isNaN(num)) return num * scaleFactor; } return undefined; } export function parseSizeOrPercent(value: any, scaleFactor: number = 1): number | string | undefined { if (value === undefined || value === null) return undefined; if (typeof value === 'string' && value.endsWith('%')) return value; return parseSize(value, scaleFactor); } export function flexDirectionFromConfig(dir?: string): ViewStyle['flexDirection'] { switch (dir) { case 'horizontal': return 'row'; case 'vertical': return 'column'; case 'horizontal-reverse': return 'row-reverse'; case 'vertical-reverse': return 'column-reverse'; default: return 'column'; } } // NAM-2417: a directionless `button` component defaults to horizontal (icon left of // label), unlike containers which default to vertical via flexDirectionFromConfig. export function buttonFlexDirection(dir?: string): ViewStyle['flexDirection'] { return flexDirectionFromConfig(dir ?? 'horizontal'); } const ALIGNMENT_VALUE_MAP: Record = { top: 'flex-start', left: 'flex-start', right: 'flex-end', bottom: 'flex-end', start: 'flex-start', end: 'flex-end', leading: 'flex-start', trailing: 'flex-end', center: 'center', stretch: 'stretch', }; const JUSTIFY_VALUE_MAP: Record = { spaceBetween: 'space-between', 'space-between': 'space-between', spaceAround: 'space-around', 'space-around': 'space-around', spaceEvenly: 'space-evenly', 'space-evenly': 'space-evenly', top: 'flex-start', bottom: 'flex-end', left: 'flex-start', right: 'flex-end', start: 'flex-start', end: 'flex-end', leading: 'flex-start', trailing: 'flex-end', center: 'center', }; function mapAlignmentValue(align?: string): ViewStyle['alignItems'] | undefined { if (!align) return undefined; return ALIGNMENT_VALUE_MAP[align] ?? undefined; } function mapJustifyValue(align?: string): ViewStyle['justifyContent'] | undefined { if (!align) return undefined; return JUSTIFY_VALUE_MAP[align] ?? (ALIGNMENT_VALUE_MAP[align] as ViewStyle['justifyContent'] | undefined); } function mapPositionAlignment(align?: string): ViewStyle['alignSelf'] { return mapAlignmentValue(align); } export function paddingAndMarginStyles(component: TBaseComponent, scaleFactor: number): ViewStyle { const s: ViewStyle = {}; if (component.leftPadding != null) s.paddingLeft = parseSize(component.leftPadding, scaleFactor); if (component.rightPadding != null) s.paddingRight = parseSize(component.rightPadding, scaleFactor); if (component.topPadding != null) s.paddingTop = parseSize(component.topPadding, scaleFactor); if (component.bottomPadding != null) s.paddingBottom = parseSize(component.bottomPadding, scaleFactor); if (component.leftMargin != null) s.marginLeft = parseSize(component.leftMargin, scaleFactor); if (component.rightMargin != null) s.marginRight = parseSize(component.rightMargin, scaleFactor); if (component.topMargin != null) s.marginTop = parseSize(component.topMargin, scaleFactor); if (component.bottomMargin != null) s.marginBottom = parseSize(component.bottomMargin, scaleFactor); return s; } export function borderStyles(component: TBaseComponent, scaleFactor: number, inFocusedState: boolean = false): ViewStyle { const s: ViewStyle = {}; const bw = parseSize( inFocusedState ? (component.focusedBorderWidth ?? component.borderWidth) : component.borderWidth, scaleFactor ); const bc = parseColor( inFocusedState ? (component.focusedBorderColor ?? component.borderColor) : component.borderColor ); const br = parseSize( inFocusedState ? (component.focusedBorderRadius ?? component.borderRadius) : component.borderRadius, scaleFactor ); const roundBorders = inFocusedState ? (component.focusedRoundBorders ?? component.roundBorders) : component.roundBorders; if (roundBorders?.length) { const mapping: Record = { upperLeft: 'borderTopLeftRadius', upperRight: 'borderTopRightRadius', lowerLeft: 'borderBottomLeftRadius', lowerRight: 'borderBottomRightRadius', }; for (const corner of roundBorders) { const key = mapping[corner]; if (key && br != null) (s as any)[key] = br; } } else if (br != null) { s.borderRadius = br; } const sides: BorderSideType[] = inFocusedState ? (component.focusedBorders ?? component.borders ?? []) : (component.borders ?? []); if (sides.length > 0 && bw != null) { const sideMap: Record = { top: ['borderTopWidth', 'borderTopColor'], bottom: ['borderBottomWidth', 'borderBottomColor'], left: ['borderLeftWidth', 'borderLeftColor'], right: ['borderRightWidth', 'borderRightColor'], }; for (const side of sides) { const [wKey, cKey] = sideMap[side]; (s as any)[wKey] = bw; if (bc) (s as any)[cKey] = bc; } } else if (bw != null) { s.borderWidth = bw; if (bc) s.borderColor = bc; } else if (bc) { s.borderColor = bc; } return s; } function parseDropShadow(value?: string): { x: number; y: number; blur: number; color?: string } | null { if (!value || typeof value !== 'string') return null; const match = value.trim().match(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(.+)/); if (!match) return null; const x = parseFloat(match[1]); const y = parseFloat(match[2]); const blur = parseFloat(match[3]); const color = parseColor(match[4].trim()); return { x, y, blur, color: color ?? undefined }; } export function shadowStyles(component: TBaseComponent): ViewStyle { const parsed = parseDropShadow(component.dropShadow); if (!parsed) return {}; const { x, y, blur, color } = parsed; return { ...Platform.select({ ios: { shadowColor: color ?? 'rgba(0,0,0,0.5)', shadowOffset: { width: x, height: y }, shadowOpacity: 1, shadowRadius: blur, }, android: { elevation: blur ? Math.ceil(blur / 2) : 4, }, }), }; } export function sizeStyles( component: TBaseComponent, scaleFactor: number, parentDirection?: string ): ViewStyle { const s: ViewStyle = {}; const rawW = component.width ?? (component as any).fixedWidth; const rawH = component.height ?? (component as any).fixedHeight; const w = parseSizeOrPercent(rawW, scaleFactor); const h = parseSizeOrPercent(rawH, scaleFactor); if (w != null) s.width = w as any; if (h != null) s.height = h as any; s.maxWidth = '100%'; if (rawW === 'fitContent') { s.flexShrink = 0; s.flexGrow = 0; } if (rawH === 'fitContent') { s.flexGrow = 0; } if (parentDirection === 'horizontal' && rawW == null) { s.flex = 1; s.flexShrink = 1; (s as any).minWidth = 0; } else if (typeof rawW === 'string' && rawW.trim().endsWith('%')) { s.flexShrink = 1; (s as any).minWidth = 0; } if (typeof rawH === 'string' && rawH.trim().endsWith('%')) { s.flexShrink = 1; } const componentId = String((component as any).id ?? ''); const componentType = String((component as any).namiComponentType ?? ''); const isDividerLike = componentType === 'divider' || /^divider/i.test(componentId); if (isDividerLike && parentDirection === 'horizontal' && rawH === '100%') { (s as any).height = undefined; s.alignSelf = 'stretch'; s.flexGrow = 1; s.flexShrink = 0; } if (!parentDirection && rawW == null && rawH == null) { s.alignSelf = 'stretch'; } return s; } export function layoutStyles(component: TBaseComponent, scaleFactor: number): ViewStyle { const s: ViewStyle = {}; s.flexDirection = flexDirectionFromConfig((component as any).direction); const alignment = (component as any).alignment; const horizontal = (component as any).horizontalAlignment; const vertical = (component as any).verticalAlignment; const verticalAlign = vertical ? mapAlignmentValue(vertical) : undefined; const horizontalAlign = horizontal ? mapAlignmentValue(horizontal) : undefined; const fallbackAlign = alignment ? (mapAlignmentValue(alignment) ?? 'center') : 'center'; const fallbackJustify = alignment ? (mapJustifyValue(alignment) ?? 'center') : 'center'; const isRow = s.flexDirection === 'row' || s.flexDirection === 'row-reverse'; if (verticalAlign && horizontalAlign) { if (isRow) { s.alignItems = verticalAlign; s.justifyContent = mapJustifyValue(horizontal) ?? fallbackJustify; } else { s.alignItems = horizontalAlign; s.justifyContent = mapJustifyValue(vertical) ?? 'center'; } } else if (!isRow) { s.alignItems = horizontalAlign ?? fallbackAlign; s.justifyContent = mapJustifyValue(vertical) ?? 'center'; } else { s.alignItems = verticalAlign ?? 'center'; s.justifyContent = mapJustifyValue(horizontal) ?? fallbackJustify; } if ((component as any).grow) s.flexGrow = 1; return s; } export function positionStyles(component: TBaseComponent): ViewStyle { const pos = (component as any).position as TContainerPosition | undefined; if (!pos) return { position: 'relative' }; const [alignment, spot] = pos.split('-'); const s: ViewStyle = { position: 'absolute' }; const alignSelf = mapPositionAlignment(alignment); if (alignSelf) s.alignSelf = alignSelf; if (spot === 'top' || spot === 'bottom' || spot === 'left' || spot === 'right') { (s as any)[spot] = 0; } return s; } export function backgroundColorStyle(component: TBaseComponent, inFocusedState: boolean = false): ViewStyle { const fill = typeof component.fillColor === 'string' ? component.fillColor : undefined; const focusedFill = typeof (component as any).focusedFillColor === 'string' ? (component as any).focusedFillColor : undefined; const useFocused = inFocusedState && focusedFill; const primary = useFocused ? focusedFill : fill; const fallback = useFocused ? (component as any).focusedFillColorFallback : component.fillColorFallback; const gradientSource = primary ?? fill; if (isLinearGradient(gradientSource)) { const fallbackColor = parseColor(fallback); return fallbackColor ? { backgroundColor: fallbackColor } : { backgroundColor: 'transparent' }; } const bg = parseColor(primary) ?? parseColor(fallback); if (bg) return { backgroundColor: bg }; return {}; } export function transformStyles(component: TBaseComponent, scaleFactor: number): ViewStyle { const transforms: Array<{ translateX: number } | { translateY: number }> = []; const mx = parseSize((component as any).moveX, scaleFactor); const my = parseSize((component as any).moveY, scaleFactor); if (mx) transforms.push({ translateX: mx }); if (my) transforms.push({ translateY: my }); if (transforms.length) return { transform: transforms } as ViewStyle; return {}; } export function applyStyles(component: TBaseComponent, scaleFactor: number = 1, inFocusedState: boolean = false, parentDirection?: string): NamiStyle { return { ...positionStyles(component), ...paddingAndMarginStyles(component, scaleFactor), ...borderStyles(component, scaleFactor, inFocusedState), ...shadowStyles(component), ...sizeStyles(component, scaleFactor, parentDirection), ...layoutStyles(component, scaleFactor), ...backgroundColorStyle(component, inFocusedState), ...transformStyles(component, scaleFactor), ...(component.zIndex != null ? { zIndex: component.zIndex } : {}), } as NamiStyle; } export function focusedStyleOverrides(component: TBaseComponent, scaleFactor: number = 1): ViewStyle { return { ...backgroundColorStyle(component, true), ...borderStyles(component, scaleFactor, true), }; } export function resolveFillImageUrl(fillImage: any): string | undefined { if (!fillImage) return undefined; if (typeof fillImage === 'string') return fillImage; if (typeof fillImage === 'object' && typeof fillImage.url === 'string') return fillImage.url; return undefined; } export function childSpacingStyle(index: number, component: { spacing?: any; direction?: string }, scaleFactor: number): ViewStyle { if (!component?.spacing || index === 0) return {}; const spacing = parseSize(component.spacing, scaleFactor); if (spacing == null) return {}; const isVertical = component.direction === 'vertical'; return isVertical ? { marginTop: spacing } : { marginLeft: spacing }; } export function extractPrefixedStyles(component: Record, prefix: string): Record { const out: Record = {}; if (!component) return out; const keys = Object.keys(component); for (const key of keys) { if (!key.startsWith(prefix)) continue; const stripped = key.slice(prefix.length); if (!stripped) continue; const newKey = stripped.charAt(0).toLowerCase() + stripped.slice(1); out[newKey] = component[key]; } return out; } export function textStyles( component: any, scaleFactor: number = 1, inFocusedState: boolean = false, ): TextStyle { const s: TextStyle = {}; const inferredVariant = inferFontNameVariant(component.fontName ?? component.fontFamily); const requestedItalic = component.fontStyle === 'italic' || inferredVariant.italic; const requestedBold = inferredVariant.bold || ( typeof component.fontWeight === 'string' && ( component.fontWeight === 'bold' || Number.parseInt(component.fontWeight, 10) >= 600 ) ); if (component.fontSize) s.fontSize = parseSize(component.fontSize, scaleFactor); const resolvedFont = resolveFontDescriptor(component.fontName ?? component.fontFamily, { italic: requestedItalic, bold: requestedBold, }); if (resolvedFont.family) { s.fontFamily = resolvedFont.family; } const color = inFocusedState ? parseColor(component.focusedFontColor ?? component.activeFontColor) ?? parseColor(component.fontColor) ?? parseColor(component.textColor) : parseColor(component.fontColor) ?? parseColor(component.textColor); if (color) s.color = color; if (resolvedFont.isHosted) { s.fontWeight = 'normal'; s.fontStyle = 'normal'; if (shouldApplySyntheticItalic(requestedItalic, resolvedFont.variant)) { s.transform = [{ skewX: '-10deg' }]; } } else { if (component.fontWeight) s.fontWeight = component.fontWeight; if (component.fontStyle) s.fontStyle = component.fontStyle; } if (component.capitalize) s.textTransform = 'uppercase'; if (component.alignment) { switch (component.alignment) { case 'leading': case 'left': s.textAlign = 'left'; break; case 'trailing': case 'right': s.textAlign = 'right'; break; case 'center': s.textAlign = 'center'; break; } } if (component.strikethrough) s.textDecorationLine = 'line-through'; // NAM-1409: letterSpacing is an em fraction of font size (signed decimal in // [-1, 1]), but RN's Text `letterSpacing` is in POINTS, so convert. // Numeric strings ("0.08") coerce via Number(); non-numeric / an unresolved // `${var}` smart-text token -> NaN -> omitted (-> 0). const letterSpacingValue = typeof component.letterSpacing === 'string' ? Number(component.letterSpacing) : component.letterSpacing; if (Number.isFinite(letterSpacingValue) && s.fontSize) { s.letterSpacing = (letterSpacingValue as number) * s.fontSize; } // NAM-1409: lineHeight is an ABSOLUTE px value; only applied when it // coerces to a finite number > 0 (scaled by scaleFactor), replacing rather // than stacking with the computed default. Numeric strings coerce via // Number(); non-numeric / an unresolved `${var}` smart-text token -> NaN. const lineHeightValue = typeof component.lineHeight === 'string' ? Number(component.lineHeight) : component.lineHeight; if (Number.isFinite(lineHeightValue) && (lineHeightValue as number) > 0) { s.lineHeight = (lineHeightValue as number) * scaleFactor; } else if ( s.fontSize && (component.textType === 'legal' || component.component === 'text-list') ) { s.lineHeight = Number((s.fontSize * 1.2).toFixed(3)); } // Android/TV adds extra font padding by default; disable for parity with web (s as any).includeFontPadding = false; const shadow = parseDropShadow(component.dropShadow); if (shadow) { s.textShadowColor = shadow.color ?? 'rgba(0,0,0,0.5)'; s.textShadowOffset = { width: shadow.x, height: shadow.y }; s.textShadowRadius = shadow.blur; } return s; } export function pickAndApplyBackgroundColor(component: TBaseComponent, inFocusedState: boolean = false): ViewStyle { return backgroundColorStyle(component, inFocusedState); } export function applyGridStyles(component: TBaseComponent, scaleFactor: number = 1, inFocusedState: boolean = false, parentDirection?: string): NamiStyle { return { ...positionStyles(component), ...paddingAndMarginStyles(component, scaleFactor), ...borderStyles(component, scaleFactor, inFocusedState), ...shadowStyles(component), ...sizeStyles(component, scaleFactor, parentDirection), ...backgroundColorStyle(component, inFocusedState), ...transformStyles(component, scaleFactor), ...(component.zIndex != null ? { zIndex: component.zIndex } : {}), } as NamiStyle; } export function applySegmentFontStyles(styles: any, scaleFactor: number = 1): TextStyle { const s: TextStyle = {}; const inferredVariant = inferFontNameVariant(styles.fontName ?? styles.fontFamily); const requestedItalic = styles.fontStyle === 'italic' || inferredVariant.italic; const requestedBold = inferredVariant.bold || ( typeof styles.fontWeight === 'string' && ( styles.fontWeight === 'bold' || Number.parseInt(styles.fontWeight, 10) >= 600 ) ); if (styles.fontSize != null) s.fontSize = parseSize(styles.fontSize, scaleFactor); const resolvedFont = resolveFontDescriptor(styles.fontName ?? styles.fontFamily, { italic: requestedItalic, bold: requestedBold, }); if (resolvedFont.family) { s.fontFamily = resolvedFont.family; } const color = parseColor(styles.fontColor ?? styles.textColor); if (color) s.color = color; if (resolvedFont.isHosted) { s.fontWeight = 'normal'; s.fontStyle = 'normal'; if (shouldApplySyntheticItalic(requestedItalic, resolvedFont.variant)) { s.transform = [{ skewX: '-10deg' }]; } } else { if (styles.fontWeight) s.fontWeight = styles.fontWeight; if (styles.fontStyle) s.fontStyle = styles.fontStyle; } if (styles.alignment) { switch (styles.alignment) { case 'leading': case 'left': s.textAlign = 'left'; break; case 'trailing': case 'right': s.textAlign = 'right'; break; case 'center': s.textAlign = 'center'; break; } } return s; } export function applySegmentStyles(styles: any, scaleFactor: number = 1, inFocusedState: boolean = false): ViewStyle { return applyStyles(styles as any, scaleFactor, inFocusedState); } function shouldApplySyntheticItalic( requestedItalic: boolean, resolvedVariant?: string, ): boolean { if (!requestedItalic) { return false; } return resolvedVariant !== 'italic' && resolvedVariant !== 'boldItalic'; }