initial commit

This commit is contained in:
michael
2026-03-03 17:59:56 -08:00
commit 1c8d5855e6
56 changed files with 11767 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
{
"name": "@dangerousthings/react-native",
"version": "0.1.0",
"description": "React Native themed components for the Dangerous Things design system",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dangerousthings/tokens": "*"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.72.0",
"react-native-paper": ">=5.0.0",
"react-native-safe-area-context": ">=4.0.0",
"react-native-svg": ">=13.0.0"
},
"peerDependenciesMeta": {
"expo-font": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-native": "^0.72.8",
"react-native-paper": "^5.15.0",
"react-native-safe-area-context": "^5.7.0",
"react-native-svg": "^15.15.3",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,276 @@
/**
* DT Accordion Component
*
* Custom-built accordion with animated height expansion following
* the Dangerous Things angular aesthetic.
*
* Web CSS reference (FilterAccordion):
* - menu-item-clipped style headers with thick top border
* - Expand/collapse with height animation
* - Active sections switch to activeVariant color
*/
import {useState, useCallback, useRef, useEffect} from 'react';
import {
StyleSheet,
View,
ViewStyle,
StyleProp,
Pressable,
Animated,
} from 'react-native';
import {Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
export interface DTAccordionSection {
/**
* Unique key for the section
*/
key: string;
/**
* Section header title
*/
title: string;
/**
* Section content (rendered when expanded)
*/
children: React.ReactNode;
}
interface DTAccordionProps {
/**
* Array of accordion sections
*/
sections: DTAccordionSection[];
/**
* Visual variant for section headers
* @default 'normal'
*/
variant?: DTVariant;
/**
* Variant color for active/expanded sections
* @default 'emphasis'
*/
activeVariant?: DTVariant;
/**
* Allow multiple sections open simultaneously
* @default false
*/
allowMultiple?: boolean;
/**
* Section keys to open initially
*/
initialOpenKeys?: string[];
/**
* Called when a section is toggled
*/
onSectionToggle?: (key: string, isOpen: boolean) => void;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled Accordion with animated height expansion
*
* @example
* <DTAccordion
* sections={[
* { key: 'size', title: 'Size', children: <SizeFilter /> },
* { key: 'color', title: 'Color', children: <ColorFilter /> },
* ]}
* variant="normal"
* />
*/
export function DTAccordion({
sections,
variant = 'normal',
activeVariant = 'emphasis',
allowMultiple = false,
initialOpenKeys,
onSectionToggle,
style,
}: DTAccordionProps) {
const [openKeys, setOpenKeys] = useState<Set<string>>(
new Set(initialOpenKeys),
);
const toggleSection = useCallback(
(key: string) => {
setOpenKeys(prev => {
const next = new Set(allowMultiple ? prev : []);
const isOpening = !prev.has(key);
if (isOpening) {
next.add(key);
} else {
next.delete(key);
}
onSectionToggle?.(key, isOpening);
return next;
});
},
[allowMultiple, onSectionToggle],
);
const inactiveColor = getVariantColor(variant);
const activeColor = getVariantColor(activeVariant);
return (
<View style={[styles.container, style]}>
{sections.map(section => (
<AccordionSection
key={section.key}
section={section}
isOpen={openKeys.has(section.key)}
onToggle={() => toggleSection(section.key)}
inactiveColor={inactiveColor}
activeColor={activeColor}
/>
))}
</View>
);
}
function AccordionSection({
section,
isOpen,
onToggle,
inactiveColor,
activeColor,
}: {
section: DTAccordionSection;
isOpen: boolean;
onToggle: () => void;
inactiveColor: string;
activeColor: string;
}) {
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);
const [measured, setMeasured] = useState(false);
const sectionColor = isOpen ? activeColor : inactiveColor;
useEffect(() => {
Animated.parallel([
Animated.timing(heightAnim, {
toValue: isOpen ? 1 : 0,
duration: 250,
useNativeDriver: false,
}),
Animated.timing(chevronAnim, {
toValue: isOpen ? 1 : 0,
duration: 250,
useNativeDriver: true,
}),
]).start();
}, [isOpen, heightAnim, chevronAnim]);
const animatedHeight = heightAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, contentHeight],
});
const chevronRotate = chevronAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '180deg'],
});
return (
<View>
{/* Header */}
<Pressable
onPress={onToggle}
style={({pressed}) => [
styles.header,
{
borderColor: sectionColor,
borderTopColor: sectionColor,
opacity: pressed ? 0.7 : 1,
},
]}>
<Text style={[styles.title, {color: sectionColor}]}>
{section.title}
</Text>
<Animated.View
style={[
styles.chevron,
{transform: [{rotate: chevronRotate}]},
]}>
<View
style={[styles.chevronArrow, {borderTopColor: sectionColor}]}
/>
</Animated.View>
</Pressable>
{/* Animated content */}
<Animated.View
style={[
styles.contentWrapper,
{
height: measured ? animatedHeight : undefined,
overflow: 'hidden',
},
]}>
<View
style={styles.content}
onLayout={e => {
const h = e.nativeEvent.layout.height;
if (h > 0 && !measured) {
setContentHeight(h);
setMeasured(true);
if (!isOpen) {
heightAnim.setValue(0);
}
}
}}>
{section.children}
</View>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: DTColors.dark,
borderWidth: 1,
borderTopWidth: 5,
paddingVertical: 12,
paddingHorizontal: 16,
},
title: {
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
fontSize: 14,
},
contentWrapper: {},
content: {
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: DTColors.dark,
},
chevron: {
justifyContent: 'center',
alignItems: 'center',
width: 24,
height: 24,
},
chevronArrow: {
width: 0,
height: 0,
borderLeftWidth: 6,
borderRightWidth: 6,
borderTopWidth: 8,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});

View File

@@ -0,0 +1,151 @@
/**
* DT Button Component
*
* A themed button following the Dangerous Things design language
* with SVG-based beveled corners.
*/
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
import {useState} from 'react';
interface DTButtonProps {
/**
* Button content/label
*/
children: React.ReactNode;
/**
* Visual variant of the button
* @default 'normal'
*/
variant?: DTVariant;
/**
* Button display mode
* - 'outlined': Border with transparent background (default)
* - 'contained': Filled background
* @default 'outlined'
*/
mode?: 'outlined' | 'contained';
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Press handler
*/
onPress?: () => void;
/**
* Whether button is disabled
*/
disabled?: boolean;
/**
* Additional styles for container
*/
style?: StyleProp<ViewStyle>;
/**
* Border width
* @default 2
*/
borderWidth?: number;
/**
* Bevel size in pixels
* @default 8
*/
bevelSize?: number;
}
/**
* DT-styled Button component with SVG beveled corners
*
* @example
* <DTButton variant="normal" onPress={handleScan}>
* Scan NFC Tag
* </DTButton>
*
* @example
* <DTButton variant="emphasis" mode="contained" onPress={handleAction}>
* View Results
* </DTButton>
*/
export function DTButton({
children,
variant = 'normal',
mode = 'outlined',
color,
onPress,
disabled = false,
style,
borderWidth = 2,
bevelSize = 8,
}: DTButtonProps) {
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const [pressed, setPressed] = useState(false);
const accentColor = getVariantColor(variant, color);
const isContained = mode === 'contained';
const bgColor = isContained ? accentColor : 'transparent';
const textColor = isContained ? DTColors.dark : accentColor;
const {width, height} = dimensions;
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
return (
<Pressable
onPress={onPress}
disabled={disabled}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
style={[{opacity}, style]}>
<View style={styles.container} onLayout={onLayout}>
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path
d={buildButtonBevelPath(width, height, bevelSize, borderWidth)}
fill={bgColor}
stroke={accentColor}
strokeWidth={borderWidth}
/>
</Svg>
)}
<View style={styles.content}>
{typeof children === 'string' ? (
<Text variant="labelLarge" style={[styles.label, {color: textColor}]}>
{children}
</Text>
) : (
children
)}
</View>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
minHeight: 44,
},
content: {
position: 'relative',
zIndex: 1,
paddingVertical: 12,
paddingHorizontal: 24,
alignItems: 'center',
justifyContent: 'center',
},
label: {
fontWeight: '600',
letterSpacing: 1,
textTransform: 'uppercase',
},
});

View File

@@ -0,0 +1,221 @@
/**
* DT Card Component
*
* A themed card following the Dangerous Things design language
* with SVG-based beveled corners matching the web storefront style.
*
* Web CSS reference:
* - Bottom-right bevel: 2em (~32px)
* - Bottom-left bevel: 1em (~16px)
* - Border width: 0.2em (~3px)
*/
import {ReactNode} from 'react';
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildCardBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
interface DTCardProps {
children: ReactNode;
/**
* Color mode for the card accent
* @default 'normal'
*/
mode?: DTVariant;
/**
* Custom border color (overrides mode)
*/
borderColor?: string;
/**
* Card title (displayed in header)
*/
title?: string;
/**
* Whether to show the accent header bar
* @default true when title is provided
*/
showHeader?: boolean;
/**
* Additional styles for the card container
*/
style?: StyleProp<ViewStyle>;
/**
* Additional styles for the content area
*/
contentStyle?: StyleProp<ViewStyle>;
/**
* Inner content padding
* @default 16
*/
padding?: number;
/**
* Border width in pixels
* @default 3
*/
borderWidth?: number;
/**
* Bottom-right bevel size in pixels
* @default 32
*/
bevelSize?: number;
/**
* Bottom-left bevel size in pixels (smaller accent bevel)
* @default 16
*/
bevelSizeSmall?: number;
/**
* Background color
* @default '#000000'
*/
backgroundColor?: string;
/**
* Press handler
*/
onPress?: () => void;
}
/**
* DT-styled Card component with SVG beveled corners
*
* @example
* <DTCard title="Detected Chip" mode="normal">
* <Text>NTAG215</Text>
* </DTCard>
*
* @example
* <DTCard mode="emphasis" onPress={handlePress}>
* <Text>Tap to scan</Text>
* </DTCard>
*/
export function DTCard({
children,
mode = 'normal',
borderColor,
title,
showHeader,
style,
contentStyle,
padding = 16,
borderWidth = 3,
bevelSize = 32,
bevelSizeSmall = 16,
backgroundColor = '#000000',
onPress,
}: DTCardProps) {
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const accentColor = getVariantColor(mode, borderColor);
const shouldShowHeader = showHeader ?? !!title;
const {width, height} = dimensions;
// Outer bevel path (full card shape)
const outerPath = hasDimensions
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
: '';
// Inner bevel path at border inset (for background + frame cutout)
const innerPath = hasDimensions
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
: '';
const content = (
<View style={[styles.container, style]} onLayout={onLayout}>
{/* Background fill (behind content) */}
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={backgroundColor} />
</Svg>
)}
<View style={styles.innerContainer}>
{shouldShowHeader && (
<View style={[styles.header, {backgroundColor: accentColor}]}>
{title && (
<Text
variant="titleMedium"
style={[styles.headerText, {color: DTColors.dark}]}>
{title}
</Text>
)}
</View>
)}
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
</View>
{/* Frame overlay (above content) — clips content at beveled border */}
{hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
<Path
d={outerPath + ' ' + innerPath}
fillRule="evenodd"
fill={accentColor}
/>
</Svg>
)}
</View>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({pressed}) => ({opacity: pressed ? 0.8 : 1})}>
{content}
</Pressable>
);
}
return content;
}
/**
* DT design system constants matching web CSS variables
*/
export const DTCardClipPath = {
// CSS-style clip path (for web reference)
css: `polygon(
0% 0%,
100% 0%,
100% calc(100% - 2em),
calc(100% - 2em) 100%,
1em 100%,
0% calc(100% - 1em)
)`,
// Default bevel sizes in pixels (matching 2em and 1em at 16px base)
bevelSize: 32,
bevelSizeSmall: 16,
borderWidth: 3,
};
const styles = StyleSheet.create({
container: {
position: 'relative',
},
innerContainer: {
position: 'relative',
zIndex: 1,
overflow: 'hidden',
},
header: {
paddingHorizontal: 16,
paddingVertical: 12,
},
headerText: {
fontWeight: '700',
letterSpacing: 0.5,
},
content: {},
frameOverlay: {
zIndex: 2,
},
});

