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

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -0,0 +1,11 @@
---
"@dangerousthings/tokens": minor
"@dangerousthings/web": minor
"@dangerousthings/react-native": minor
---
Initial release of the DT design system monorepo.
- `@dangerousthings/tokens`: Canonical design tokens for DT, Classic, and Supra brands with CSS generation
- `@dangerousthings/web`: Web CSS theme with bevels, glows, form styles, and Tektur font
- `@dangerousthings/react-native`: 18 React Native themed components (DTButton, DTCard, DTTextInput, etc.)

52
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Build all packages
run: npx turbo run build
- name: Typecheck
run: npx turbo run typecheck
publish-check:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx turbo run build
- name: Check for changesets
run: npx changeset status

37
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Release
on:
push:
branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npx turbo run build
- name: Create release PR or publish
uses: changesets/action@v1
with:
publish: npx changeset publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Turbo cache
.turbo/
# OS files
.DS_Store
Thumbs.db
# Editor
.idea/
.vscode/
*.swp
*.swo
# Env
.env
.env.local
# Logs
*.log
npm-debug.log*

5378
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "dt-design-system",
"version": "0.0.0",
"private": true,
"description": "Dangerous Things design system — shared tokens, web CSS, and React Native components",
"repository": {
"type": "git",
"url": "https://github.com/dangerous-tac0s/dt-design-system"
},
"license": "MIT",
"workspaces": [
"packages/*"
],
"scripts": {
"build": "turbo run build",
"build:tokens": "npm run build -w packages/tokens",
"build:web": "npm run build -w packages/web",
"build:react-native": "npm run build -w packages/react-native",
"clean": "turbo run clean",
"typecheck": "turbo run typecheck",
"lint": "turbo run lint",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"turbo": "^2.4.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "npm@10.0.0"
}

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"]
}

View File

