From 47e8085581711683272d558413f80463416c640f Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 4 Mar 2026 12:19:25 -0800 Subject: [PATCH] Make RN components fully theme-driven for multi-brand support Refactor all 18 React Native components from static DTColors imports to dynamic useDTTheme() context, enabling proper visual differentiation when switching between DT and Classic brands. Key changes: - Replace static DTColors with theme context in all components - Add shape tokens (bevel/radius) to DTExtendedTheme interface - Conditionally render SVG bevels (DT) vs borderRadius (Classic) - Configure fonts per brand (Tektur for DT, system sans-serif for Classic) - Update buildThemeFromBrand to parse shape and typography tokens - Fix Metro config for monorepo singleton resolution Co-Authored-By: Claude Opus 4.6 --- .../src/components/DTAccordion.tsx | 13 +-- .../react-native/src/components/DTButton.tsx | 22 ++++- .../react-native/src/components/DTCard.tsx | 42 ++++++--- .../src/components/DTCheckbox.tsx | 72 +++++++++----- .../react-native/src/components/DTChip.tsx | 20 ++-- .../react-native/src/components/DTDrawer.tsx | 76 ++++++++------- .../react-native/src/components/DTGallery.tsx | 94 +++++++++++++------ .../react-native/src/components/DTHexagon.tsx | 4 +- .../react-native/src/components/DTLabel.tsx | 21 +++-- .../src/components/DTMediaFrame.tsx | 38 ++++---- .../react-native/src/components/DTMenu.tsx | 17 ++-- .../react-native/src/components/DTModal.tsx | 6 +- .../src/components/DTProgressBar.tsx | 13 ++- .../src/components/DTQuantityStepper.tsx | 56 +++++++---- .../src/components/DTRadioGroup.tsx | 29 +++--- .../src/components/DTSearchInput.tsx | 16 ++-- .../react-native/src/components/DTSwitch.tsx | 12 +-- .../src/components/DTTextInput.tsx | 19 ++-- packages/react-native/src/index.ts | 2 +- packages/react-native/src/theme/paperTheme.ts | 14 +++ .../react-native/src/utils/variantColors.ts | 23 +++-- packages/showcase/mobile/metro.config.js | 28 ++++++ .../showcase/mobile/src/brand/buildTheme.ts | 38 +++++++- 23 files changed, 436 insertions(+), 239 deletions(-) diff --git a/packages/react-native/src/components/DTAccordion.tsx b/packages/react-native/src/components/DTAccordion.tsx index 75d64e6..375283c 100644 --- a/packages/react-native/src/components/DTAccordion.tsx +++ b/packages/react-native/src/components/DTAccordion.tsx @@ -20,7 +20,7 @@ import { Animated, } from 'react-native'; import {Text} from 'react-native-paper'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; export interface DTAccordionSection { @@ -93,6 +93,7 @@ export function DTAccordion({ onSectionToggle, style, }: DTAccordionProps) { + const theme = useDTTheme(); const [openKeys, setOpenKeys] = useState>( new Set(initialOpenKeys), ); @@ -114,8 +115,8 @@ export function DTAccordion({ [allowMultiple, onSectionToggle], ); - const inactiveColor = getVariantColor(variant); - const activeColor = getVariantColor(activeVariant); + const inactiveColor = getVariantColor(theme, variant); + const activeColor = getVariantColor(theme, activeVariant); return ( @@ -146,6 +147,7 @@ function AccordionSection({ inactiveColor: string; activeColor: string; }) { + const theme = useDTTheme(); const heightAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current; const chevronAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current; const [contentHeight, setContentHeight] = useState(0); @@ -187,6 +189,7 @@ function AccordionSection({ { borderColor: sectionColor, borderTopColor: sectionColor, + backgroundColor: theme.colors.background, opacity: pressed ? 0.7 : 1, }, ]}> @@ -214,7 +217,7 @@ function AccordionSection({ }, ]}> { const h = e.nativeEvent.layout.height; if (h > 0 && !measured) { @@ -240,7 +243,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - backgroundColor: DTColors.dark, borderWidth: 1, borderTopWidth: 5, paddingVertical: 12, @@ -256,7 +258,6 @@ const styles = StyleSheet.create({ content: { paddingHorizontal: 16, paddingVertical: 12, - backgroundColor: DTColors.dark, }, chevron: { justifyContent: 'center', diff --git a/packages/react-native/src/components/DTButton.tsx b/packages/react-native/src/components/DTButton.tsx index 5676549..08897ba 100644 --- a/packages/react-native/src/components/DTButton.tsx +++ b/packages/react-native/src/components/DTButton.tsx @@ -8,7 +8,7 @@ import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native'; import {Text} from 'react-native-paper'; import Svg, {Path} from 'react-native-svg'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; import {buildButtonBevelPath} from '../utils/bevelPaths'; import {useComponentLayout} from '../utils/useComponentLayout'; @@ -83,16 +83,18 @@ export function DTButton({ borderWidth = 2, bevelSize = 8, }: DTButtonProps) { + const theme = useDTTheme(); const {dimensions, onLayout, hasDimensions} = useComponentLayout(); const [pressed, setPressed] = useState(false); - const accentColor = getVariantColor(variant, color); + const accentColor = getVariantColor(theme, variant, color); const isContained = mode === 'contained'; const bgColor = isContained ? accentColor : 'transparent'; - const textColor = isContained ? DTColors.dark : accentColor; + const textColor = isContained ? theme.colors.onPrimary : accentColor; const {width, height} = dimensions; const opacity = disabled ? 0.5 : pressed ? 0.7 : 1; + const useBevels = theme.custom.bevelMd > 0; return ( setPressed(true)} onPressOut={() => setPressed(false)} style={[{opacity}, style]}> - - {hasDimensions && ( + + {useBevels && hasDimensions && ( 0; // Outer bevel path (full card shape) - const outerPath = hasDimensions + const outerPath = useBevels && hasDimensions ? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0) : ''; // Inner bevel path at border inset (for background + frame cutout) - const innerPath = hasDimensions + const innerPath = useBevels && hasDimensions ? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth) : ''; const content = ( - - {/* Background fill (behind content) */} - {hasDimensions && ( + + {/* Background fill (behind content) — beveled mode only */} + {useBevels && hasDimensions && ( - + )} @@ -139,7 +153,7 @@ export function DTCard({ {title && ( + style={[styles.headerText, {color: theme.colors.onPrimary}]}> {title} )} @@ -147,8 +161,8 @@ export function DTCard({ )} {children} - {/* Frame overlay (above content) — clips content at beveled border */} - {hasDimensions && ( + {/* Frame overlay (above content) — beveled mode only */} + {useBevels && hasDimensions && ( 0; const bevelSize = Math.round(size * 0.3); // Outer beveled path (border) - const outerPath = buildBeveledRectPath(size, size, { - corners: {topLeft: bevelSize, bottomRight: bevelSize}, - strokeWidth: borderWidth, - }); + const outerPath = useBevels + ? buildBeveledRectPath(size, size, { + corners: {topLeft: bevelSize, bottomRight: bevelSize}, + strokeWidth: borderWidth, + }) + : ''; return ( - - {/* Beveled border */} - - {/* Checkmark path (only when checked) */} - {checked && ( + {useBevels ? ( + - )} - + {checked && ( + + )} + + ) : ( + + {checked && ( + + )} + + )} {label && ( - + {label} )} diff --git a/packages/react-native/src/components/DTChip.tsx b/packages/react-native/src/components/DTChip.tsx index 02eac8b..76642de 100644 --- a/packages/react-native/src/components/DTChip.tsx +++ b/packages/react-native/src/components/DTChip.tsx @@ -8,16 +8,15 @@ // React import not needed with new JSX transform import { StyleSheet, ViewStyle, StyleProp } from 'react-native'; import { Chip, ChipProps } from 'react-native-paper'; -import { DTColors } from '../theme/colors'; - -type DTChipVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other'; +import { useDTTheme } from '../theme/DTThemeProvider'; +import { type DTVariant, getVariantColor } from '../utils/variantColors'; interface DTChipProps extends Omit { /** * Visual variant of the chip * @default 'normal' */ - variant?: DTChipVariant; + variant?: DTVariant; /** * Whether the chip is in selected state * @default false @@ -29,14 +28,6 @@ interface DTChipProps extends Omit { style?: StyleProp; } -const variantColors: Record = { - normal: DTColors.modeNormal, - emphasis: DTColors.modeEmphasis, - warning: DTColors.modeWarning, - success: DTColors.modeSuccess, - other: DTColors.modeOther, -}; - /** * DT-styled Chip component * @@ -56,7 +47,8 @@ export function DTChip({ children, ...props }: DTChipProps) { - const color = variantColors[variant]; + const theme = useDTTheme(); + const color = getVariantColor(theme, variant); const chipStyle: ViewStyle = { backgroundColor: selected ? color : 'transparent', @@ -71,7 +63,7 @@ export function DTChip({ mode="outlined" textStyle={[ styles.text, - { color: selected ? DTColors.dark : color }, + { color: selected ? theme.colors.onPrimary : color }, ]} style={[styles.chip, chipStyle, style]} selectedColor={color} diff --git a/packages/react-native/src/components/DTDrawer.tsx b/packages/react-native/src/components/DTDrawer.tsx index 8fcc205..fc94c76 100644 --- a/packages/react-native/src/components/DTDrawer.tsx +++ b/packages/react-native/src/components/DTDrawer.tsx @@ -31,7 +31,7 @@ import { import {Portal, Text} from 'react-native-paper'; import Svg, {Path} from 'react-native-svg'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; import {buildDrawerBevelPath} from '../utils/bevelPaths'; @@ -108,9 +108,11 @@ export function DTDrawer({ children, style, }: DTDrawerProps) { + const theme = useDTTheme(); + const useBevels = theme.custom.bevelMd > 0; const slideAnim = useRef(new Animated.Value(0)).current; const overlayAnim = useRef(new Animated.Value(0)).current; - const headerColor = getVariantColor(headingVariant); + const headerColor = getVariantColor(theme, headingVariant); const {width: screenWidth, height: screenHeight} = useWindowDimensions(); const insets = useSafeAreaInsets(); const effectiveWidth = Math.min(drawerWidth, screenWidth); @@ -172,7 +174,7 @@ export function DTDrawer({ {/* Overlay backdrop */} @@ -183,42 +185,49 @@ export function DTDrawer({ styles.panel, position === 'right' ? styles.panelRight : styles.panelLeft, {width: effectiveWidth, transform: [{translateX}]}, + !useBevels && { + borderWidth, + borderColor: headerColor, + borderRadius: theme.custom.radius, + backgroundColor: theme.colors.background, + overflow: 'hidden', + }, style, ]}> - {/* Dual-SVG-path technique: outer border + inner dark fill */} - - - {/* Outer path: accent border color */} - - {/* Inner path: dark background (inset by borderWidth) */} - - - + {/* Dual-SVG-path technique: outer border + inner dark fill — beveled mode only */} + {useBevels && ( + + + + + + + )} {/* Header bar */} - {heading} + {heading} ({opacity: pressed ? 0.7 : 1})}> - + @@ -235,9 +244,6 @@ export function DTDrawer({ } const styles = StyleSheet.create({ - overlay: { - backgroundColor: DTColors.overlay, - }, panel: { position: 'absolute', top: 0, @@ -258,13 +264,11 @@ const styles = StyleSheet.create({ zIndex: 1, }, headerText: { - color: DTColors.dark, fontWeight: '700', fontSize: 18, letterSpacing: 0.5, }, closeButton: { - color: DTColors.dark, fontSize: 20, fontWeight: '700', paddingHorizontal: 8, diff --git a/packages/react-native/src/components/DTGallery.tsx b/packages/react-native/src/components/DTGallery.tsx index de0c636..8ddc48b 100644 --- a/packages/react-native/src/components/DTGallery.tsx +++ b/packages/react-native/src/components/DTGallery.tsx @@ -26,7 +26,7 @@ import { Platform, } from 'react-native'; import Svg, {Path} from 'react-native-svg'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths'; import {useComponentLayout} from '../utils/useComponentLayout'; @@ -110,9 +110,10 @@ export function DTGallery({ borderWidth = 3, style, }: DTGalleryProps) { + const theme = useDTTheme(); const flatListRef = useRef(null); const {dimensions, onLayout, hasDimensions} = useComponentLayout(); - const accentColor = getVariantColor(variant); + const accentColor = getVariantColor(theme, variant); const atStart = activeIndex <= 0; const atEnd = activeIndex >= items.length - 1; @@ -171,22 +172,45 @@ export function DTGallery({ style={({pressed}) => ({ opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1, })}> - - - - + {useBevels ? ( + + + + + ) : ( + + + + + + )} ); }; @@ -204,6 +228,7 @@ export function DTGallery({ width: thumbnailSize, height: thumbnailSize, borderColor: isActive ? accentColor : 'transparent', + borderRadius: useBevels ? 0 : theme.custom.radiusSm, }, isActive && styles.thumbnailActive, ]}> @@ -235,30 +260,41 @@ export function DTGallery({ if (items.length === 0) return null; const {width, height} = dimensions; + const useBevels = theme.custom.bevelMd > 0; // Outer and inner bevel paths for frame overlay - const outerPath = hasDimensions + const outerPath = useBevels && hasDimensions ? buildMediaFrameBevelPath(width, height, bevelSize) : ''; - const innerPath = hasDimensions + const innerPath = useBevels && hasDimensions ? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth) : ''; return ( {/* Main image with beveled frame */} - - {/* Background fill */} - {hasDimensions && ( + + {/* Background fill — beveled mode only */} + {useBevels && hasDimensions && ( - + )} - + - {/* Frame overlay (above content) — clips image at beveled border */} - {hasDimensions && ( + {/* Frame overlay — beveled mode only */} + {useBevels && hasDimensions && ( - {/* Corner mask: fill area outside outer bevel with dark to hide overflow */} - {/* Colored border between outer and inner bevel paths */} { @@ -196,6 +198,7 @@ export function DTLabel({ }, [showIndicator, pingAnim]); const {width, height} = dimensions; + const useBevels = theme.custom.bevelMd > 0; return ( - {hasDimensions && ( + {useBevels && hasDimensions && ( {primaryText} @@ -236,7 +243,7 @@ export function DTLabel({ {secondaryText} @@ -251,7 +258,7 @@ export function DTLabel({ @@ -299,11 +306,9 @@ const styles = StyleSheet.create({ alignItems: 'center', }, primaryText: { - color: DTColors.dark, fontWeight: '500', }, secondaryText: { - color: DTColors.dark, fontWeight: '800', }, indicator: {}, diff --git a/packages/react-native/src/components/DTMediaFrame.tsx b/packages/react-native/src/components/DTMediaFrame.tsx index af399f5..61e3a46 100644 --- a/packages/react-native/src/components/DTMediaFrame.tsx +++ b/packages/react-native/src/components/DTMediaFrame.tsx @@ -13,7 +13,7 @@ import {ReactNode, useRef, useEffect} from 'react'; import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native'; import Svg, {Path, Defs, ClipPath} from 'react-native-svg'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; import {buildMediaFrameBevelPath} from '../utils/bevelPaths'; import {useComponentLayout} from '../utils/useComponentLayout'; @@ -91,9 +91,10 @@ export function DTMediaFrame({ style, contentStyle, }: DTMediaFrameProps) { + const theme = useDTTheme(); const {dimensions, onLayout, hasDimensions} = useComponentLayout(); const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current; - const accentColor = getVariantColor(variant, color); + const accentColor = getVariantColor(theme, variant, color); useEffect(() => { if (animated) { @@ -107,13 +108,14 @@ export function DTMediaFrame({ }, [animated, animationDelay, scaleAnim]); const {width, height} = dimensions; + const useBevels = theme.custom.bevelMd > 0; // Outer bevel path (full frame shape) - const outerPath = hasDimensions + const outerPath = useBevels && hasDimensions ? buildMediaFrameBevelPath(width, height, bevelSize) : ''; // Inner bevel path at border inset - const innerPath = hasDimensions + const innerPath = useBevels && hasDimensions ? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth) : ''; @@ -123,21 +125,27 @@ export function DTMediaFrame({ styles.container, aspectRatio ? {aspectRatio} : undefined, {transform: [{scale: scaleAnim}]}, + !useBevels && { + borderWidth, + borderColor: accentColor, + borderRadius: theme.custom.radius, + overflow: 'hidden', + }, style, ]} onLayout={onLayout}> - {/* Background fill (behind content) */} - {hasDimensions && ( + {/* Background fill (behind content) — beveled mode only */} + {useBevels && hasDimensions && ( - + )} - {/* Content clipped to the inner bevel shape */} - {hasDimensions ? ( + {/* Content */} + {useBevels && hasDimensions ? ( ) : ( - + {children} )} - {/* Frame overlay (above content) — masks content at beveled corners */} - {hasDimensions && ( + {/* Frame overlay (above content) — beveled mode only */} + {useBevels && hasDimensions && ( - {/* Outer frame border */} - {/* Corner masks: fill the rectangular area outside the bevel shape with black - to hide content that overflows past the beveled corners */} )} diff --git a/packages/react-native/src/components/DTMenu.tsx b/packages/react-native/src/components/DTMenu.tsx index 031280d..9ad3a5c 100644 --- a/packages/react-native/src/components/DTMenu.tsx +++ b/packages/react-native/src/components/DTMenu.tsx @@ -20,7 +20,7 @@ import { Animated, } from 'react-native'; import {Menu, Text} from 'react-native-paper'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; export interface DTMenuItem { @@ -163,7 +163,8 @@ export function DTMenuDropdown({ onDismiss, style, }: DTMenuDropdownProps) { - const accentColor = getVariantColor(variant); + const theme = useDTTheme(); + const accentColor = getVariantColor(theme, variant); return ( {items.map(item => ( @@ -189,7 +190,7 @@ export function DTMenuDropdown({ item.onPress?.(); onDismiss(); }} - titleStyle={[styles.dropdownItemTitle, {color: DTColors.light}]} + titleStyle={[styles.dropdownItemTitle, {color: theme.colors.onSurface}]} style={styles.dropdownItem} /> ))} @@ -210,10 +211,11 @@ function DTMenuItemRow({ activeVariant: DTVariant; level: number; }) { + const theme = useDTTheme(); const [expanded, setExpanded] = useState(false); const hasChildren = item.items && item.items.length > 0; const isActive = item.isActive || expanded; - const itemColor = getVariantColor(isActive ? activeVariant : variant); + const itemColor = getVariantColor(theme, isActive ? activeVariant : variant); const heightAnim = useRef(new Animated.Value(0)).current; const chevronAnim = useRef(new Animated.Value(0)).current; @@ -265,7 +267,7 @@ function DTMenuItemRow({ borderTopColor: itemColor, backgroundColor: isActive ? `${itemColor}20` - : DTColors.dark, + : theme.colors.background, paddingLeft: 16 + level * 16, opacity: pressed ? 0.7 : 1, }, @@ -360,7 +362,6 @@ const styles = StyleSheet.create({ dropdownContent: { borderWidth: 2, borderRadius: 0, - backgroundColor: DTColors.dark, }, dropdownItem: { borderRadius: 0, diff --git a/packages/react-native/src/components/DTModal.tsx b/packages/react-native/src/components/DTModal.tsx index 0c33e73..8de0471 100644 --- a/packages/react-native/src/components/DTModal.tsx +++ b/packages/react-native/src/components/DTModal.tsx @@ -11,7 +11,7 @@ import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform} from 'react-native'; import {Portal, Modal} from 'react-native-paper'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant} from '../utils/variantColors'; import {DTCard} from './DTCard'; @@ -75,6 +75,8 @@ export function DTModal({ contentStyle, style, }: DTModalProps) { + const theme = useDTTheme(); + return ( @@ -120,12 +122,14 @@ export function DTProgressBar({ function VerticalProgressBar({ value, color, + trackColor, width, showLabel, style, }: { value: number; color: string; + trackColor: string; width: number; showLabel: boolean; style?: StyleProp; @@ -135,7 +139,7 @@ function VerticalProgressBar({ return ( - + 0; + const accentColor = getVariantColor(theme, variant, color); const config = sizeConfigs[size]; const atMin = value <= min; const atMax = value >= max; @@ -168,21 +170,37 @@ export function DTQuantityStepper({ style={({pressed}) => ({ opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1, })}> - - - - + {useBevels ? ( + + + + + ) : ( + + + {type === 'minus' ? '−' : '+'} + + + )} ); }; @@ -190,7 +208,7 @@ export function DTQuantityStepper({ return ( {label && ( - {label} + {label} )} {renderStepButton('minus', handleDecrement, atMin)} diff --git a/packages/react-native/src/components/DTRadioGroup.tsx b/packages/react-native/src/components/DTRadioGroup.tsx index 2835d2a..e059147 100644 --- a/packages/react-native/src/components/DTRadioGroup.tsx +++ b/packages/react-native/src/components/DTRadioGroup.tsx @@ -14,7 +14,7 @@ import {createContext, useContext} from 'react'; import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native'; import {Text} from 'react-native-paper'; import Svg, {Polygon} from 'react-native-svg'; -import {DTColors, getColor} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; // --- Context --- @@ -115,6 +115,15 @@ function hexPoints(cx: number, cy: number, r: number): string { return pts.join(' '); } +/** Convert hex color to rgba with given alpha */ +function hexToRgba(hex: string, alpha: number): string { + const clean = hex.replace('#', ''); + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + /** * DT-styled RadioOption with hexagonal indicator * @@ -130,6 +139,7 @@ export function DTRadioOption({ disabled = false, style, }: DTRadioOptionProps) { + const theme = useDTTheme(); const context = useContext(RadioContext); if (!context) { throw new Error('DTRadioOption must be used inside DTRadioGroup'); @@ -137,14 +147,9 @@ export function DTRadioOption({ const {value: selectedValue, onValueChange, variant} = context; const isSelected = selectedValue === value; - const accentColor = getVariantColor(variant); - const selectedBgColor = getColor( - variant === 'normal' - ? 'modeNormalSelected' - : variant === 'emphasis' - ? 'modeEmphasisSelected' - : 'modeNormal', - ); + const accentColor = getVariantColor(theme, variant); + const selectedBgColor = hexToRgba(accentColor, 0.7); + const unselectedBorderColor = hexToRgba(theme.custom.modeNormal, 0.3); const opacity = disabled ? 0.5 : 1; return ( @@ -153,7 +158,7 @@ export function DTRadioOption({ style={({pressed}) => [ styles.option, { - borderColor: isSelected ? accentColor : getColor('modeNormal', 0.3), + borderColor: isSelected ? accentColor : unselectedBorderColor, backgroundColor: isSelected ? selectedBgColor : 'transparent', opacity: pressed ? 0.7 : opacity, }, @@ -183,7 +188,7 @@ export function DTRadioOption({ {label} @@ -192,7 +197,7 @@ export function DTRadioOption({ variant="bodySmall" style={[ styles.optionDescription, - {color: isSelected ? DTColors.dark : DTColors.disabled}, + {color: isSelected ? theme.colors.onPrimary : theme.colors.onSurfaceDisabled}, ]}> {description} diff --git a/packages/react-native/src/components/DTSearchInput.tsx b/packages/react-native/src/components/DTSearchInput.tsx index 14797c3..e7b7637 100644 --- a/packages/react-native/src/components/DTSearchInput.tsx +++ b/packages/react-native/src/components/DTSearchInput.tsx @@ -11,7 +11,7 @@ import {useState, useRef, useEffect} from 'react'; import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Animated} from 'react-native'; import {Searchbar, ActivityIndicator} from 'react-native-paper'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; interface DTSearchInputProps { @@ -86,7 +86,8 @@ export function DTSearchInput({ style, inputStyle, }: DTSearchInputProps) { - const accentColor = getVariantColor(variant, color); + const theme = useDTTheme(); + const accentColor = getVariantColor(theme, variant, color); const [focused, setFocused] = useState(false); const focusAnim = useRef(new Animated.Value(0)).current; @@ -110,7 +111,7 @@ export function DTSearchInput({ onBlur={() => setFocused(false)} returnKeyType="search" placeholder={placeholder} - placeholderTextColor={DTColors.disabled} + placeholderTextColor={theme.colors.onSurfaceDisabled} editable={!disabled} autoFocus={autoFocus} icon={ @@ -119,13 +120,13 @@ export function DTSearchInput({ : 'magnify' } iconColor={accentColor} - inputStyle={[styles.input, {color: DTColors.light}, inputStyle]} - style={[styles.searchbar, {borderColor: accentColor}]} + inputStyle={[styles.input, {color: theme.colors.onSurface}, inputStyle]} + style={[styles.searchbar, {borderColor: accentColor, backgroundColor: theme.colors.background}]} rippleColor={accentColor} theme={{ colors: { - elevation: {level3: DTColors.dark}, - onSurface: DTColors.light, + elevation: {level3: theme.colors.background}, + onSurface: theme.colors.onSurface, onSurfaceVariant: accentColor, }, roundness: 0, @@ -149,7 +150,6 @@ const styles = StyleSheet.create({ container: {}, searchbar: { borderWidth: 2, - backgroundColor: DTColors.dark, elevation: 0, }, input: { diff --git a/packages/react-native/src/components/DTSwitch.tsx b/packages/react-native/src/components/DTSwitch.tsx index 674ad5a..07d216b 100644 --- a/packages/react-native/src/components/DTSwitch.tsx +++ b/packages/react-native/src/components/DTSwitch.tsx @@ -15,7 +15,7 @@ import { Animated, } from 'react-native'; import {Text} from 'react-native-paper'; -import {DTColors} from '../theme/colors'; +import {useDTTheme} from '../theme/DTThemeProvider'; import {type DTVariant, getVariantColor} from '../utils/variantColors'; const TRACK_WIDTH = 48; @@ -73,7 +73,8 @@ export function DTSwitch({ color, style, }: DTSwitchProps) { - const accentColor = getVariantColor(variant, color); + const theme = useDTTheme(); + const accentColor = getVariantColor(theme, variant, color); const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current; useEffect(() => { @@ -91,10 +92,9 @@ export function DTSwitch({ const trackColor = thumbAnim.interpolate({ inputRange: [0, 1], - outputRange: [DTColors.disabledBackground, accentColor], + outputRange: [theme.colors.surfaceDisabled, accentColor], }); - return ( !disabled && onValueChange(!value)} @@ -104,7 +104,7 @@ export function DTSwitch({ style, ]}> {label && ( - {label} + {label} )} { const anim = Animated.timing(focusAnim, { @@ -101,7 +102,7 @@ export function DTTextInput({ {error && errorMessage && ( - + {errorMessage} @@ -151,9 +152,7 @@ export function DTTextInput({ } const styles = StyleSheet.create({ - inputWrapper: { - backgroundColor: DTColors.dark, - }, + inputWrapper: {}, input: { backgroundColor: 'transparent', }, diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index d424b7c..9144227 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -27,7 +27,7 @@ export { DTThemeProvider, useDTTheme } from './theme/DTThemeProvider'; // Shared utilities export type { DTVariant } from './utils/variantColors'; -export { variantColorMap, getVariantColor } from './utils/variantColors'; +export { getVariantColor } from './utils/variantColors'; export { buildBeveledRectPath } from './utils/bevelPaths'; export { useComponentLayout } from './utils/useComponentLayout'; diff --git a/packages/react-native/src/theme/paperTheme.ts b/packages/react-native/src/theme/paperTheme.ts index 2162341..752e583 100644 --- a/packages/react-native/src/theme/paperTheme.ts +++ b/packages/react-native/src/theme/paperTheme.ts @@ -124,6 +124,14 @@ export interface DTExtendedTheme extends MD3Theme { modeOther: string; border: string; borderEmphasis: string; + /** Bevel sizes in pixels (0 = no bevels, use borderRadius instead) */ + bevelSm: number; + bevelMd: number; + bevelLg: number; + /** Border radius in pixels (0 = angular/beveled) */ + radiusSm: number; + radius: number; + radiusLg: number; }; } @@ -139,6 +147,12 @@ export const DTExtendedDarkTheme: DTExtendedTheme = { modeOther: DTColors.modeOther, border: DTColors.border, borderEmphasis: DTColors.borderEmphasis, + bevelSm: 16, + bevelMd: 32, + bevelLg: 40, + radiusSm: 0, + radius: 0, + radiusLg: 0, }, }; diff --git a/packages/react-native/src/utils/variantColors.ts b/packages/react-native/src/utils/variantColors.ts index 324fb97..2a09375 100644 --- a/packages/react-native/src/utils/variantColors.ts +++ b/packages/react-native/src/utils/variantColors.ts @@ -2,26 +2,31 @@ * Centralized variant type and color mapping * * Used by all DT components for consistent mode/variant color resolution. + * Reads colors from the active DTExtendedTheme rather than static defaults. */ -import {DTColors} from '../theme/colors'; +import type {DTExtendedTheme} from '../theme/paperTheme'; export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other'; -export const variantColorMap: Record = { - normal: DTColors.modeNormal, - emphasis: DTColors.modeEmphasis, - warning: DTColors.modeWarning, - success: DTColors.modeSuccess, - other: DTColors.modeOther, +/** String-typed keys in DTExtendedTheme['custom'] that map to mode colors */ +type ModeColorKey = 'modeNormal' | 'modeEmphasis' | 'modeWarning' | 'modeSuccess' | 'modeOther'; + +const variantToThemeKey: Record = { + normal: 'modeNormal', + emphasis: 'modeEmphasis', + warning: 'modeWarning', + success: 'modeSuccess', + other: 'modeOther', }; /** - * Resolve variant to color, with optional custom color override. + * Resolve variant to color from the active theme, with optional custom color override. */ export function getVariantColor( + theme: DTExtendedTheme, variant: DTVariant, customColor?: string, ): string { - return customColor ?? variantColorMap[variant]; + return customColor ?? theme.custom[variantToThemeKey[variant]]; } diff --git a/packages/showcase/mobile/metro.config.js b/packages/showcase/mobile/metro.config.js index 7ba9cfc..a31c3d0 100644 --- a/packages/showcase/mobile/metro.config.js +++ b/packages/showcase/mobile/metro.config.js @@ -15,6 +15,34 @@ config.resolver.nodeModulesPaths = [ path.resolve(monorepoRoot, 'node_modules'), ]; +// Force singleton packages to always resolve from the mobile project. +// In monorepos, Metro can bundle two copies of react (one from root, one from +// the project) causing "Cannot read property 'use' of null" crashes. +const singletons = ['react', 'react-native', 'react-native-safe-area-context']; + +config.resolver.resolveRequest = (context, moduleName, platform) => { + // Check if this is a singleton or a deep import of one (e.g. react/jsx-runtime) + const isSingleton = singletons.some( + (s) => moduleName === s || moduleName.startsWith(s + '/') + ); + + if (isSingleton) { + // Resolve as if the import originated from the mobile project root + const mobileContext = { + ...context, + resolveRequest: undefined, + originModulePath: path.join(projectRoot, 'package.json'), + }; + return context.resolveRequest(mobileContext, moduleName, platform); + } + + return context.resolveRequest( + { ...context, resolveRequest: undefined }, + moduleName, + platform, + ); +}; + // Block sibling packages' node_modules to prevent duplicate native modules const packagesDir = path.join(monorepoRoot, 'packages'); const escapedPath = packagesDir.replace(/[/\\]/g, '[/\\\\]'); diff --git a/packages/showcase/mobile/src/brand/buildTheme.ts b/packages/showcase/mobile/src/brand/buildTheme.ts index 2e93e51..fdc8621 100644 --- a/packages/showcase/mobile/src/brand/buildTheme.ts +++ b/packages/showcase/mobile/src/brand/buildTheme.ts @@ -1,7 +1,35 @@ -import { MD3DarkTheme } from 'react-native-paper'; +import { Platform } from 'react-native'; +import { MD3DarkTheme, configureFonts } from 'react-native-paper'; import type { BrandTokens } from '@dangerousthings/tokens'; import type { DTExtendedTheme } from '@dangerousthings/react-native'; +/** Parse CSS size token ("1rem", "0.25rem", "0px", "0") to pixels (1rem = 16px) */ +function parseSize(value: string): number { + if (value === '0' || value === '0px') return 0; + const num = parseFloat(value); + if (value.endsWith('rem')) return num * 16; + if (value.endsWith('px')) return num; + return num; +} + +/** Build RNP font config from brand typography tokens */ +function buildFonts(brand: BrandTokens) { + const usesTektur = brand.typography.heading.includes('Tektur'); + if (!usesTektur) { + // System sans-serif — use RNP defaults + return MD3DarkTheme.fonts; + } + // Tektur custom font + const fontFamily = Platform.select({ + ios: 'Tektur', + android: 'Tektur-Regular', + default: 'Tektur', + })!; + return configureFonts({ + config: { fontFamily }, + }); +} + export function buildThemeFromBrand(brand: BrandTokens): DTExtendedTheme { const colors = brand.dark; @@ -9,7 +37,7 @@ export function buildThemeFromBrand(brand: BrandTokens): DTExtendedTheme { ...MD3DarkTheme, dark: true, roundness: brand.shape.radiusSm === '0' || brand.shape.radiusSm === '0px' ? 0 : 4, - fonts: MD3DarkTheme.fonts, + fonts: buildFonts(brand), colors: { ...MD3DarkTheme.colors, primary: colors.primary, @@ -63,6 +91,12 @@ export function buildThemeFromBrand(brand: BrandTokens): DTExtendedTheme { modeOther: colors.other, border: colors.border, borderEmphasis: colors.secondary, + bevelSm: parseSize(brand.shape.bevelSm), + bevelMd: parseSize(brand.shape.bevelMd), + bevelLg: parseSize(brand.shape.bevelLg), + radiusSm: parseSize(brand.shape.radiusSm), + radius: parseSize(brand.shape.radius), + radiusLg: parseSize(brand.shape.radiusLg), }, }; }