View File

@@ -0,0 +1,140 @@
/**
* DT Checkbox Component
*
* Custom-built angular checkbox following the Dangerous Things design language.
* Uses SVG for beveled shape and checkmark — RNP Checkbox enforces
* rounded corners (borderRadius: 18) so we build from scratch.
*
* Uses diagonal-symmetry bevels (top-left + bottom-right) on the indicator.
*/
import {StyleSheet, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildBeveledRectPath} from '../utils/bevelPaths';
interface DTCheckboxProps {
/**
* Whether the checkbox is checked
*/
checked: boolean;
/**
* Press handler to toggle state
*/
onPress: () => void;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Label text displayed beside the checkbox
*/
label?: string;
/**
* Whether the checkbox is disabled
*/
disabled?: boolean;
/**
* Size of the checkbox in pixels
* @default 24
*/
size?: number;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles for the container
*/
style?: StyleProp<ViewStyle>;
/**
* Additional styles for the label text
*/
labelStyle?: StyleProp<TextStyle>;
}
/**
* DT-styled Checkbox with beveled opposing corners (top-left + bottom-right)
*
* @example
* <DTCheckbox checked={isChecked} onPress={() => setChecked(!isChecked)} label="Remember me" />
*
* @example
* <DTCheckbox checked={true} onPress={toggle} variant="success" label="Accepted" />
*/
export function DTCheckbox({
checked,
onPress,
variant = 'normal',
label,
disabled = false,
size = 24,
color,
style,
labelStyle,
}: DTCheckboxProps) {
const accentColor = getVariantColor(variant, color);
const opacity = disabled ? 0.5 : 1;
const borderWidth = 2;
const bevelSize = Math.round(size * 0.3);
// Outer beveled path (border)
const outerPath = buildBeveledRectPath(size, size, {
corners: {topLeft: bevelSize, bottomRight: bevelSize},
strokeWidth: borderWidth,
});
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={({pressed}) => [
styles.container,
{opacity: pressed ? 0.7 : opacity},
style,
]}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Beveled border */}
<Path
d={outerPath}
fill={checked ? accentColor : 'transparent'}
stroke={accentColor}
strokeWidth={borderWidth}
/>
{/* Checkmark path (only when checked) */}
{checked && (
<Path
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
fill="none"
stroke={DTColors.dark}
strokeWidth={borderWidth + 0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</Svg>
{label && (
<Text style={[styles.label, {color: DTColors.light}, labelStyle]}>
{label}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
minHeight: 44,
paddingVertical: 8,
},
label: {
fontSize: 14,
letterSpacing: 0.3,
},
});

View File

@@ -0,0 +1,95 @@
/**
* DT Chip Component
*
* A themed chip/tag following the Dangerous Things design language.
* Useful for displaying chip types, statuses, and labels.
*/
// 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';
interface DTChipProps extends Omit<ChipProps, 'mode'> {
/**
* Visual variant of the chip
* @default 'normal'
*/
variant?: DTChipVariant;
/**
* Whether the chip is in selected state
* @default false
*/
selected?: boolean;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
const variantColors: Record<DTChipVariant, string> = {
normal: DTColors.modeNormal,
emphasis: DTColors.modeEmphasis,
warning: DTColors.modeWarning,
success: DTColors.modeSuccess,
other: DTColors.modeOther,
};
/**
* DT-styled Chip component
*
* @example
* <DTChip variant="normal">NTAG215</DTChip>
*
* @example
* <DTChip variant="success" selected>Cloneable</DTChip>
*
* @example
* <DTChip variant="warning">Non-Cloneable</DTChip>
*/
export function DTChip({
variant = 'normal',
selected = false,
style,
children,
...props
}: DTChipProps) {
const color = variantColors[variant];
const chipStyle: ViewStyle = {
backgroundColor: selected ? color : 'transparent',
borderColor: color,
borderWidth: 1,
borderRadius: 0, // Angular DT style
};
return (
<Chip
{...props}
mode="outlined"
textStyle={[
styles.text,
{ color: selected ? DTColors.dark : color },
]}
style={[styles.chip, chipStyle, style]}
selectedColor={color}
selected={selected}
>
{children}
</Chip>
);
}
const styles = StyleSheet.create({
chip: {
marginRight: 8,
marginBottom: 8,
},
text: {
fontWeight: '600',
letterSpacing: 0.5,
fontSize: 12,
},
});

View File

@@ -0,0 +1,279 @@
/**
* DT Drawer Component
*
* Hybrid component: RNP Portal for layering + custom Animated panel
* for the sliding behavior. RNP has no sliding panel component.
*
* Uses the dual-SVG-path technique (outer border fill + inner dark fill)
* consistent with DTCard/DTMediaFrame for proper visual clipping at
* beveled corners.
*
* Web CSS reference (Aside.tsx):
* - Fixed sidebar sliding from right
* - Beveled left edges (top-left + bottom-left for right drawer)
* - Colored header bar
* - Overlay backdrop
* - transition: transform 200ms ease-in-out
*/
import {useRef, useEffect} from 'react';
import {
StyleSheet,
View,
ViewStyle,
StyleProp,
Pressable,
Animated,
BackHandler,
ScrollView,
useWindowDimensions,
} from 'react-native';
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildDrawerBevelPath} from '../utils/bevelPaths';
interface DTDrawerProps {
/**
* Whether the drawer is visible
*/
visible: boolean;
/**
* Called when the drawer is dismissed
*/
onDismiss: () => void;
/**
* Heading text in the drawer header bar
*/
heading: string;
/**
* Visual variant for the header bar
* @default 'emphasis'
*/
headingVariant?: DTVariant;
/**
* Which side the drawer slides from
* @default 'right'
*/
position?: 'right' | 'left';
/**
* Drawer panel width in pixels
* @default 400
*/
width?: number;
/**
* Bevel size in pixels
* @default 32
*/
bevelSize?: number;
/**
* Border width in pixels
* @default 2
*/
borderWidth?: number;
/**
* Drawer content
*/
children: React.ReactNode;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled Drawer/Aside with beveled edges and slide animation
*
* @example
* <DTDrawer visible={cartOpen} onDismiss={() => setCartOpen(false)} heading="Cart">
* <CartItems />
* </DTDrawer>
*
* @example
* <DTDrawer visible={menuOpen} onDismiss={close} heading="Menu" position="left">
* <MenuItems />
* </DTDrawer>
*/
export function DTDrawer({
visible,
onDismiss,
heading,
headingVariant = 'emphasis',
position = 'right',
width: drawerWidth = 400,
bevelSize = 32,
borderWidth = 2,
children,
style,
}: DTDrawerProps) {
const slideAnim = useRef(new Animated.Value(0)).current;
const overlayAnim = useRef(new Animated.Value(0)).current;
const headerColor = getVariantColor(headingVariant);
const {width: screenWidth, height: screenHeight} = useWindowDimensions();
const insets = useSafeAreaInsets();
const effectiveWidth = Math.min(drawerWidth, screenWidth);
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(overlayAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(overlayAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideAnim, overlayAnim]);
// Android back button handling
useEffect(() => {
if (!visible) return;
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
onDismiss();
return true;
});
return () => handler.remove();
}, [visible, onDismiss]);
const translateX = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [
position === 'right' ? effectiveWidth : -effectiveWidth,
0,
],
});
// Don't render when fully hidden
const pointerEvents = visible ? 'auto' : 'none';
return (
<Portal>
<View style={StyleSheet.absoluteFill} pointerEvents={pointerEvents}>
{/* Overlay backdrop */}
<Animated.View style={[StyleSheet.absoluteFill, {opacity: overlayAnim}]}>
<Pressable
style={[StyleSheet.absoluteFill, styles.overlay]}
onPress={onDismiss}
/>
</Animated.View>
{/* Drawer panel */}
<Animated.View
style={[
styles.panel,
position === 'right' ? styles.panelRight : styles.panelLeft,
{width: effectiveWidth, transform: [{translateX}]},
style,
]}>
{/* Dual-SVG-path technique: outer border + inner dark fill */}
<View style={StyleSheet.absoluteFill}>
<Svg width={effectiveWidth} height={screenHeight}>
{/* Outer path: accent border color */}
<Path
d={buildDrawerBevelPath(
effectiveWidth,
screenHeight,
bevelSize,
position,
)}
fill={headerColor}
/>
{/* Inner path: dark background (inset by borderWidth) */}
<Path
d={buildDrawerBevelPath(
effectiveWidth - borderWidth * 2,
screenHeight - borderWidth * 2,
bevelSize - borderWidth,
position,
)}
fill={DTColors.dark}
transform={`translate(${borderWidth}, ${borderWidth})`}
/>
</Svg>
</View>
{/* Header bar */}
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
<Text style={styles.headerText}>{heading}</Text>
<Pressable
onPress={onDismiss}
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
<Text style={styles.closeButton}></Text>
</Pressable>
</View>
{/* Content */}
<ScrollView
style={styles.content}
contentContainerStyle={[styles.contentContainer, {paddingBottom: insets.bottom + 16}]}>
{children}
</ScrollView>
</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create({
overlay: {
backgroundColor: DTColors.overlay,
},
panel: {
position: 'absolute',
top: 0,
bottom: 0,
},
panelRight: {
right: 0,
},
panelLeft: {
left: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
zIndex: 1,
},
headerText: {
color: DTColors.dark,
fontWeight: '700',
fontSize: 18,
letterSpacing: 0.5,
},
closeButton: {
color: DTColors.dark,
fontSize: 20,
fontWeight: '700',
paddingHorizontal: 8,
},
content: {
flex: 1,
zIndex: 1,
},
contentContainer: {
padding: 16,
},
});

View File

@@ -0,0 +1,366 @@
/**
* DT Gallery Component
*
* Custom-built image gallery with thumbnail navigation following
* the Dangerous Things design language. No RNP equivalent.
*
* Uses the dual-SVG-path technique for the main image frame,
* consistent with DTCard/DTMediaFrame.
*
* Web CSS reference (CyberGallery):
* - Horizontal thumbnail strip with arrow navigation
* - Active thumbnail with glow effect (box-shadow: 0 0 8px)
* - Beveled arrow buttons
* - Main image in beveled media frame
*/
import {useRef, useCallback} from 'react';
import {
StyleSheet,
View,
ViewStyle,
StyleProp,
Pressable,
Image,
FlatList,
Platform,
} from 'react-native';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
export interface DTGalleryItem {
/**
* Unique identifier
*/
id: string;
/**
* Image URI
*/
uri: string;
/**
* Accessibility label
*/
alt?: string;
}
interface DTGalleryProps {
/**
* Gallery items
*/
items: DTGalleryItem[];
/**
* Currently active item index
*/
activeIndex: number;
/**
* Called when an item is selected
*/
onSelect: (index: number) => void;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Thumbnail size in pixels
* @default 64
*/
thumbnailSize?: number;
/**
* Bevel size for the main image frame
* @default 24
*/
bevelSize?: number;
/**
* Border width for the main image frame
* @default 3
*/
borderWidth?: number;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
const ARROW_SIZE = 32;
const ARROW_BEVEL = 4;
const ARROW_BORDER = 2;
/**
* DT-styled Gallery with thumbnail strip and arrow navigation
*
* @example
* <DTGallery
* items={images}
* activeIndex={activeIdx}
* onSelect={setActiveIdx}
* variant="normal"
* />
*/
export function DTGallery({
items,
activeIndex,
onSelect,
variant = 'normal',
thumbnailSize = 64,
bevelSize = 24,
borderWidth = 3,
style,
}: DTGalleryProps) {
const flatListRef = useRef<FlatList>(null);
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const accentColor = getVariantColor(variant);
const atStart = activeIndex <= 0;
const atEnd = activeIndex >= items.length - 1;
const handlePrev = useCallback(() => {
if (activeIndex > 0) {
onSelect(activeIndex - 1);
}
}, [activeIndex, onSelect]);
const handleNext = useCallback(() => {
if (activeIndex < items.length - 1) {
onSelect(activeIndex + 1);
}
}, [activeIndex, items.length, onSelect]);
const scrollToActive = useCallback(
(index: number) => {
flatListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0.5,
});
},
[],
);
const handleSelect = useCallback(
(index: number) => {
onSelect(index);
scrollToActive(index);
},
[onSelect, scrollToActive],
);
const getItemLayout = useCallback(
(_: unknown, index: number) => ({
length: thumbnailSize + 8,
offset: (thumbnailSize + 8) * index,
index,
}),
[thumbnailSize],
);
const renderArrow = (direction: 'left' | 'right', onPress: () => void, isDisabled: boolean) => {
const center = ARROW_SIZE / 2;
const arrowSize = ARROW_SIZE * 0.2;
const iconPath =
direction === 'left'
? `M ${center + arrowSize} ${center - arrowSize} L ${center - arrowSize} ${center} L ${center + arrowSize} ${center + arrowSize}`
: `M ${center - arrowSize} ${center - arrowSize} L ${center + arrowSize} ${center} L ${center - arrowSize} ${center + arrowSize}`;
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
style={({pressed}) => ({
opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1,
})}>
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
<Path
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
fill="transparent"
stroke={accentColor}
strokeWidth={ARROW_BORDER}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</Pressable>
);
};
const renderThumbnail = ({item, index}: {item: DTGalleryItem; index: number}) => {
const isActive = index === activeIndex;
return (
<Pressable
onPress={() => handleSelect(index)}
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
<View
style={[
styles.thumbnail,
{
width: thumbnailSize,
height: thumbnailSize,
borderColor: isActive ? accentColor : 'transparent',
},
isActive && styles.thumbnailActive,
]}>
{/* Android glow: colored border overlay since elevation is gray-only */}
{isActive && Platform.OS === 'android' && (
<View
style={[
StyleSheet.absoluteFill,
{
borderWidth: 3,
borderColor: accentColor,
opacity: 0.5,
margin: -3,
},
]}
/>
)}
<Image
source={{uri: item.uri}}
style={styles.thumbnailImage}
accessibilityLabel={item.alt}
resizeMode="cover"
/>
</View>
</Pressable>
);
};
if (items.length === 0) return null;
const {width, height} = dimensions;
// Outer and inner bevel paths for frame overlay
const outerPath = hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize)
: '';
const innerPath = hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
: '';
return (
<View style={[styles.container, style]}>
{/* Main image with beveled frame */}
<View style={styles.mainImageContainer} onLayout={onLayout}>
{/* Background fill */}
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={DTColors.surfaceVariant} />
</Svg>
)}
<View style={[styles.mainImageContent, {padding: borderWidth}]}>
<Image
source={{uri: items[activeIndex]?.uri}}
style={styles.mainImage}
accessibilityLabel={items[activeIndex]?.alt}
resizeMode="contain"
/>
</View>
{/* Frame overlay (above content) — clips image at beveled border */}
{hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
{/* Corner mask: fill area outside outer bevel with dark to hide overflow */}
<Path
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
fillRule="evenodd"
fill={DTColors.dark}
/>
{/* Colored border between outer and inner bevel paths */}
<Path
d={outerPath + ' ' + innerPath}
fillRule="evenodd"
fill={accentColor}
/>
</Svg>
)}
</View>
{/* Thumbnail strip with arrows */}
{items.length > 1 && (
<View style={styles.thumbnailStrip}>
{renderArrow('left', handlePrev, atStart)}
<FlatList
ref={flatListRef}
data={items}
renderItem={renderThumbnail}
keyExtractor={item => item.id}
horizontal
showsHorizontalScrollIndicator={false}
getItemLayout={getItemLayout}
contentContainerStyle={styles.thumbnailList}
style={styles.thumbnailFlatList}
initialNumToRender={8}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
{renderArrow('right', handleNext, atEnd)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 12,
},
mainImageContainer: {
aspectRatio: 1,
position: 'relative',
},
mainImageContent: {
position: 'relative',
zIndex: 1,
flex: 1,
overflow: 'hidden',
},
mainImage: {
flex: 1,
},
thumbnailStrip: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
thumbnailFlatList: {
flex: 1,
},
thumbnailList: {
gap: 8,
},
thumbnail: {
borderWidth: 2,
overflow: 'hidden',
},
thumbnailActive: {
...Platform.select({
ios: {
shadowOffset: {width: 0, height: 0},
shadowOpacity: 0.8,
shadowRadius: 8,
},
}),
},
thumbnailImage: {
flex: 1,
},
frameOverlay: {
zIndex: 2,
},
});