@@ -0,0 +1,36 @@
{
"name": "@dangerousthings/tokens",
"version": "0.1.0",
"description": "Canonical design tokens for the Dangerous Things design system",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./brands/*": {
"types": "./dist/brands/*.d.ts",
"import": "./dist/brands/*.js"
},
"./css/*": "./dist/css/*"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && node dist/scripts/generate-css.js",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.3.3",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,66 @@
import type { BrandTokens } from "../types.js";
export const classic: BrandTokens = {
id: "classic",
name: "Classic Cyberpunk",
description: "Original dark navy aesthetic with magenta accents",
dark: {
bg: "#0a0e1a",
surface: "#121827",
surfaceHover: "#1a2332",
border: "#1e293b",
textPrimary: "#e2e8f0",
textSecondary: "#94a3b8",
textMuted: "#64748b",
primary: "#00ffff",
primaryDim: "#0891b2",
secondary: "#ff00ff",
accent: "#00ff88",
other: "#ff00ff",
success: "#00ff88",
warning: "#ffaa00",
error: "#ff0055",
info: "#00ffff",
},
light: {
bg: "#ffffff",
surface: "#f8fafc",
surfaceHover: "#f1f5f9",
border: "#e2e8f0",
textPrimary: "#0f172a",
textSecondary: "#475569",
textMuted: "#94a3b8",
primary: "#0891b2",
primaryDim: "#0e7490",
secondary: "#c026d3",
accent: "#00ff88",
other: "#c026d3",
success: "#16a34a",
warning: "#d97706",
error: "#dc2626",
info: "#0891b2",
},
typography: {
heading: "var(--font-sans)",
body: "var(--font-sans)",
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
},
shape: {
bevelSm: "0px",
bevelMd: "0px",
bevelLg: "0px",
radiusSm: "0.25rem",
radius: "0.5rem",
radiusLg: "0.75rem",
},
};

View File

@@ -0,0 +1,66 @@
import type { BrandTokens } from "../types.js";
export const dt: BrandTokens = {
id: "dt",
name: "Dangerous Things",
description: "Official DT brand — Tektur font, neon cyberpunk, beveled corners",
dark: {
bg: "#000000",
surface: "#0a0a0a",
surfaceHover: "#141414",
border: "#00ffff",
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
primary: "#00ffff",
primaryDim: "#0891b2",
secondary: "#ffff00",
accent: "#00ff00",
other: "#ff00ff",
success: "#00ff00",
warning: "#ffff00",
error: "#ff0000",
info: "#00ffff",
},
light: {
bg: "#ffffff",
surface: "#f8fafc",
surfaceHover: "#f1f5f9",
border: "#000000",
textPrimary: "#000000",
textSecondary: "#475569",
textMuted: "#94a3b8",
primary: "#0891b2",
primaryDim: "#0e7490",
secondary: "#a16207",
accent: "#15803d",
other: "#ff00ff",
success: "#15803d",
warning: "#a16207",
error: "#dc2626",
info: "#0891b2",
},
typography: {
heading: "'Tektur', var(--font-sans)",
body: "'Tektur', var(--font-sans)",
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
},
shape: {
bevelSm: "1rem",
bevelMd: "2rem",
bevelLg: "2.5rem",
radiusSm: "0",
radius: "0",
radiusLg: "0",
},
};

View File

@@ -0,0 +1,66 @@
import type { BrandTokens } from "../types.js";
export const supra: BrandTokens = {
id: "supra",
name: "VivoKey Supra",
description: "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
dark: {
bg: "#000000",
surface: "#28292E",
surfaceHover: "#303136",
border: "#42667E",
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
primary: "#42667E",
primaryDim: "#385872",
secondary: "#8BAEC7",
accent: "#8BAEC7",
other: "#F67448",
success: "#8BB174",
warning: "#F67448",
error: "#ED4337",
info: "#42667E",
},
light: {
bg: "#ffffff",
surface: "#AFB8C2",
surfaceHover: "#C5CCD4",
border: "#000000",
textPrimary: "#000000",
textSecondary: "#374151",
textMuted: "#6b7280",
primary: "#000000",
primaryDim: "#1f2937",
secondary: "#F67448",
accent: "#FDFE1B",
other: "#F67448",
success: "#8BB174",
warning: "#F67448",
error: "#ED4337",
info: "#42667E",
},
typography: {
heading: "var(--font-sans)",
body: "var(--font-sans)",
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
},
shape: {
bevelSm: "0px",
bevelMd: "0px",
bevelLg: "0px",
radiusSm: "0.5rem",
radius: "0.625rem",
radiusLg: "0.75rem",
},
};

View File

@@ -0,0 +1,46 @@
export type {
ThemeBrand,
ThemeMode,
ThemeDefinition,
ColorTokens,
TypographyTokens,
ShapeTokens,
BrandTokens,
} from "./types.js";
export { dt } from "./brands/dt.js";
export { classic } from "./brands/classic.js";
export { supra } from "./brands/supra.js";
import { dt } from "./brands/dt.js";
import { classic } from "./brands/classic.js";
import { supra } from "./brands/supra.js";
import type { BrandTokens, ThemeDefinition } from "./types.js";
/** All brand tokens keyed by id */
export const brands: Record<string, BrandTokens> = { dt, classic, supra };
/** Theme registry for UI selectors */
export const themes: ThemeDefinition[] = [
{
id: "dt",
name: dt.name,
description: dt.description,
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
{
id: "classic",
name: classic.name,
description: classic.description,
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
{
id: "supra",
name: supra.name,
description: supra.description,
supportsModes: ["dark", "light", "auto"],
defaultMode: "dark",
},
];

View File

@@ -0,0 +1,129 @@
/**
* Generates CSS custom property files from canonical TypeScript token definitions.
* Run via: node dist/scripts/generate-css.js
* Output: dist/css/{brand}.css
*/
import { writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { BrandTokens, ColorTokens } from "../types.js";
import { dt } from "../brands/dt.js";
import { classic } from "../brands/classic.js";
import { supra } from "../brands/supra.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = join(__dirname, "..", "css");
/** Convert hex #rrggbb to "r, g, b" triplet for rgba() usage */
function hexToRgb(hex: string): string {
const h = hex.replace("#", "");
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
/** Generate CSS custom properties from color tokens */
function colorVars(colors: ColorTokens): string {
return ` /* Backgrounds */
--color-bg: ${colors.bg};
--color-bg-rgb: ${hexToRgb(colors.bg)};
--color-surface: ${colors.surface};
--color-surface-rgb: ${hexToRgb(colors.surface)};
--color-surface-hover: ${colors.surfaceHover};
--color-border: ${colors.border};
/* Text */
--color-text-primary: ${colors.textPrimary};
--color-text-secondary: ${colors.textSecondary};
--color-text-muted: ${colors.textMuted};
/* Brand Palette */
--color-primary: ${colors.primary};
--color-primary-rgb: ${hexToRgb(colors.primary)};
--color-primary-dim: ${colors.primaryDim};
--color-secondary: ${colors.secondary};
--color-secondary-rgb: ${hexToRgb(colors.secondary)};
--color-accent: ${colors.accent};
--color-accent-rgb: ${hexToRgb(colors.accent)};
--color-other: ${colors.other};
--color-other-rgb: ${hexToRgb(colors.other)};
/* Status */
--color-success: ${colors.success};
--color-success-rgb: ${hexToRgb(colors.success)};
--color-warning: ${colors.warning};
--color-warning-rgb: ${hexToRgb(colors.warning)};
--color-error: ${colors.error};
--color-error-rgb: ${hexToRgb(colors.error)};
--color-info: ${colors.info};
--color-info-rgb: ${hexToRgb(colors.info)};`;
}
/** Generate a full brand CSS file */
function generateBrandCSS(brand: BrandTokens): string {
const id = brand.id;
// DT is also the default (when no data-brand is set)
const darkSelector =
id === "dt"
? `:root[data-brand="${id}"],\n:root:not([data-brand])`
: `:root[data-brand="${id}"]`;
let css = `/* @dangerousthings/tokens — Generated CSS for "${brand.name}" */\n/* DO NOT EDIT — regenerate with: npm run build -w packages/tokens */\n\n`;
// Dark mode (default)
css += `/* ${brand.name} — Dark Mode (Default) */\n`;
css += `${darkSelector} {\n`;
css += colorVars(brand.dark) + "\n\n";
css += ` /* Typography */\n`;
css += ` --font-heading: ${brand.typography.heading};\n`;
css += ` --font-body: ${brand.typography.body};\n\n`;
css += ` /* Shape */\n`;
css += ` --bevel-sm: ${brand.shape.bevelSm};\n`;
css += ` --bevel-md: ${brand.shape.bevelMd};\n`;
css += ` --bevel-lg: ${brand.shape.bevelLg};\n`;
css += ` --radius-sm: ${brand.shape.radiusSm};\n`;
css += ` --radius: ${brand.shape.radius};\n`;
css += ` --radius-lg: ${brand.shape.radiusLg};\n\n`;
css += ` color-scheme: dark;\n`;
css += `}\n\n`;
// Light mode (explicit)
css += `/* ${brand.name} — Light Mode */\n`;
css += `:root[data-brand="${id}"][data-theme="light"] {\n`;
css += colorVars(brand.light) + "\n\n";
css += ` color-scheme: light;\n`;
css += `}\n\n`;
// Auto mode (prefers-color-scheme)
css += `/* ${brand.name} — Auto Mode (follows system preference) */\n`;
css += `@media (prefers-color-scheme: light) {\n`;
css += ` :root[data-brand="${id}"][data-theme="auto"] {\n`;
css += colorVars(brand.light).replace(/^/gm, " ") + "\n\n";
css += ` color-scheme: light;\n`;
css += ` }\n`;
css += `}\n`;
// DT gets explicit dark override
if (id === "dt") {
css += `\n/* Force dark explicitly */\n`;
css += `:root[data-brand="${id}"][data-theme="dark"] {\n`;
css += ` color-scheme: dark;\n`;
css += `}\n`;
}
return css;
}
// --- Main ---
mkdirSync(outDir, { recursive: true });
for (const brand of [dt, classic, supra]) {
const css = generateBrandCSS(brand);
const path = join(outDir, `${brand.id}.css`);
writeFileSync(path, css, "utf-8");
console.log(` tokens: generated ${path}`);
}
console.log(" tokens: CSS generation complete");

View File

@@ -0,0 +1,65 @@
/** Brand identifiers */
export type ThemeBrand = "dt" | "classic" | "supra";
/** Color mode */
export type ThemeMode = "dark" | "light" | "auto";
/** Theme metadata for UI selectors */
export interface ThemeDefinition {
id: ThemeBrand;
name: string;
description: string;
supportsModes: ThemeMode[];
defaultMode: ThemeMode;
}
/** Color tokens for a single mode */
export interface ColorTokens {
bg: string;
surface: string;
surfaceHover: string;
border: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
primary: string;
primaryDim: string;
secondary: string;
accent: string;
other: string;
success: string;
warning: string;
error: string;
info: string;
}
/** Typography tokens */
export interface TypographyTokens {
heading: string;
body: string;
mono: string;
}
/** Shape tokens */
export interface ShapeTokens {
bevelSm: string;
bevelMd: string;
bevelLg: string;
radiusSm: string;
radius: string;
radiusLg: string;
}
/** Complete brand definition */
export interface BrandTokens {
id: ThemeBrand;
name: string;
description: string;
dark: ColorTokens;
light: ColorTokens;
typography: TypographyTokens;
shape: ShapeTokens;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

Binary file not shown.

30
packages/web/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@dangerousthings/web",
"version": "0.1.0",
"description": "Web CSS theme for the Dangerous Things design system — bevels, glows, form styles",
"license": "MIT",
"main": "dist/index.css",
"exports": {
".": "./dist/index.css",
"./tokens/*": "./dist/tokens/*",
"./components/*": "./dist/components/*",
"./fonts/*": "./dist/fonts/*",
"./theme-registry": {
"types": "./dist/theme-registry.d.ts",
"import": "./dist/theme-registry.js",
"default": "./dist/theme-registry.js"
},
"./dist/*": "./dist/*"
},
"publishConfig": {
"access": "public"
},
"files": ["dist"],
"scripts": {
"build": "node scripts/build.js",
"clean": "rm -rf dist"
},
"dependencies": {
"@dangerousthings/tokens": "*"
}
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* @dangerousthings/web build script
*
* 1. Copies src/ to dist/ (component CSS, base tokens, index.css)
* 2. Copies fonts/ to dist/fonts/
* 3. Copies generated token CSS from @dangerousthings/tokens/dist/css/ into dist/tokens/
* 4. Copies theme-registry from @dangerousthings/tokens
*
* Consumers (Vite, webpack) resolve @import chains at build time.
*/
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const src = path.join(root, "src");
const dist = path.join(root, "dist");
const fontsDir = path.join(root, "fonts");
// Resolve @dangerousthings/tokens dist directory
const tokensDir = path.join(root, "..", "tokens", "dist");
function copyDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true });
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// Clean dist
if (fs.existsSync(dist)) {
fs.rmSync(dist, { recursive: true });
}
// 1. Copy src → dist
copyDir(src, dist);
// 2. Copy fonts → dist/fonts
if (fs.existsSync(fontsDir)) {
copyDir(fontsDir, path.join(dist, "fonts"));
}
// 3. Copy generated token CSS from @dangerousthings/tokens → dist/tokens/
const tokensCssDir = path.join(tokensDir, "css");
if (fs.existsSync(tokensCssDir)) {
const destTokens = path.join(dist, "tokens");
fs.mkdirSync(destTokens, { recursive: true });
for (const file of fs.readdirSync(tokensCssDir)) {
if (file.endsWith(".css")) {
fs.copyFileSync(
path.join(tokensCssDir, file),
path.join(destTokens, file)
);
}
}
} else {
console.error("ERROR: @dangerousthings/tokens CSS not found at", tokensCssDir);
console.error("Run: npm run build -w packages/tokens first");
process.exit(1);
}
// 4. Copy theme-registry from tokens
const registryFiles = ["theme-registry.js", "theme-registry.d.ts"];
// The theme-registry is now in @dangerousthings/tokens as index.js — consumers
// import types from @dangerousthings/tokens directly. We generate a thin re-export
// for backward compatibility with dt-web-theme consumers.
const reExport = `// Re-export from @dangerousthings/tokens for backward compatibility
export { themes, brands } from "@dangerousthings/tokens";
export { DEFAULT_THEME } from "@dangerousthings/tokens";
`;
const reExportDts = `export { ThemeDefinition, ThemeBrand, ThemeMode } from "@dangerousthings/tokens";
export declare const themes: import("@dangerousthings/tokens").ThemeDefinition[];
export declare const brands: Record<string, import("@dangerousthings/tokens").BrandTokens>;
export declare const DEFAULT_THEME: string;
`;
fs.writeFileSync(path.join(dist, "theme-registry.js"), reExport);
fs.writeFileSync(path.join(dist, "theme-registry.d.ts"), reExportDts);
console.log("@dangerousthings/web: built to dist/");

31
packages/web/src/base.css Normal file
View File

@@ -0,0 +1,31 @@
/* @dangerousthings/web: Base Tokens (shared across all brands) */
:root {
/* Spacing (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition: 250ms ease;
/* Font stacks — brands override --font-heading and --font-body */
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-heading: var(--font-sans);
--font-body: var(--font-sans);
/* Border Radius — brands override these */
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-lg: 0.75rem;
/* Bevel sizes — DT sets real values; Classic/Supra leave at 0 */
--bevel-sm: 0px;
--bevel-md: 0px;
--bevel-lg: 0px;
}

View File

@@ -0,0 +1,290 @@
/* dt-web-theme: Bevel Utilities */
/* Angular/beveled clip-path patterns from the DT design system */
/* Source: dt-shopify-storefront clip-path patterns + react-native-dt-theme bevelPaths.ts */
/* */
/* clip-path clips CSS borders and box-shadow. This file uses the storefront's */
/* dual-element technique: the element's own background serves as the "border" color, */
/* and a ::before pseudo-element provides the inner surface fill. The gap between */
/* them follows the clip-path shape perfectly. */
/* */
/* For glow effects on clipped elements, use filter: drop-shadow() (not box-shadow). */
/* ============================================================================
Card Bevels — dual bottom bevels (bottom-right large + bottom-left small)
Source: storefront .card-container + .clip-corners-inner pattern
Outer bg = border color, ::before = surface fill
Border width: ~3px uniform (storefront --card-border-width: 0.2em)
============================================================================ */
[data-brand="dt"] .dt-bevel-card,
[data-brand="dt"] .card {
position: relative;
isolation: isolate;
background: var(--color-primary);
border: none;
border-radius: 0;
clip-path: polygon(
0% 0%,
100% 0%,
100% calc(100% - var(--bevel-md)),
calc(100% - var(--bevel-md)) 100%,
var(--bevel-sm) 100%,
0% calc(100% - var(--bevel-sm))
);
}
[data-brand="dt"] .dt-bevel-card::before,
[data-brand="dt"] .card::before {
content: '';
position: absolute;
inset: 0;
background: var(--color-surface);
z-index: -1;
/* Inner clip-path mirrors outer shape, inset 3px on all edges.
This creates a uniform cyan border along both straight edges AND bevel diagonals. */
clip-path: polygon(
3px 3px,
calc(100% - 3px) 3px,
calc(100% - 3px) calc(100% - var(--bevel-md)),
calc(100% - var(--bevel-md)) calc(100% - 3px),
var(--bevel-sm) calc(100% - 3px),
3px calc(100% - var(--bevel-sm))
);
}
/* Card Header — storefront .card-header pattern
Title extends edge-to-edge on the card's primary bg (cyan).
Below it, the ::before dark surface shows through for the body area. */
[data-brand="dt"] .card > .card-title,
[data-brand="dt"] .dt-bevel-card > .card-title {
/* Pull title to card edges, canceling card padding */
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-4) calc(-1 * var(--space-6));
padding: 1rem var(--space-6);
/* Sits above ::before so its own bg covers the dark surface */
background: var(--color-primary);
color: var(--color-bg);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 1rem;
position: relative;
}
/* Force icon spans to inherit header color (overrides inline styles) */
[data-brand="dt"] .card > .card-title > * {
color: inherit !important;
}
/* ============================================================================
Button Bevels — top-right corner cut
Source: storefront .label-clipped pattern (solid fill + clip-path)
Filled buttons (primary/danger): direct clip-path, solid bg
Outline button (secondary): dual-element technique via ::before
============================================================================ */
[data-brand="dt"] .btn-primary,
[data-brand="dt"] .btn-danger {
border: none;
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-sm)) 0%,
100% var(--bevel-sm),
100% 100%,
0% 100%
);
}
[data-brand="dt"] .btn-secondary {
position: relative;
isolation: isolate;
background: var(--color-primary);
border: none;
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-sm)) 0%,
100% var(--bevel-sm),
100% 100%,
0% 100%
);
}
[data-brand="dt"] .btn-secondary::before {
content: '';
position: absolute;
inset: 0;
background: var(--color-bg);
z-index: -1;
clip-path: polygon(
2px 2px,
calc(100% - var(--bevel-sm)) 2px,
calc(100% - 2px) var(--bevel-sm),
calc(100% - 2px) calc(100% - 2px),
2px calc(100% - 2px)
);
}
[data-brand="dt"] .btn-secondary:hover {
background: var(--color-accent);
}
[data-brand="dt"] .btn-secondary:hover::before {
background: rgba(var(--color-primary-rgb), 0.1);
}
/* ============================================================================
Badge/Label Bevels — top-right bevel
Source: storefront .label-clipped pattern
Dual-element technique: badge bg = border color, ::before = inner fill
============================================================================ */
[data-brand="dt"] .badge {
--badge-border-color: var(--color-border);
--badge-fill: var(--color-surface);
position: relative;
isolation: isolate;
background: var(--badge-border-color);
color: var(--color-text-primary);
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-sm)) 0%,
100% var(--bevel-sm),
100% 100%,
0% 100%
);
border: none;
border-radius: 0;
}
[data-brand="dt"] .badge::before {
content: '';
position: absolute;
inset: 0;
background: var(--badge-fill);
z-index: -1;
clip-path: polygon(
2px 2px,
calc(100% - var(--bevel-sm)) 2px,
calc(100% - 2px) var(--bevel-sm),
calc(100% - 2px) calc(100% - 2px),
2px calc(100% - 2px)
);
}
[data-brand="dt"] .badge-success {
--badge-border-color: var(--color-success);
--badge-fill: rgba(var(--color-success-rgb), 0.25);
}
[data-brand="dt"] .badge-error {
--badge-border-color: var(--color-error);
--badge-fill: rgba(var(--color-error-rgb), 0.25);
}
[data-brand="dt"] .badge-warning {
--badge-border-color: var(--color-warning);
--badge-fill: rgba(var(--color-warning-rgb), 0.25);
}
[data-brand="dt"] .badge-info {
--badge-border-color: var(--color-info);
--badge-fill: rgba(var(--color-info-rgb), 0.25);
}
/* ============================================================================
Opt-in Bevel Utility Classes
Use these for custom elements that need bevel shapes
============================================================================ */
[data-brand="dt"] .dt-bevel-btn {
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-sm)) 0%,
100% var(--bevel-sm),
100% 100%,
0% 100%
);
}
[data-brand="dt"] .dt-bevel-label {
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-sm)) 0%,
100% var(--bevel-sm),
100% 100%,
0% 100%
);
border-radius: 0;
}
/* ============================================================================
Media Frame Bevels — diagonal opposite corners (top-left + bottom-right)
Source: storefront .media-frame-clipped pattern
============================================================================ */
.dt-bevel-media {
clip-path: polygon(
var(--bevel-md) 0%,
100% 0%,
100% calc(100% - var(--bevel-md)),
calc(100% - var(--bevel-md)) 100%,
0% 100%,
0% var(--bevel-md)
);
}
/* ============================================================================
Modal/Drawer Bevels — dual bottom bevels (bottom-right + bottom-left)
============================================================================ */
.dt-bevel-modal {
clip-path: polygon(
0% 0%,
100% 0%,
100% calc(100% - var(--bevel-lg)),
calc(100% - var(--bevel-lg)) 100%,
var(--bevel-lg) 100%,
0% calc(100% - var(--bevel-lg))
);
}
/* ============================================================================
Drawer Bevels — exposed-edge bevels
Source: storefront aside clip-path pattern
============================================================================ */
.dt-bevel-drawer-right {
clip-path: polygon(
0% var(--bevel-lg),
var(--bevel-lg) 0%,
100% 0%,
100% 100%,
var(--bevel-lg) 100%,
0% calc(100% - var(--bevel-lg))
);
}
.dt-bevel-drawer-left {
clip-path: polygon(
0% 0%,
calc(100% - var(--bevel-lg)) 0%,
100% var(--bevel-lg),
100% calc(100% - var(--bevel-lg)),
calc(100% - var(--bevel-lg)) 100%,
0% 100%
);
}
/* ============================================================================
Small Bevels — for compact elements (arrows, stepper buttons)
============================================================================ */
.dt-bevel-sm {
clip-path: polygon(
var(--bevel-sm) 0%,
100% 0%,
100% calc(100% - var(--bevel-sm)),
calc(100% - var(--bevel-sm)) 100%,
0% 100%,
0% var(--bevel-sm)
);
}
/* ============================================================================
Accordion/Menu — thick top border accent (storefront menu-item-clipped)
============================================================================ */
[data-brand="dt"] .dt-accent-top,
[data-brand="dt"] .terminal {
border-top: 3px solid var(--color-primary);
}

View File

@@ -0,0 +1,96 @@
/* dt-web-theme: Supra Elevation & MD3 Styles */
/* Source: react-native-supra-theme/src/theme/paperTheme.ts */
/* Rounded Material Design 3 aesthetic with elevation shadows */
/* ============================================================================
Card Elevation
============================================================================ */
[data-brand="supra"] .card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
border-radius: var(--radius-lg);
}
[data-brand="supra"] .card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
}
/* ============================================================================
Button — Dual-layer inset shadow (hardware button feel)
Source: SupraButton.tsx — inner shadow for tactile appearance
============================================================================ */
[data-brand="supra"] .btn-primary {
box-shadow:
inset -2px -2px 4px rgba(0, 0, 0, 0.18),
inset 2px 2px 4px rgba(255, 255, 255, 0.25);
border-radius: var(--radius);
}
[data-brand="supra"] .btn-primary:hover {
box-shadow:
inset -2px -2px 6px rgba(0, 0, 0, 0.22),
inset 2px 2px 6px rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
[data-brand="supra"] .btn-secondary {
border-radius: var(--radius);
}
[data-brand="supra"] .btn-danger {
border-radius: var(--radius);
}
/* ============================================================================
Badge — Rounded pill (MD3 chip style)
============================================================================ */
[data-brand="supra"] .badge {
border-radius: 8px;
}
/* ============================================================================
Input — Rounded with standard focus ring
============================================================================ */
[data-brand="supra"] .input,
[data-brand="supra"] input,
[data-brand="supra"] textarea,
[data-brand="supra"] select {
border-radius: var(--radius);
}
[data-brand="supra"] .input:focus,
[data-brand="supra"] input:focus,
[data-brand="supra"] textarea:focus,
[data-brand="supra"] select:focus {
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.2);
}
/* ============================================================================
Terminal — Rounded with subtle elevation
============================================================================ */
[data-brand="supra"] .terminal {
border-radius: var(--radius);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
}
/* ============================================================================
Elevation Utility Classes
============================================================================ */
.supra-elevation-1 {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.supra-elevation-2 {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
}
.supra-elevation-3 {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
}
.supra-elevation-4 {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
}
.supra-elevation-5 {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}

View File

@@ -0,0 +1,329 @@
/* dt-web-theme: DT Form Component Styles */
/* Source: react-native-dt-theme/src/components/DT*.tsx */
/* These styles exist in the RN DT theme but NOT the Shopify storefront */
/* ============================================================================
Text Input — Sharp corners + focus glow bar
Source: DTTextInput.tsx
============================================================================ */
[data-brand="dt"] input[type="text"],
[data-brand="dt"] input[type="password"],
[data-brand="dt"] input[type="email"],
[data-brand="dt"] input[type="number"],
[data-brand="dt"] input[type="search"],
[data-brand="dt"] input[type="url"],
[data-brand="dt"] input[type="tel"],
[data-brand="dt"] textarea,
[data-brand="dt"] select,
[data-brand="dt"] .input {
border: 2px solid var(--color-primary);
border-radius: 0;
background: var(--color-bg);
color: var(--color-text-primary);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
[data-brand="dt"] input:focus,
[data-brand="dt"] textarea:focus,
[data-brand="dt"] select:focus,
[data-brand="dt"] .input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 4px 0 1px var(--color-primary);
}
[data-brand="dt"] input.error,
[data-brand="dt"] .input.error {
border-color: var(--color-error);
}
[data-brand="dt"] input.error:focus,
[data-brand="dt"] .input.error:focus {
box-shadow: 0 4px 0 1px var(--color-error);
}
/* ============================================================================
Checkbox — Beveled opposing corners (top-left + bottom-right)
Source: DTCheckbox.tsx — bevel at 30% of size
Uses drop-shadow instead of border (clip-path clips CSS borders)
============================================================================ */
[data-brand="dt"] input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 24px;
height: 24px;
border: none;
background: rgba(var(--color-primary-rgb), 0.1);
cursor: pointer;
position: relative;
clip-path: polygon(
30% 0%, 100% 0%, 100% 70%, 70% 100%, 0% 100%, 0% 30%
);
filter: drop-shadow(0 0 1px var(--color-primary));
transition: background var(--transition-fast), filter var(--transition-fast);
}
[data-brand="dt"] input[type="checkbox"]:checked {
background: var(--color-primary);
filter: drop-shadow(0 0 2px var(--color-primary));
}
[data-brand="dt"] input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 6px;
top: 8px;
width: 10px;
height: 6px;
border: 2px solid var(--color-bg);
border-top: none;
border-right: none;
transform: rotate(-45deg);
}
[data-brand="dt"] input[type="checkbox"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================================================
Switch / Toggle — Angular track + sliding square thumb
Source: DTSwitch.tsx — 48x26 track, 20x20 thumb, 150ms
============================================================================ */
.dt-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
cursor: pointer;
}
.dt-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.dt-switch-track {
position: absolute;
inset: 0;
border: 2px solid var(--color-primary);
border-radius: 0;
background: rgba(var(--color-primary-rgb), 0.1);
transition: background var(--transition-fast);
}
.dt-switch input:checked + .dt-switch-track {
background: var(--color-primary);
}
.dt-switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: #ffffff;
transition: left var(--transition-fast);
}
.dt-switch input:checked ~ .dt-switch-thumb,
.dt-switch input:checked + .dt-switch-track + .dt-switch-thumb {
left: 25px;
background: var(--color-bg);
}
.dt-switch input:disabled + .dt-switch-track {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================================================
Radio Button — Hexagonal indicator
Source: DTRadioGroup.tsx — flat-top hexagon, 22px default
Uses drop-shadow instead of border (clip-path clips CSS borders)
============================================================================ */
[data-brand="dt"] input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 22px;
height: 22px;
border: none;
background: rgba(var(--color-primary-rgb), 0.1);
cursor: pointer;
clip-path: polygon(
50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%
);
filter: drop-shadow(0 0 1px var(--color-primary));
transition: background var(--transition-fast), filter var(--transition-fast);
}
[data-brand="dt"] input[type="radio"]:checked {
background: var(--color-primary);
filter: drop-shadow(0 0 2px var(--color-primary));
}
[data-brand="dt"] input[type="radio"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Radio group option container */
.dt-radio-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 2px solid rgba(var(--color-primary-rgb), 0.3);
cursor: pointer;
transition: all var(--transition-fast);
}
.dt-radio-option.selected {
background: rgba(var(--color-primary-rgb), 0.15);
border-color: var(--color-primary);
}
/* ============================================================================
Progress Bar — Angular, no radius
Source: DTProgressBar.tsx — 4px height, no border-radius
============================================================================ */
.dt-progress {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 0;
overflow: hidden;
}
.dt-progress-fill {
height: 100%;
background: var(--color-primary);
transition: width 300ms ease-out;
}
.dt-progress-label {
font-size: 11px;
font-weight: 600;
color: var(--color-primary);
margin-top: 4px;
font-family: var(--font-mono);
}
/* Vertical variant */
.dt-progress.vertical {
width: 8px;
height: 200px;
display: flex;
flex-direction: column-reverse;
}
.dt-progress.vertical .dt-progress-fill {
width: 100%;
height: 0%;
transition: height 300ms ease-out;
}
/* ============================================================================
Accordion — 5px top border, chevron rotation
Source: DTAccordion.tsx — 250ms animation
============================================================================ */
.dt-accordion {
display: flex;
flex-direction: column;
gap: 4px;
}
.dt-accordion-header {
background: var(--color-bg);
border: 1px solid var(--color-primary);
border-top: 5px solid var(--color-primary);
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
text-transform: uppercase;
font-weight: 600;
font-size: 0.875rem;
letter-spacing: 0.05em;
color: var(--color-text-primary);
transition: opacity var(--transition-fast);
}
.dt-accordion-header:hover {
opacity: 0.7;
}
.dt-accordion-header[aria-expanded="true"] {
color: var(--color-secondary);
border-top-color: var(--color-secondary);
}
.dt-accordion-chevron {
display: inline-block;
transition: transform 250ms ease-in-out;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid currentColor;
}
.dt-accordion-header[aria-expanded="true"] .dt-accordion-chevron {
transform: rotate(180deg);
}
.dt-accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 250ms ease-in-out;
}
.dt-accordion-content[data-open="true"] {
max-height: 2000px;
}
/* ============================================================================
Quantity Stepper — Beveled +/- buttons
Source: DTQuantityStepper.tsx — bottom-right bevel
============================================================================ */
.dt-stepper {
display: inline-flex;
gap: 4px;
align-items: center;
}
.dt-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 2px solid var(--color-primary);
background: transparent;
color: var(--color-primary);
font-size: 1.25rem;
font-weight: bold;
cursor: pointer;
border-radius: 0;
transition: opacity var(--transition-fast);
}
.dt-stepper-btn:hover {
opacity: 0.7;
}
.dt-stepper-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.dt-stepper-value {
min-width: 36px;
text-align: center;
color: var(--color-primary);
font-weight: 700;
font-family: var(--font-mono);
}