View File

@@ -0,0 +1,210 @@
/**
* DT Hexagon Component
*
* Decorative hexagon shape following the Dangerous Things design language.
* No RNP equivalent — purely SVG-based.
*
* Web CSS reference (AnimatedHexagon / HexagonHamburgerMenu):
* - Flat-top hexagon shape
* - Optional rotation and pulse animations
* - Used for loading indicators, backgrounds, menu icons
*/
import {useRef, useEffect} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
import Svg, {Polygon} from 'react-native-svg';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTHexagonProps {
/**
* Hexagon diameter in pixels
*/
size: number;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Whether the hexagon has a solid fill
* @default true
*/
filled?: boolean;
/**
* Whether to animate
* @default false
*/
animated?: boolean;
/**
* Animation type
* @default 'none'
*/
animationType?: 'rotate' | 'pulse' | 'none';
/**
* Animation duration in milliseconds
* @default 2000
*/
animationDuration?: number;
/**
* Stroke/border width when not filled
* @default 2
*/
borderWidth?: number;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Opacity
* @default 1
*/
opacity?: number;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
/**
* Content rendered centered inside the hexagon
*/
children?: React.ReactNode;
}
/**
* Build flat-top hexagon SVG polygon points
*/
function buildHexagonPoints(size: number): string {
const cx = size / 2;
const cy = size / 2;
const r = size / 2;
return [0, 1, 2, 3, 4, 5]
.map(i => {
const angle = (Math.PI / 3) * i - Math.PI / 6; // flat-top orientation
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
return `${x},${y}`;
})
.join(' ');
}
/**
* DT-styled decorative Hexagon
*
* @example
* <DTHexagon size={60} variant="normal" />
*
* @example
* <DTHexagon size={40} variant="emphasis" animated animationType="rotate" />
*
* @example
* <DTHexagon size={80} variant="success" filled={false} animated animationType="pulse" />
*/
export function DTHexagon({
size,
variant = 'normal',
filled = true,
animated = false,
animationType = 'none',
animationDuration = 2000,
borderWidth = 2,
color,
opacity: opacityProp = 1,
style,
children,
}: DTHexagonProps) {
const accentColor = getVariantColor(variant, color);
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (!animated) {
rotateAnim.setValue(0);
pulseAnim.setValue(1);
return;
}
if (animationType === 'rotate') {
const animation = Animated.loop(
Animated.timing(rotateAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}),
);
animation.start();
return () => {
animation.stop();
rotateAnim.setValue(0);
};
}
if (animationType === 'pulse') {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 0.3,
duration: animationDuration / 2,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: animationDuration / 2,
useNativeDriver: true,
}),
]),
);
animation.start();
return () => {
animation.stop();
pulseAnim.setValue(1);
};
}
}, [animated, animationType, animationDuration, rotateAnim, pulseAnim]);
const rotation = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const animatedOpacity =
animated && animationType === 'pulse' ? pulseAnim : opacityProp;
const transform =
animated && animationType === 'rotate'
? [{rotate: rotation}]
: [];
const points = buildHexagonPoints(size);
return (
<Animated.View
style={[
styles.container,
{width: size, height: size, opacity: animatedOpacity},
transform.length > 0 ? {transform} : undefined,
style,
]}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Polygon
points={points}
fill={filled ? accentColor : 'transparent'}
stroke={accentColor}
strokeWidth={filled ? 0 : borderWidth}
/>
</Svg>
{children && (
<View style={[StyleSheet.absoluteFill, styles.childrenContainer]}>
{children}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {},
childrenContainer: {
justifyContent: 'center',
alignItems: 'center',
},
});

View File

@@ -0,0 +1,313 @@
/**
* DT Label Component
*
* A themed label following the Dangerous Things design language
* with an SVG-based beveled top-right corner.
*
* Web CSS reference (CyberLabel):
* - Top-right bevel: 1em (~16px)
* - Solid filled background using mode color
* - Black text on colored background
*/
import {ReactNode, useEffect, useRef} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildLabelBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
type DTLabelSize = 'small' | 'medium' | 'large';
interface SizeConfig {
fontSize: number;
fontSizeSecondary: number;
paddingHorizontal: number;
paddingVertical: number;
bevel: number;
indicatorWidth: number;
gap: number;
}
const sizeConfigs: Record<DTLabelSize, SizeConfig> = {
small: {
fontSize: 11,
fontSizeSecondary: 11,
paddingHorizontal: 8,
paddingVertical: 4,
bevel: 10,
indicatorWidth: 12,
gap: 3,
},
medium: {
fontSize: 14,
fontSizeSecondary: 14,
paddingHorizontal: 12,
paddingVertical: 8,
bevel: 16,
indicatorWidth: 16,
gap: 4,
},
large: {
fontSize: 18,
fontSizeSecondary: 18,
paddingHorizontal: 16,
paddingVertical: 12,
bevel: 22,
indicatorWidth: 20,
gap: 6,
},
};
interface DTLabelProps {
/**
* Primary text content
*/
primaryText: ReactNode;
/**
* Secondary text (displayed bold, after primary)
*/
secondaryText?: ReactNode;
/**
* Color mode for the label
* @default 'normal'
*/
mode?: DTVariant;
/**
* Size of the label - affects text size and scales all dimensions
* @default 'medium'
*/
size?: DTLabelSize;
/**
* Whether the label should fill its container width
* @default false
*/
fullWidth?: boolean;
/**
* Whether to animate the label on mount
* @default true
*/
animated?: boolean;
/**
* Animation delay in milliseconds
* @default 100
*/
animationDelay?: number;
/**
* Animation duration in milliseconds
* @default 330
*/
animationDuration?: number;
/**
* Whether to show the pinging indicator
* @default true
*/
showIndicator?: boolean;
/**
* Top-right bevel size in pixels (overrides size-based default)
*/
bevelSize?: number;
/**
* Additional styles for the label container
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled Label component with SVG beveled top-right corner
*
* Based on CyberLabel from the Dangerous Things web storefront.
* Features a solid colored background with black text and an optional
* animated ping indicator.
*
* @example
* <DTLabel primaryText="Detected" secondaryText="NTAG215" mode="normal" />
*
* @example
* <DTLabel primaryText="Status" secondaryText="Ready to scan" mode="success" fullWidth />
*
* @example
* <DTLabel primaryText="Warning" mode="warning" showIndicator={false} />
*
* @example
* <DTLabel primaryText="Small label" size="small" />
*
* @example
* <DTLabel primaryText="Large label" size="large" mode="emphasis" />
*/
export function DTLabel({
primaryText,
secondaryText,
mode = 'normal',
size = 'medium',
fullWidth = false,
animated = true,
animationDelay = 100,
animationDuration = 330,
showIndicator = true,
bevelSize,
style,
}: DTLabelProps) {
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
const pingAnim = useRef(new Animated.Value(1)).current;
const backgroundColor = getVariantColor(mode);
const sizeConfig = sizeConfigs[size];
const effectiveBevelSize = bevelSize ?? sizeConfig.bevel;
// Mount animation
useEffect(() => {
if (animated) {
Animated.timing(scaleAnim, {
toValue: 1,
duration: animationDuration,
delay: animationDelay,
useNativeDriver: true,
}).start();
}
}, [animated, animationDelay, animationDuration, scaleAnim]);
// Ping animation loop
useEffect(() => {
if (showIndicator) {
const pingAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pingAnim, {
toValue: 0.3,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pingAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]),
);
pingAnimation.start();
return () => {
pingAnimation.stop();
pingAnim.setValue(1);
};
}
pingAnim.setValue(1);
}, [showIndicator, pingAnim]);
const {width, height} = dimensions;
return (
<Animated.View
style={[
styles.container,
fullWidth && styles.fullWidth,
{transform: [{scale: scaleAnim}]},
style,
]}
onLayout={onLayout}>
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={buildLabelBevelPath(width, height, effectiveBevelSize)} fill={backgroundColor} />
</Svg>
)}
<View style={styles.innerContainer}>
<View
style={[
styles.textContainer,
{
paddingHorizontal: sizeConfig.paddingHorizontal,
paddingVertical: sizeConfig.paddingVertical,
gap: sizeConfig.gap,
},
]}>
<Text
style={[
styles.primaryText,
{fontSize: sizeConfig.fontSize},
]}>
{primaryText}
</Text>
{secondaryText && (
<Text
style={[
styles.secondaryText,
{fontSize: sizeConfig.fontSizeSecondary},
]}>
{secondaryText}
</Text>
)}
</View>
{showIndicator && (
<Animated.View
style={[
styles.indicator,
{opacity: pingAnim, paddingLeft: sizeConfig.paddingHorizontal / 1.5},
]}>
<View
style={[
styles.indicatorLine,
{borderColor: DTColors.dark, width: sizeConfig.indicatorWidth},
]}
/>
</Animated.View>
)}
</View>
</Animated.View>
);
}
/**
* DT Label design constants matching web CSS
*/
export const DTLabelClipPath = {
// CSS-style clip path (for web reference)
css: `polygon(
0% 0%,
calc(100% - 1em) 0%,
100% 1em,
100% 100%,
0% 100%
)`,
// Default bevel size in pixels (matching 1em at 16px base)
bevelSize: 16,
};
const styles = StyleSheet.create({
container: {
position: 'relative',
alignSelf: 'flex-start',
margin: 8,
},
fullWidth: {
alignSelf: 'stretch',
},
innerContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative',
zIndex: 1,
},
textContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
},
primaryText: {
color: DTColors.dark,
fontWeight: '500',
},
secondaryText: {
color: DTColors.dark,
fontWeight: '800',
},
indicator: {},
indicatorLine: {
borderBottomWidth: 2,
},
});

View File

@@ -0,0 +1,210 @@
/**
* DT MediaFrame Component
*
* A beveled frame for images and video content following the
* Dangerous Things design language. No RNP equivalent.
*
* Web CSS reference (CyberMediaFrame / .media-frame-clipped):
* - Top-left + bottom-right diagonal symmetry bevels
* - Colored border with dark inner background
* - Optional mount scale animation
*/
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildMediaFrameBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
interface DTMediaFrameProps {
/**
* Content to frame (Image, Video, etc.)
*/
children: ReactNode;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Aspect ratio (e.g., 16/9, 1, 4/3)
*/
aspectRatio?: number;
/**
* Border width in pixels
* @default 3
*/
borderWidth?: number;
/**
* Bevel size in pixels (top-left and bottom-right)
* @default 32
*/
bevelSize?: number;
/**
* Whether to animate on mount
* @default true
*/
animated?: boolean;
/**
* Animation delay in milliseconds
* @default 0
*/
animationDelay?: number;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles for outer container
*/
style?: StyleProp<ViewStyle>;
/**
* Additional styles for the content area
*/
contentStyle?: StyleProp<ViewStyle>;
}
/**
* DT-styled MediaFrame with diagonal-symmetry beveled corners
*
* @example
* <DTMediaFrame variant="normal" aspectRatio={16/9}>
* <Image source={{uri: imageUrl}} style={{flex: 1}} />
* </DTMediaFrame>
*
* @example
* <DTMediaFrame variant="emphasis" bevelSize={24}>
* <Video source={videoSource} />
* </DTMediaFrame>
*/
export function DTMediaFrame({
children,
variant = 'normal',
aspectRatio,
borderWidth = 3,
bevelSize = 32,
animated = true,
animationDelay = 0,
color,
style,
contentStyle,
}: DTMediaFrameProps) {
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
const accentColor = getVariantColor(variant, color);
useEffect(() => {
if (animated) {
Animated.timing(scaleAnim, {
toValue: 1,
duration: 300,
delay: animationDelay,
useNativeDriver: true,
}).start();
}
}, [animated, animationDelay, scaleAnim]);
const {width, height} = dimensions;
// Outer bevel path (full frame shape)
const outerPath = hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize)
: '';
// Inner bevel path at border inset
const innerPath = hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
: '';
return (
<Animated.View
style={[
styles.container,
aspectRatio ? {aspectRatio} : undefined,
{transform: [{scale: scaleAnim}]},
style,
]}
onLayout={onLayout}>
{/* Background fill (behind content) */}
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={DTColors.dark} />
</Svg>
)}
{/* Content clipped to the inner bevel shape */}
{hasDimensions ? (
<View style={[styles.content, contentStyle]}>
<Svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={StyleSheet.absoluteFill}>
<Defs>
<ClipPath id="mediaClip">
<Path d={innerPath} />
</ClipPath>
</Defs>
</Svg>
<View
style={[
StyleSheet.absoluteFill,
{
margin: borderWidth,
// Use borderRadius 0 to keep angular, overflow hidden clips rectangular part
overflow: 'hidden',
},
]}>
{children}
</View>
</View>
) : (
<View style={[styles.content, {padding: borderWidth}, contentStyle]}>
{children}
</View>
)}
{/* Frame overlay (above content) — masks content at beveled corners */}
{hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
{/* Outer frame border */}
<Path
d={outerPath + ' ' + innerPath}
fillRule="evenodd"
fill={accentColor}
/>
{/* Corner masks: fill the rectangular area outside the bevel shape with black
to hide content that overflows past the beveled corners */}
<Path
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
fillRule="evenodd"
fill={DTColors.dark}
/>
</Svg>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
},
content: {
position: 'relative',
zIndex: 1,
overflow: 'hidden',
flex: 1,
},
frameOverlay: {
zIndex: 2,
},
});

View File