View File

@@ -0,0 +1,80 @@
/* dt-web-theme: Glow & Neon Effects */
/* Source: dt-shopify-storefront glow patterns */
/* */
/* Elements with clip-path use filter: drop-shadow() instead of box-shadow */
/* because clip-path clips box-shadow but filter follows the clipped shape. */
/* ============================================================================
Button Glows — filter: drop-shadow (buttons have clip-path)
============================================================================ */
[data-brand="dt"] .btn-primary {
box-shadow: none;
filter: drop-shadow(0 0 10px rgba(var(--color-primary-rgb), 0.3));
}
[data-brand="dt"] .btn-primary:hover {
box-shadow: none;
filter: drop-shadow(0 0 15px rgba(var(--color-accent-rgb), 0.4));
}
[data-brand="dt"] .btn-secondary:hover {
filter: drop-shadow(0 0 8px rgba(var(--color-primary-rgb), 0.3));
}
[data-brand="dt"] .btn-danger {
box-shadow: none;
filter: drop-shadow(0 0 10px rgba(var(--color-error-rgb), 0.3));
}
[data-brand="dt"] .btn-danger:hover {
box-shadow: none;
filter: drop-shadow(0 0 15px rgba(var(--color-error-rgb), 0.4));
}
/* ============================================================================
Link Glow
============================================================================ */
[data-brand="dt"] a:hover {
text-shadow: 0 0 8px var(--color-primary);
}
/* ============================================================================
Terminal Inset Glow (no clip-path, box-shadow OK)
============================================================================ */
[data-brand="dt"] .terminal {
box-shadow: inset 0 0 10px rgba(var(--color-accent-rgb), 0.05),
0 0 10px rgba(var(--color-accent-rgb), 0.1);
}
/* ============================================================================
Card Hover Glow — filter: drop-shadow (cards have clip-path)
============================================================================ */
[data-brand="dt"] .card:hover {
filter: drop-shadow(0 0 8px rgba(var(--color-primary-rgb), 0.3));
}
/* ============================================================================
Input Focus Glow (no clip-path on inputs, box-shadow OK)
============================================================================ */
[data-brand="dt"] .input:focus {
box-shadow: 0 4px 0 1px var(--color-primary);
}
/* ============================================================================
Generic Glow Utilities
============================================================================ */
.dt-glow {
filter: drop-shadow(0 0 10px rgba(var(--color-primary-rgb), 0.3));
}
.dt-glow-strong {
filter: drop-shadow(0 0 15px rgba(var(--color-primary-rgb), 0.5));
}
.dt-glow-inset {
box-shadow: inset 0 0 10px rgba(var(--color-primary-rgb), 0.1);
}
.dt-text-glow {
text-shadow: 0 0 8px currentColor;
}

View File

@@ -0,0 +1,9 @@
/* @dangerousthings/web: Tektur Font Face */
@font-face {
font-family: 'Tektur';
src: url('../fonts/Tektur-VariableFont_wdth,wght.ttf') format('truetype');
font-weight: 100 900;
font-stretch: 75% 125%;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,21 @@
/* @dangerousthings/web: Main Entry Point */
/* Import this file to get all tokens and component styles */
/* Base tokens (spacing, transitions, font stacks) */
@import './base.css';
/* Tektur font face (used by DT brand) */
@import './dt-font.css';
/* Brand tokens — generated from @dangerousthings/tokens */
@import './tokens/dt.css';
@import './tokens/classic.css';
@import './tokens/supra.css';
/* Component styles — DT visual identity */
@import './components/bevels.css';
@import './components/glows.css';
@import './components/forms-dt.css';
/* Component styles — Supra/MD3 visual identity */
@import './components/elevation.css';

16
tsconfig.base.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}

16
turbo.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"clean": {
"cache": false
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
}
}