@@ -0,0 +1,371 @@
/**
* DT Menu Component
*
* Wraps React Native Paper's Menu for dropdown positioning, with custom
* menu items styled to the Dangerous Things aesthetic.
*
* Web CSS reference (CyberMenu):
* - Hierarchical items with menu-item-clipped styling
* - Active/selected state with emphasis color fill
* - borderTopWidth: 5 thick accent border
*/
import {useState, useCallback, useRef, useEffect} from 'react';
import {
StyleSheet,
View,
ViewStyle,
StyleProp,
Pressable,
Animated,
} from 'react-native';
import {Menu, Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
export interface DTMenuItem {
/**
* Unique identifier
*/
id: string;
/**
* Display title
*/
title: string;
/**
* Press handler (leaf items)
*/
onPress?: () => void;
/**
* Sub-menu items
*/
items?: DTMenuItem[];
/**
* Whether this item is currently active
*/
isActive?: boolean;
}
interface DTMenuProps {
/**
* Menu items
*/
items: DTMenuItem[];
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Variant for active items
* @default 'emphasis'
*/
activeVariant?: DTVariant;
/**
* Layout direction
* @default 'vertical'
*/
layout?: 'horizontal' | 'vertical';
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled Menu with hierarchical items
*
* For inline menu display (not dropdown). Items with sub-items
* expand/collapse on press.
*
* @example
* <DTMenu items={[
* { id: '1', title: 'Home', onPress: goHome, isActive: true },
* { id: '2', title: 'Products', items: [
* { id: '2a', title: 'NFC Tags', onPress: goTags },
* ]},
* ]} />
*/
export function DTMenu({
items,
variant = 'normal',
activeVariant = 'emphasis',
layout = 'vertical',
style,
}: DTMenuProps) {
return (
<View
style={[
styles.container,
layout === 'horizontal' ? styles.horizontal : styles.vertical,
style,
]}>
{items.map(item => (
<DTMenuItemRow
key={item.id}
item={item}
variant={variant}
activeVariant={activeVariant}
level={0}
/>
))}
</View>
);
}
/**
* For dropdown use, wraps RNP Menu for positioning/portal behavior
*/
interface DTMenuDropdownProps {
/**
* Menu items
*/
items: DTMenuItem[];
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Anchor element or position
*/
anchor: React.ReactNode;
/**
* Whether the menu is visible
*/
visible: boolean;
/**
* Called when the menu is dismissed
*/
onDismiss: () => void;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled dropdown Menu wrapping React Native Paper Menu
*
* @example
* <DTMenuDropdown
* items={menuItems}
* anchor={<DTButton onPress={open}>Open Menu</DTButton>}
* visible={menuVisible}
* onDismiss={() => setMenuVisible(false)}
* />
*/
export function DTMenuDropdown({
items,
variant = 'normal',
anchor,
visible,
onDismiss,
style,
}: DTMenuDropdownProps) {
const accentColor = getVariantColor(variant);
return (
<Menu
visible={visible}
onDismiss={onDismiss}
anchor={anchor}
anchorPosition="bottom"
contentStyle={[
styles.dropdownContent,
{borderColor: accentColor},
style,
]}
theme={{
colors: {
elevation: {level2: DTColors.dark},
},
}}>
{items.map(item => (
<Menu.Item
key={item.id}
title={item.title}
onPress={() => {
item.onPress?.();
onDismiss();
}}
titleStyle={[styles.dropdownItemTitle, {color: DTColors.light}]}
style={styles.dropdownItem}
/>
))}
</Menu>
);
}
// --- Internal DTMenuItemRow ---
function DTMenuItemRow({
item,
variant,
activeVariant,
level,
}: {
item: DTMenuItem;
variant: DTVariant;
activeVariant: DTVariant;
level: number;
}) {
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 heightAnim = useRef(new Animated.Value(0)).current;
const chevronAnim = useRef(new Animated.Value(0)).current;
const [contentHeight, setContentHeight] = useState(0);
const [measured, setMeasured] = useState(false);
useEffect(() => {
if (!hasChildren) return;
Animated.parallel([
Animated.timing(heightAnim, {
toValue: expanded ? 1 : 0,
duration: 250,
useNativeDriver: false,
}),
Animated.timing(chevronAnim, {
toValue: expanded ? 1 : 0,
duration: 250,
useNativeDriver: true,
}),
]).start();
}, [expanded, heightAnim, chevronAnim, hasChildren]);
const handlePress = useCallback(() => {
if (hasChildren) {
setExpanded(prev => !prev);
} else {
item.onPress?.();
}
}, [hasChildren, item]);
const animatedHeight = heightAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, contentHeight],
});
const chevronRotate = chevronAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '180deg'],
});
return (
<View>
<Pressable
onPress={handlePress}
style={({pressed}) => [
styles.menuItem,
{
borderColor: itemColor,
borderTopColor: itemColor,
backgroundColor: isActive
? `${itemColor}20`
: DTColors.dark,
paddingLeft: 16 + level * 16,
opacity: pressed ? 0.7 : 1,
},
]}>
<Text style={[styles.menuItemTitle, {color: itemColor}]}>
{item.title}
</Text>
{hasChildren && (
<Animated.View
style={[
styles.chevron,
{transform: [{rotate: chevronRotate}]},
]}>
<View style={[styles.chevronArrow, {borderTopColor: itemColor}]} />
</Animated.View>
)}
</Pressable>
{hasChildren && (
<Animated.View
style={{
height: measured ? animatedHeight : undefined,
overflow: 'hidden',
}}>
<View
onLayout={e => {
const h = e.nativeEvent.layout.height;
if (h > 0 && !measured) {
setContentHeight(h);
setMeasured(true);
if (!expanded) {
heightAnim.setValue(0);
}
}
}}>
{item.items?.map(child => (
<DTMenuItemRow
key={child.id}
item={child}
variant={variant}
activeVariant={activeVariant}
level={level + 1}
/>
))}
</View>
</Animated.View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 4,
},
horizontal: {
flexDirection: 'row',
flexWrap: 'wrap',
},
vertical: {
flexDirection: 'column',
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderTopWidth: 5,
paddingVertical: 12,
paddingRight: 16,
},
menuItemTitle: {
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
fontSize: 14,
},
chevron: {
justifyContent: 'center',
alignItems: 'center',
width: 24,
height: 24,
},
chevronArrow: {
width: 0,
height: 0,
borderLeftWidth: 5,
borderRightWidth: 5,
borderTopWidth: 7,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
dropdownContent: {
borderWidth: 2,
borderRadius: 0,
backgroundColor: DTColors.dark,
},
dropdownItem: {
borderRadius: 0,
},
dropdownItemTitle: {
letterSpacing: 0.3,
},
});

View File

@@ -0,0 +1,115 @@
/**
* DT Modal Component
*
* Wraps React Native Paper's Modal + Portal with the Dangerous Things
* aesthetic. Content is rendered inside a DTCard for the beveled look.
*
* Web CSS reference (Modal.tsx):
* - Centered overlay with dark backdrop
* - Content in beveled card container
*/
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform} from 'react-native';
import {Portal, Modal} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant} from '../utils/variantColors';
import {DTCard} from './DTCard';
interface DTModalProps {
/**
* Whether the modal is visible
*/
visible: boolean;
/**
* Called when the modal is dismissed
*/
onDismiss: () => void;
/**
* Optional title for the modal header
*/
title?: string;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Modal content
*/
children: React.ReactNode;
/**
* Whether the modal can be dismissed by tapping the backdrop
* @default true
*/
dismissable?: boolean;
/**
* Additional styles for the card content area
*/
contentStyle?: StyleProp<ViewStyle>;
/**
* Additional styles for the modal container
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled Modal wrapping React Native Paper Modal + Portal
*
* @example
* <DTModal visible={showModal} onDismiss={() => setShowModal(false)} title="Confirm">
* <Text>Are you sure?</Text>
* </DTModal>
*
* @example
* <DTModal visible={isOpen} onDismiss={close} variant="warning" title="Error">
* <Text>Something went wrong</Text>
* </DTModal>
*/
export function DTModal({
visible,
onDismiss,
title,
variant = 'normal',
children,
dismissable = true,
contentStyle,
style,
}: DTModalProps) {
return (
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
dismissable={dismissable}
contentContainerStyle={[styles.container, style]}
style={styles.modal}
theme={{
colors: {
backdrop: DTColors.overlay,
},
}}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardAvoid}>
<DTCard
mode={variant}
title={title}
showHeader={!!title}
contentStyle={contentStyle}>
{children}
</DTCard>
</KeyboardAvoidingView>
</Modal>
</Portal>
);
}
const styles = StyleSheet.create({
modal: {},
container: {
marginHorizontal: 24,
},
keyboardAvoid: {
width: '100%',
},
});

View File

@@ -0,0 +1,183 @@
/**
* DT ProgressBar Component
*
* A themed progress bar wrapping React Native Paper's ProgressBar
* with the Dangerous Things angular aesthetic.
*
* Web CSS reference (.progress / .progress-indicator):
* - Vertical or horizontal bar showing progress 0-100%
* - Mode-colored fill
*/
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
import {ProgressBar, Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {useComponentLayout} from '../utils/useComponentLayout';
interface DTProgressBarProps {
/**
* Progress value between 0 and 1
*/
value: number;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Orientation of the bar
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical';
/**
* Bar thickness in pixels
* @default 4
*/
height?: number;
/**
* Whether to animate progress changes
* @default true
*/
animated?: boolean;
/**
* Show percentage label
* @default false
*/
showLabel?: boolean;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled ProgressBar wrapping React Native Paper ProgressBar
*
* @example
* <DTProgressBar value={0.75} variant="normal" />
*
* @example
* <DTProgressBar value={0.5} variant="emphasis" showLabel />
*
* @example
* <DTProgressBar value={0.3} orientation="vertical" height={100} variant="success" />
*/
export function DTProgressBar({
value,
variant = 'normal',
orientation = 'horizontal',
height = 4,
animated: _animated = true,
showLabel = false,
color,
style,
}: DTProgressBarProps) {
const accentColor = getVariantColor(variant, color);
const clampedValue = Math.max(0, Math.min(1, value));
if (orientation === 'vertical') {
return (
<VerticalProgressBar
value={clampedValue}
color={accentColor}
width={height}
showLabel={showLabel}
style={style}
/>
);
}
return (
<View style={[styles.horizontalContainer, style]}>
<ProgressBar
progress={clampedValue}
color={accentColor}
style={[styles.bar, {height}]}
theme={{
colors: {
surfaceVariant: DTColors.disabledBackground,
},
}}
/>
{showLabel && (
<Text style={[styles.label, {color: accentColor}]}>
{Math.round(clampedValue * 100)}%
</Text>
)}
</View>
);
}
/**
* Vertical progress bar using measured pixel height instead of
* percentage strings (which don't animate smoothly in RN).
*/
function VerticalProgressBar({
value,
color,
width,
showLabel,
style,
}: {
value: number;
color: string;
width: number;
showLabel: boolean;
style?: StyleProp<ViewStyle>;
}) {
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const fillHeight = hasDimensions ? dimensions.height * value : 0;
return (
<View style={[styles.verticalContainer, {width}, style]}>
<View style={styles.verticalTrack} onLayout={onLayout}>
<View
style={[
styles.verticalFill,
{
backgroundColor: color,
height: fillHeight,
},
]}
/>
</View>
{showLabel && (
<Text style={[styles.label, {color}]}>
{Math.round(value * 100)}%
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
horizontalContainer: {
gap: 8,
},
bar: {
borderRadius: 0,
},
verticalContainer: {
alignItems: 'center',
gap: 8,
},
verticalTrack: {
flex: 1,
width: '100%',
backgroundColor: DTColors.disabledBackground,
justifyContent: 'flex-end',
},
verticalFill: {
width: '100%',
},
label: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
},
});

View File

@@ -0,0 +1,234 @@
/**
* DT QuantityStepper Component
*
* Custom-built quantity increment/decrement control following the
* Dangerous Things design language. No RNP equivalent exists.
*
* Web CSS reference (AddOnOptionItem quantity controls):
* - Plus/minus buttons with beveled corners
* - Quantity display between buttons
*/
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath} from '../utils/bevelPaths';
type DTStepperSize = 'small' | 'medium' | 'large';
interface SizeConfig {
buttonSize: number;
fontSize: number;
bevelSize: number;
iconStrokeWidth: number;
minWidth: number;
}
const sizeConfigs: Record<DTStepperSize, SizeConfig> = {
small: {
buttonSize: 28,
fontSize: 12,
bevelSize: 4,
iconStrokeWidth: 1.5,
minWidth: 28,
},
medium: {
buttonSize: 36,
fontSize: 16,
bevelSize: 6,
iconStrokeWidth: 2,
minWidth: 36,
},
large: {
buttonSize: 44,
fontSize: 20,
bevelSize: 8,
iconStrokeWidth: 2.5,
minWidth: 44,
},
};
interface DTQuantityStepperProps {
/**
* Current quantity value
*/
value: number;
/**
* Called when value changes
*/
onValueChange: (value: number) => void;
/**
* Minimum value
* @default 0
*/
min?: number;
/**
* Maximum value
* @default 99
*/
max?: number;
/**
* Step increment
* @default 1
*/
step?: number;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Whether the stepper is disabled
*/
disabled?: boolean;
/**
* Label text displayed above the stepper
*/
label?: string;
/**
* Size preset
* @default 'medium'
*/
size?: DTStepperSize;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled QuantityStepper with beveled +/- buttons
*
* @example
* <DTQuantityStepper value={qty} onValueChange={setQty} label="Quantity" />
*
* @example
* <DTQuantityStepper value={qty} onValueChange={setQty} min={1} max={10} variant="emphasis" />
*/
export function DTQuantityStepper({
value,
onValueChange,
min = 0,
max = 99,
step = 1,
variant = 'normal',
disabled = false,
label,
size = 'medium',
color,
style,
}: DTQuantityStepperProps) {
const accentColor = getVariantColor(variant, color);
const config = sizeConfigs[size];
const atMin = value <= min;
const atMax = value >= max;
const borderWidth = 2;
const handleDecrement = () => {
const next = Math.max(min, value - step);
onValueChange(next);
};
const handleIncrement = () => {
const next = Math.min(max, value + step);
onValueChange(next);
};
const renderStepButton = (
type: 'minus' | 'plus',
onPress: () => void,
isDisabled: boolean,
) => {
const s = config.buttonSize;
const center = s / 2;
const iconSize = s * 0.35;
const sw = config.iconStrokeWidth;
// Minus: horizontal line; Plus: cross
const iconPath =
type === 'minus'
? `M ${center - iconSize} ${center} L ${center + iconSize} ${center}`
: `M ${center - iconSize} ${center} L ${center + iconSize} ${center} M ${center} ${center - iconSize} L ${center} ${center + iconSize}`;
// Expand touch target to at least 44px
const hitSlopSize = Math.max(0, (44 - s) / 2);
const hitSlop = hitSlopSize > 0 ? hitSlopSize : undefined;
return (
<Pressable
onPress={onPress}
disabled={disabled || isDisabled}
hitSlop={hitSlop}
style={({pressed}) => ({
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
})}>
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<Path
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
fill="transparent"
stroke={accentColor}
strokeWidth={borderWidth}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={sw}
strokeLinecap="round"
/>
</Svg>
</Pressable>
);
};
return (
<View style={[styles.wrapper, style]}>
{label && (
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
)}
<View style={styles.container}>
{renderStepButton('minus', handleDecrement, atMin)}
<View style={[styles.valueContainer, {minWidth: config.minWidth}]}>
<Text
style={[
styles.valueText,
{fontSize: config.fontSize, color: accentColor},
]}>
{value}
</Text>
</View>
{renderStepButton('plus', handleIncrement, atMax)}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: 8,
},
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
label: {
fontSize: 14,
letterSpacing: 0.3,
},
valueContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
},
valueText: {
fontWeight: '700',
letterSpacing: 0.5,
},
});

View File

@@ -0,0 +1,229 @@
/**
* DT RadioGroup + RadioOption Components
*
* Custom-built angular radio selection following the Dangerous Things design language.
* RNP RadioButton uses circular geometry with animated SVG circles,
* so we build hexagonal indicators from scratch.
*
* Web CSS reference (AddOnSelector single-select):
* - Selected: border highlight + semi-transparent fill (var(--selected) at 70% opacity)
* - Hexagon indicator instead of circular radio button
*/
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
// --- Context ---
interface RadioContextValue {
value: string | null;
onValueChange: (value: string) => void;
variant: DTVariant;
}
const RadioContext = createContext<RadioContextValue | null>(null);
// --- DTRadioGroup ---
interface DTRadioGroupProps {
/**
* Currently selected value
*/
value: string | null;
/**
* Called when selection changes
*/
onValueChange: (value: string) => void;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Radio option children
*/
children: React.ReactNode;
/**
* Additional styles for the container
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled RadioGroup container
*
* @example
* <DTRadioGroup value={selected} onValueChange={setSelected} variant="normal">
* <DTRadioOption value="a" label="Option A" />
* <DTRadioOption value="b" label="Option B" description="With description" />
* </DTRadioGroup>
*/
export function DTRadioGroup({
value,
onValueChange,
variant = 'normal',
children,
style,
}: DTRadioGroupProps) {
return (
<RadioContext.Provider value={{value, onValueChange, variant}}>
<View style={[styles.group, style]}>{children}</View>
</RadioContext.Provider>
);
}
// --- DTRadioOption ---
interface DTRadioOptionProps {
/**
* Value identifier for this option
*/
value: string;
/**
* Label text
*/
label: string;
/**
* Optional description text
*/
description?: string;
/**
* Whether this option is disabled
*/
disabled?: boolean;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
const INDICATOR_SIZE = 22;
const INDICATOR_INNER = 12;
const INDICATOR_BORDER = 2;
/** Generate flat-top hexagon points string for SVG Polygon */
function hexPoints(cx: number, cy: number, r: number): string {
const pts: string[] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6; // flat-top: start at -30°
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(' ');
}
/**
* DT-styled RadioOption with hexagonal indicator
*
* Must be used inside a DTRadioGroup.
*
* @example
* <DTRadioOption value="option1" label="First option" />
*/
export function DTRadioOption({
value,
label,
description,
disabled = false,
style,
}: DTRadioOptionProps) {
const context = useContext(RadioContext);
if (!context) {
throw new Error('DTRadioOption must be used inside DTRadioGroup');
}
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 opacity = disabled ? 0.5 : 1;
return (
<Pressable
onPress={() => !disabled && onValueChange(value)}
style={({pressed}) => [
styles.option,
{
borderColor: isSelected ? accentColor : getColor('modeNormal', 0.3),
backgroundColor: isSelected ? selectedBgColor : 'transparent',
opacity: pressed ? 0.7 : opacity,
},
style,
]}>
{/* Hexagon indicator */}
<Svg
width={INDICATOR_SIZE}
height={INDICATOR_SIZE}
viewBox={`0 0 ${INDICATOR_SIZE} ${INDICATOR_SIZE}`}>
<Polygon
points={hexPoints(INDICATOR_SIZE / 2, INDICATOR_SIZE / 2, INDICATOR_SIZE / 2 - INDICATOR_BORDER / 2)}
fill="transparent"
stroke={accentColor}
strokeWidth={INDICATOR_BORDER}
strokeLinejoin="miter"
/>
{isSelected && (
<Polygon
points={hexPoints(INDICATOR_SIZE / 2, INDICATOR_SIZE / 2, INDICATOR_INNER / 2)}
fill={accentColor}
/>
)}
</Svg>
{/* Text content */}
<View style={styles.optionText}>
<Text
style={[
styles.optionLabel,
{color: isSelected ? DTColors.dark : DTColors.light},
]}>
{label}
</Text>
{description && (
<Text
variant="bodySmall"
style={[
styles.optionDescription,
{color: isSelected ? DTColors.dark : DTColors.disabled},
]}>
{description}
</Text>
)}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
group: {
gap: 8,
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 12,
borderWidth: 2,
},
optionText: {
flex: 1,
gap: 2,
},
optionLabel: {
fontSize: 14,
fontWeight: '500',
letterSpacing: 0.3,
},
optionDescription: {
fontSize: 12,
letterSpacing: 0.2,
},
});

View File

@@ -0,0 +1,163 @@
/**
* DT SearchInput Component
*
* A themed search input wrapping React Native Paper's Searchbar
* with the Dangerous Things angular aesthetic.
*
* Styled input only — predictive results logic is app-specific
* (composable principle from CLAUDE.md).
*/
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 {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTSearchInputProps {
/**
* Current search text
*/
value: string;
/**
* Called when text changes
*/
onChangeText: (text: string) => void;
/**
* Called when search is submitted
*/
onSubmit?: () => void;
/**
* Placeholder text
* @default 'Search...'
*/
placeholder?: string;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Show loading indicator instead of search icon
* @default false
*/
loading?: boolean;
/**
* Whether the input is disabled
*/
disabled?: boolean;
/**
* Auto-focus the input on mount
*/
autoFocus?: boolean;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles for the container
*/
style?: StyleProp<ViewStyle>;
/**
* Additional styles for the input text
*/
inputStyle?: StyleProp<TextStyle>;
}
/**
* DT-styled SearchInput wrapping React Native Paper Searchbar
*
* @example
* <DTSearchInput value={query} onChangeText={setQuery} onSubmit={handleSearch} />
*
* @example
* <DTSearchInput value={query} onChangeText={setQuery} loading={isSearching} variant="emphasis" />
*/
export function DTSearchInput({
value,
onChangeText,
onSubmit,
placeholder = 'Search...',
variant = 'normal',
loading = false,
disabled = false,
autoFocus = false,
color,
style,
inputStyle,
}: DTSearchInputProps) {
const accentColor = getVariantColor(variant, color);
const [focused, setFocused] = useState(false);
const focusAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const anim = Animated.timing(focusAnim, {
toValue: focused ? 1 : 0,
duration: 150,
useNativeDriver: false,
});
anim.start();
return () => anim.stop();
}, [focused, focusAnim]);
return (
<View style={[styles.container, style]}>
<Searchbar
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
returnKeyType="search"
placeholder={placeholder}
placeholderTextColor={DTColors.disabled}
editable={!disabled}
autoFocus={autoFocus}
icon={
loading
? () => <ActivityIndicator size={20} color={accentColor} />
: 'magnify'
}
iconColor={accentColor}
inputStyle={[styles.input, {color: DTColors.light}, inputStyle]}
style={[styles.searchbar, {borderColor: accentColor}]}
rippleColor={accentColor}
theme={{
colors: {
elevation: {level3: DTColors.dark},
onSurface: DTColors.light,
onSurfaceVariant: accentColor,
},
roundness: 0,
}}
/>
{/* Focus glow bar — matches DTTextInput */}
<Animated.View
style={[
styles.focusBar,
{
backgroundColor: accentColor,
opacity: focusAnim,
},
]}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {},
searchbar: {
borderWidth: 2,
backgroundColor: DTColors.dark,
elevation: 0,
},
input: {
fontSize: 14,
letterSpacing: 0.3,
},
focusBar: {
height: 4,
marginTop: 1,
},
});

View File

@@ -0,0 +1,152 @@
/**
* DT Switch Component
*
* Custom-built angular toggle switch following the Dangerous Things design language.
* RNP Switch delegates to native <NativeSwitch> with no shape control,
* so we build a rectangular toggle from scratch.
*/
import {useRef, useEffect} from 'react';
import {
StyleSheet,
ViewStyle,
StyleProp,
Pressable,
Animated,
} from 'react-native';
import {Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
const TRACK_WIDTH = 48;
const TRACK_HEIGHT = 26;
const THUMB_SIZE = 20;
const THUMB_MARGIN = 3;
interface DTSwitchProps {
/**
* Whether the switch is on
*/
value: boolean;
/**
* Called when the value changes
*/
onValueChange: (value: boolean) => void;
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Whether the switch is disabled
*/
disabled?: boolean;
/**
* Label text displayed beside the switch
*/
label?: string;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Additional styles for the container
*/
style?: StyleProp<ViewStyle>;
}
/**
* DT-styled angular Switch/Toggle
*
* @example
* <DTSwitch value={enabled} onValueChange={setEnabled} label="Enable NFC" />
*
* @example
* <DTSwitch value={true} onValueChange={toggle} variant="success" />
*/
export function DTSwitch({
value,
onValueChange,
variant = 'normal',
disabled = false,
label,
color,
style,
}: DTSwitchProps) {
const accentColor = getVariantColor(variant, color);
const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => {
Animated.timing(thumbAnim, {
toValue: value ? 1 : 0,
duration: 150,
useNativeDriver: false,
}).start();
}, [value, thumbAnim]);
const thumbTranslateX = thumbAnim.interpolate({
inputRange: [0, 1],
outputRange: [THUMB_MARGIN, TRACK_WIDTH - THUMB_SIZE - THUMB_MARGIN],
});
const trackColor = thumbAnim.interpolate({
inputRange: [0, 1],
outputRange: [DTColors.disabledBackground, accentColor],
});
return (
<Pressable
onPress={() => !disabled && onValueChange(!value)}
style={({pressed}) => [
styles.container,
{opacity: disabled ? 0.5 : pressed ? 0.7 : 1},
style,
]}>
{label && (
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
)}
<Animated.View
style={[
styles.track,
{
backgroundColor: trackColor,
borderColor: accentColor,
},
]}>
<Animated.View
style={[
styles.thumb,
{
backgroundColor: value ? DTColors.dark : DTColors.light,
transform: [{translateX: thumbTranslateX}],
},
]}
/>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 8,
},
label: {
fontSize: 14,
letterSpacing: 0.3,
},
track: {
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
borderWidth: 2,
justifyContent: 'center',
},
thumb: {
width: THUMB_SIZE,
height: THUMB_SIZE,
},
});

View File

@@ -0,0 +1,172 @@
/**
* DT TextInput Component
*
* A themed text input wrapping React Native Paper's TextInput
* with the Dangerous Things angular aesthetic.
*
* Web CSS reference (.mode-input):
* - 2px solid border in mode color
* - Focus: box-shadow 0 4px 0 1px var(--mode)
* - border-radius: 0
*/
import {useState, useRef, useEffect} from 'react';
import {
StyleSheet,
View,
ViewStyle,
TextStyle,
StyleProp,
Animated,
} from 'react-native';
import {TextInput, TextInputProps} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTTextInputProps
extends Omit<TextInputProps, 'mode' | 'theme' | 'error' | 'ref'> {
/**
* Visual variant
* @default 'normal'
*/
variant?: DTVariant;
/**
* Custom color (overrides variant)
*/
color?: string;
/**
* Border width
* @default 2
*/
borderWidth?: number;
/**
* Whether the input is in error state
*/
error?: boolean;
/**
* Error message text displayed below input
*/
errorMessage?: string;
/**
* Additional styles for the outer wrapper
*/
containerStyle?: StyleProp<ViewStyle>;
/**
* Additional styles for the input text
*/
inputStyle?: StyleProp<TextStyle>;
}
/**
* DT-styled TextInput wrapping React Native Paper TextInput
*
* @example
* <DTTextInput variant="normal" label="Username" value={name} onChangeText={setName} />
*
* @example
* <DTTextInput variant="warning" error errorMessage="Required field" label="Email" />
*/
export function DTTextInput({
variant = 'normal',
color,
borderWidth = 2,
error = false,
errorMessage,
containerStyle,
inputStyle,
onFocus,
onBlur,
style,
...props
}: DTTextInputProps) {
const [focused, setFocused] = useState(false);
const focusAnim = useRef(new Animated.Value(0)).current;
const accentColor = error
? DTColors.modeWarning
: getVariantColor(variant, color);
useEffect(() => {
const anim = Animated.timing(focusAnim, {
toValue: focused ? 1 : 0,
duration: 150,
useNativeDriver: false,
});
anim.start();
return () => anim.stop();
}, [focused, focusAnim]);
return (
<View style={containerStyle}>
<View
style={[
styles.inputWrapper,
{borderColor: accentColor, borderWidth},
]}>
<TextInput
{...props}
mode="flat"
error={false}
onFocus={e => {
setFocused(true);
onFocus?.(e);
}}
onBlur={e => {
setFocused(false);
onBlur?.(e);
}}
style={[styles.input, inputStyle, style]}
textColor={DTColors.light}
placeholderTextColor={DTColors.disabled}
activeUnderlineColor="transparent"
underlineColor="transparent"
theme={{
colors: {
primary: accentColor,
onSurfaceVariant: accentColor,
},
roundness: 0,
}}
/>
</View>
{/* Focus glow bar — simulates web box-shadow: 0 4px 0 1px */}
<Animated.View
style={[
styles.focusBar,
{
backgroundColor: accentColor,
opacity: focusAnim,
},
]}
/>
{error && errorMessage && (
<View style={styles.errorContainer}>
<Animated.Text style={[styles.errorText, {color: DTColors.modeWarning}]}>
{errorMessage}
</Animated.Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
inputWrapper: {
backgroundColor: DTColors.dark,
},
input: {
backgroundColor: 'transparent',
},
focusBar: {
height: 4,
marginTop: 1,
},
errorContainer: {
marginTop: 4,
paddingHorizontal: 4,
},
errorText: {
fontSize: 12,
letterSpacing: 0.5,
},
});

View File

@@ -0,0 +1,77 @@
/**
* @anthropic-dangerous-things/react-native-theme
*
* Dangerous Things design system for React Native with React Native Paper.
* Cyberpunk aesthetic with high-contrast neon colors.
*/
// Theme exports
export { DTColors, getColor } from './theme/colors';
export type { DTColorKey } from './theme/colors';
export {
DTTypography,
DTFonts,
DTFontWeights,
} from './theme/typography';
export type { DTTypographyKey } from './theme/typography';
export {
DTDarkTheme,
DTExtendedDarkTheme,
usePaperTheme,
} from './theme/paperTheme';
export type { DTExtendedTheme } from './theme/paperTheme';
export { DTThemeProvider, useDTTheme } from './theme/DTThemeProvider';
// Shared utilities
export type { DTVariant } from './utils/variantColors';
export { variantColorMap, getVariantColor } from './utils/variantColors';
export { buildBeveledRectPath } from './utils/bevelPaths';
export { useComponentLayout } from './utils/useComponentLayout';
// Component exports — existing
export { DTCard, DTCardClipPath } from './components/DTCard';
export { DTButton } from './components/DTButton';
export { DTChip } from './components/DTChip';
export { DTLabel, DTLabelClipPath } from './components/DTLabel';
// Component exports — form primitives
export { DTTextInput } from './components/DTTextInput';
export { DTCheckbox } from './components/DTCheckbox';
export { DTSwitch } from './components/DTSwitch';
export { DTRadioGroup, DTRadioOption } from './components/DTRadioGroup';
export { DTQuantityStepper } from './components/DTQuantityStepper';
// Component exports — layout & containers
export { DTProgressBar } from './components/DTProgressBar';
export { DTMediaFrame } from './components/DTMediaFrame';
export { DTAccordion } from './components/DTAccordion';
export type { DTAccordionSection } from './components/DTAccordion';
// Component exports — complex interactive
export { DTModal } from './components/DTModal';
export { DTDrawer } from './components/DTDrawer';
export { DTGallery } from './components/DTGallery';
export type { DTGalleryItem } from './components/DTGallery';
export { DTSearchInput } from './components/DTSearchInput';
// Component exports — navigation & decorative
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
export type { DTMenuItem } from './components/DTMenu';
export { DTHexagon } from './components/DTHexagon';
// Re-export commonly used Paper components for convenience
export {
Text,
Surface,
IconButton,
ActivityIndicator,
Divider,
List,
Portal,
Modal,
Snackbar,
TextInput,
} from 'react-native-paper';

View File

@@ -0,0 +1,80 @@
/**
* DT Theme Provider
*
* Wraps your app with the Dangerous Things theme.
* Combines React Native Paper's PaperProvider with
* custom theme context for extended DT colors.
*/
import { createContext, useContext, ReactNode } from 'react';
import { PaperProvider } from 'react-native-paper';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { StatusBar } from 'react-native';
import { DTExtendedDarkTheme, DTExtendedTheme } from './paperTheme';
/**
* Context for accessing extended theme properties
*/
const DTThemeContext = createContext<DTExtendedTheme>(DTExtendedDarkTheme);
/**
* Hook to access the full DT theme including custom properties
*
* @example
* const theme = useDTTheme();
* const successColor = theme.custom.success;
*/
export function useDTTheme(): DTExtendedTheme {
return useContext(DTThemeContext);
}
interface DTThemeProviderProps {
children: ReactNode;
/**
* Override the default theme (useful for testing or custom variants)
*/
theme?: DTExtendedTheme;
/**
* Include SafeAreaProvider wrapper (default: true)
* Set to false if you already have SafeAreaProvider higher in tree
*/
includeSafeArea?: boolean;
}
/**
* Theme provider component
*
* @example
* // In your App.tsx
* import { DTThemeProvider } from '@anthropic-dangerous-things/react-native-theme';
*
* export default function App() {
* return (
* <DTThemeProvider>
* <NavigationContainer>
* <YourApp />
* </NavigationContainer>
* </DTThemeProvider>
* );
* }
*/
export function DTThemeProvider({
children,
theme = DTExtendedDarkTheme,
includeSafeArea = true,
}: DTThemeProviderProps) {
const content = (
<DTThemeContext.Provider value={theme}>
<PaperProvider theme={theme}>
<StatusBar barStyle="light-content" backgroundColor="#000000" />
{children}
</PaperProvider>
</DTThemeContext.Provider>
);
if (includeSafeArea) {
return <SafeAreaProvider>{content}</SafeAreaProvider>;
}
return content;
}

View File

@@ -0,0 +1,69 @@
/**
* Dangerous Things Color Palette
*
* Based on the dt-shopify-storefront design system.
* Cyberpunk aesthetic with high-contrast neon colors on dark backgrounds.
*/
export const DTColors = {
// Base colors
dark: '#000000',
light: '#FFFFFF',
// Mode colors (primary semantic palette)
modeNormal: '#00FFFF', // Cyan - primary actions, links, borders
modeNormalSelected: 'rgba(0, 255, 255, 0.7)',
modeEmphasis: '#FFFF00', // Yellow - highlights, emphasis, headers
modeEmphasisSelected: 'rgba(255, 255, 0, 0.7)',
modeWarning: '#FF0000', // Red - errors, warnings, destructive actions
modeSuccess: '#00FF00', // Green - success states, confirmations
modeOther: '#FF00FF', // Magenta - miscellaneous, secondary info
// Semantic aliases
primary: '#00FFFF',
primaryVariant: 'rgba(0, 255, 255, 0.7)',
secondary: '#FFFF00',
secondaryVariant: 'rgba(255, 255, 0, 0.7)',
error: '#FF0000',
success: '#00FF00',
// Surface colors
background: '#000000',
surface: '#000000',
surfaceVariant: '#070707',
// Text colors
onBackground: '#FFFFFF',
onSurface: '#FFFFFF',
onPrimary: '#000000',
onSecondary: '#000000',
onError: '#000000',
// Border colors
border: '#00FFFF',
borderEmphasis: '#FFFF00',
// Transparency variants (for overlays, disabled states)
overlay: 'rgba(0, 0, 0, 0.7)',
disabled: 'rgba(255, 255, 255, 0.3)',
disabledBackground: 'rgba(255, 255, 255, 0.1)',
} as const;
export type DTColorKey = keyof typeof DTColors;
/**
* Get a color with optional opacity override
*/
export function getColor(color: DTColorKey, opacity?: number): string {
const baseColor = DTColors[color];
if (opacity === undefined || baseColor.startsWith('rgba')) {
return baseColor;
}
// Convert hex to rgba
const hex = baseColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}

View File

@@ -0,0 +1,149 @@
/**
* React Native Paper MD3 Theme Configuration
*
* This creates a Material Design 3 compliant theme that follows
* the Dangerous Things cyberpunk aesthetic while respecting
* React Native Paper's design principles.
*/
import { MD3DarkTheme, MD3Theme, configureFonts } from 'react-native-paper';
import { DTColors } from './colors';
import { DTTypography, DTFonts } from './typography';
/**
* MD3 Font configuration for React Native Paper
* Maps our typography to RNP's expected font config
*/
const fontConfig = {
fontFamily: DTFonts.regular,
displayLarge: DTTypography.displayLarge,
displayMedium: DTTypography.displayMedium,
displaySmall: DTTypography.displaySmall,
headlineLarge: DTTypography.headlineLarge,
headlineMedium: DTTypography.headlineMedium,
headlineSmall: DTTypography.headlineSmall,
titleLarge: DTTypography.titleLarge,
titleMedium: DTTypography.titleMedium,
titleSmall: DTTypography.titleSmall,
bodyLarge: DTTypography.bodyLarge,
bodyMedium: DTTypography.bodyMedium,
bodySmall: DTTypography.bodySmall,
labelLarge: DTTypography.labelLarge,
labelMedium: DTTypography.labelMedium,
labelSmall: DTTypography.labelSmall,
};
/**
* Dangerous Things Dark Theme
*
* Based on MD3DarkTheme with cyberpunk color overrides.
* Primary = Cyan, Secondary = Yellow (following DT brand)
*/
export const DTDarkTheme: MD3Theme = {
...MD3DarkTheme,
dark: true,
roundness: 4, // Slightly rounded, but we use custom clip-path for bevels
fonts: configureFonts({ config: fontConfig }),
colors: {
...MD3DarkTheme.colors,
// Primary (Cyan)
primary: DTColors.modeNormal,
onPrimary: DTColors.dark,
primaryContainer: DTColors.modeNormalSelected,
onPrimaryContainer: DTColors.dark,
// Secondary (Yellow)
secondary: DTColors.modeEmphasis,
onSecondary: DTColors.dark,
secondaryContainer: DTColors.modeEmphasisSelected,
onSecondaryContainer: DTColors.dark,
// Tertiary (Magenta)
tertiary: DTColors.modeOther,
onTertiary: DTColors.dark,
tertiaryContainer: 'rgba(255, 0, 255, 0.3)',
onTertiaryContainer: DTColors.light,
// Error (Red)
error: DTColors.modeWarning,
onError: DTColors.dark,
errorContainer: 'rgba(255, 0, 0, 0.3)',
onErrorContainer: DTColors.light,
// Background & Surface
background: DTColors.background,
onBackground: DTColors.onBackground,
surface: DTColors.surface,
onSurface: DTColors.onSurface,
surfaceVariant: DTColors.surfaceVariant,
onSurfaceVariant: DTColors.light,
// Surface elevation (MD3 uses tonal elevation)
elevation: {
level0: 'transparent',
level1: DTColors.surfaceVariant,
level2: '#0a0a0a',
level3: '#0f0f0f',
level4: '#121212',
level5: '#151515',
},
// Outline (borders)
outline: DTColors.modeNormal,
outlineVariant: 'rgba(0, 255, 255, 0.5)',
// Inverse (for snackbars, etc.)
inverseSurface: DTColors.light,
inverseOnSurface: DTColors.dark,
inversePrimary: '#008B8B', // Darker cyan for light backgrounds
// Misc
shadow: DTColors.dark,
scrim: DTColors.overlay,
surfaceDisabled: DTColors.disabledBackground,
onSurfaceDisabled: DTColors.disabled,
backdrop: DTColors.overlay,
},
};
/**
* Extended theme with DT-specific custom properties
* Use this when you need access to non-MD3 colors
*/
export interface DTExtendedTheme extends MD3Theme {
custom: {
success: string;
warning: string;
modeNormal: string;
modeEmphasis: string;
modeWarning: string;
modeSuccess: string;
modeOther: string;
border: string;
borderEmphasis: string;
};
}
export const DTExtendedDarkTheme: DTExtendedTheme = {
...DTDarkTheme,
custom: {
success: DTColors.modeSuccess,
warning: DTColors.modeWarning,
modeNormal: DTColors.modeNormal,
modeEmphasis: DTColors.modeEmphasis,
modeWarning: DTColors.modeWarning,
modeSuccess: DTColors.modeSuccess,
modeOther: DTColors.modeOther,
border: DTColors.border,
borderEmphasis: DTColors.borderEmphasis,
},
};
/**
* Hook to access extended theme properties
* Usage: const { custom } = useDTTheme();
*/
export { useTheme as usePaperTheme } from 'react-native-paper';

View File

@@ -0,0 +1,190 @@
/**
* Dangerous Things Typography
*
* Primary font: Tektur (variable font)
* Fallback: System default sans-serif
*
* Note: To use Tektur, you must:
* 1. Download Tektur from Google Fonts
* 2. Add to your project's assets/fonts/
* 3. Link fonts in react-native.config.js or use expo-font
*/
import { Platform, TextStyle } from 'react-native';
/**
* Font family configuration
* Tektur is a variable font with weights 100-900
*/
export const DTFonts = {
regular: Platform.select({
ios: 'Tektur',
android: 'Tektur-Regular',
default: 'Tektur',
}),
medium: Platform.select({
ios: 'Tektur',
android: 'Tektur-Medium',
default: 'Tektur',
}),
bold: Platform.select({
ios: 'Tektur',
android: 'Tektur-Bold',
default: 'Tektur',
}),
// Fallback when Tektur is not available
fallback: Platform.select({
ios: 'System',
android: 'sans-serif',
default: 'sans-serif',
}),
} as const;
/**
* Font weights for variable font
*/
export const DTFontWeights = {
thin: '100' as const,
light: '300' as const,
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
extrabold: '800' as const,
black: '900' as const,
};
/**
* Base text styles
*/
const baseTextStyle: TextStyle = {
fontFamily: DTFonts.regular,
color: '#FFFFFF',
letterSpacing: 0.5,
};
/**
* Typography scale following Material Design 3 naming
*/
export const DTTypography = {
// Display styles (large headlines)
displayLarge: {
...baseTextStyle,
fontSize: 57,
fontWeight: DTFontWeights.bold,
lineHeight: 64,
letterSpacing: -0.25,
} as TextStyle,
displayMedium: {
...baseTextStyle,
fontSize: 45,
fontWeight: DTFontWeights.bold,
lineHeight: 52,
} as TextStyle,
displaySmall: {
...baseTextStyle,
fontSize: 36,
fontWeight: DTFontWeights.bold,
lineHeight: 44,
} as TextStyle,
// Headline styles
headlineLarge: {
...baseTextStyle,
fontSize: 32,
fontWeight: DTFontWeights.semibold,
lineHeight: 40,
} as TextStyle,
headlineMedium: {
...baseTextStyle,
fontSize: 28,
fontWeight: DTFontWeights.semibold,
lineHeight: 36,
} as TextStyle,
headlineSmall: {
...baseTextStyle,
fontSize: 24,
fontWeight: DTFontWeights.semibold,
lineHeight: 32,
} as TextStyle,
// Title styles
titleLarge: {
...baseTextStyle,
fontSize: 22,
fontWeight: DTFontWeights.medium,
lineHeight: 28,
} as TextStyle,
titleMedium: {
...baseTextStyle,
fontSize: 16,
fontWeight: DTFontWeights.medium,
lineHeight: 24,
letterSpacing: 0.15,
} as TextStyle,
titleSmall: {
...baseTextStyle,
fontSize: 14,
fontWeight: DTFontWeights.medium,
lineHeight: 20,
letterSpacing: 0.1,
} as TextStyle,
// Body styles
bodyLarge: {
...baseTextStyle,
fontSize: 16,
fontWeight: DTFontWeights.regular,
lineHeight: 24,
letterSpacing: 0.5,
} as TextStyle,
bodyMedium: {
...baseTextStyle,
fontSize: 14,
fontWeight: DTFontWeights.regular,
lineHeight: 20,
letterSpacing: 0.25,
} as TextStyle,
bodySmall: {
...baseTextStyle,
fontSize: 12,
fontWeight: DTFontWeights.regular,
lineHeight: 16,
letterSpacing: 0.4,
} as TextStyle,
// Label styles
labelLarge: {
...baseTextStyle,
fontSize: 14,
fontWeight: DTFontWeights.medium,
lineHeight: 20,
letterSpacing: 0.1,
} as TextStyle,
labelMedium: {
...baseTextStyle,
fontSize: 12,
fontWeight: DTFontWeights.medium,
lineHeight: 16,
letterSpacing: 0.5,
} as TextStyle,
labelSmall: {
...baseTextStyle,
fontSize: 11,
fontWeight: DTFontWeights.medium,
lineHeight: 16,
letterSpacing: 0.5,
} as TextStyle,
} as const;
export type DTTypographyKey = keyof typeof DTTypography;

View File

@@ -0,0 +1,152 @@
/**
* Shared SVG bevel path builders
*
* Generic and preset functions for generating SVG path data
* for beveled rectangle shapes used across DT components.
*/
type BevelCorner = 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft';
interface BevelConfig {
corners: Partial<Record<BevelCorner, number>>;
strokeWidth?: number;
/** Additional inset applied to all coordinates (used for frame overlays) */
offset?: number;
}
/**
* Build an SVG path for a rectangle with any combination of beveled corners.
*
* Each corner can have an independent bevel size. Corners without a bevel
* value (or with 0) are rendered as sharp 90-degree angles.
*/
export function buildBeveledRectPath(
width: number,
height: number,
config: BevelConfig,
): string {
const sw = config.strokeWidth ?? 0;
const off = config.offset ?? 0;
const inset = sw / 2 + off;
const w = width - sw - off * 2;
const h = height - sw - off * 2;
if (w <= 0 || h <= 0) return '';
const maxBevel = Math.min(w / 3, h / 3);
const tl = Math.min(config.corners.topLeft ?? 0, maxBevel);
const tr = Math.min(config.corners.topRight ?? 0, maxBevel);
const br = Math.min(config.corners.bottomRight ?? 0, maxBevel);
const bl = Math.min(config.corners.bottomLeft ?? 0, maxBevel);
// Build path clockwise from top-left
return [
// Top-left corner
`M ${inset + tl} ${inset}`,
// Top edge → top-right corner
`L ${w + inset - tr} ${inset}`,
tr > 0 ? `L ${w + inset} ${inset + tr}` : `L ${w + inset} ${inset}`,
// Right edge → bottom-right corner
br > 0
? `L ${w + inset} ${h + inset - br} L ${w + inset - br} ${h + inset}`
: `L ${w + inset} ${h + inset}`,
// Bottom edge → bottom-left corner
bl > 0
? `L ${inset + bl} ${h + inset} L ${inset} ${h + inset - bl}`
: `L ${inset} ${h + inset}`,
// Left edge → back to top-left
tl > 0 ? `L ${inset} ${inset + tl}` : `L ${inset} ${inset}`,
'Z',
].join(' ');
}
/**
* DTButton shape: bottom-right bevel only
*/
export function buildButtonBevelPath(
width: number,
height: number,
bevelSize: number,
strokeWidth: number,
): string {
return buildBeveledRectPath(width, height, {
corners: {bottomRight: bevelSize},
strokeWidth,
});
}
/**
* DTCard shape: bottom-right (large) + bottom-left (small) bevels
*/
export function buildCardBevelPath(
width: number,
height: number,
bevelBR: number,
bevelBL: number,
inset: number,
): string {
const w = width - inset * 2;
const h = height - inset * 2;
if (w <= 0 || h <= 0) return '';
const br = Math.min(bevelBR, w / 3, h / 3);
const bl = Math.min(bevelBL, w / 3, h / 3);
return `
M ${inset} ${inset}
L ${w + inset} ${inset}
L ${w + inset} ${h - br + inset}
L ${w - br + inset} ${h + inset}
L ${bl + inset} ${h + inset}
L ${inset} ${h - bl + inset}
Z
`;
}
/**
* DTLabel shape: top-right bevel only
*/
export function buildLabelBevelPath(
width: number,
height: number,
bevelSize: number,
): string {
return buildBeveledRectPath(width, height, {
corners: {topRight: bevelSize},
});
}
/**
* DTMediaFrame shape: top-left + bottom-right (diagonal symmetry)
*/
export function buildMediaFrameBevelPath(
width: number,
height: number,
bevelSize: number,
offset = 0,
): string {
return buildBeveledRectPath(width, height, {
corners: {topLeft: bevelSize, bottomRight: bevelSize},
offset,
});
}
/**
* DTDrawer shape (right-side): top-left + bottom-left bevels
*/
export function buildDrawerBevelPath(
width: number,
height: number,
bevelSize: number,
position: 'left' | 'right' = 'right',
): string {
if (position === 'right') {
return buildBeveledRectPath(width, height, {
corners: {topLeft: bevelSize, bottomLeft: bevelSize},
});
}
return buildBeveledRectPath(width, height, {
corners: {topRight: bevelSize, bottomRight: bevelSize},
});
}

View File

@@ -0,0 +1,33 @@
/**
* Shared layout measurement hook
*
* Extracts the common pattern of tracking component dimensions
* via onLayout for SVG rendering.
*/
import {useState, useCallback} from 'react';
import {LayoutChangeEvent} from 'react-native';
interface ComponentDimensions {
width: number;
height: number;
}
export function useComponentLayout() {
const [dimensions, setDimensions] = useState<ComponentDimensions>({
width: 0,
height: 0,
});
const onLayout = useCallback((event: LayoutChangeEvent) => {
const {width, height} = event.nativeEvent.layout;
setDimensions(prev => {
if (prev.width === width && prev.height === height) return prev;
return {width, height};
});
}, []);
const hasDimensions = dimensions.width > 0 && dimensions.height > 0;
return {dimensions, onLayout, hasDimensions};
}

View File

@@ -0,0 +1,27 @@
/**
* Centralized variant type and color mapping
*
* Used by all DT components for consistent mode/variant color resolution.
*/
import {DTColors} from '../theme/colors';
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
export const variantColorMap: Record<DTVariant, string> = {
normal: DTColors.modeNormal,
emphasis: DTColors.modeEmphasis,
warning: DTColors.modeWarning,
success: DTColors.modeSuccess,
other: DTColors.modeOther,
};
/**
* Resolve variant to color, with optional custom color override.
*/
export function getVariantColor(
variant: DTVariant,
customColor?: string,
): string {
return customColor ?? variantColorMap[variant];
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["ES2022"],
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}