initial commit
This commit is contained in:
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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
11
.changeset/config.json
Normal 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": []
|
||||
}
|
||||
11
.changeset/initial-release.md
Normal file
11
.changeset/initial-release.md
Normal 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
52
.github/workflows/ci.yml
vendored
Normal 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
37
.github/workflows/release.yml
vendored
Normal 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
27
.gitignore
vendored
Normal 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
5378
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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"
|
||||
}
|
||||
49
packages/react-native/package.json
Normal file
49
packages/react-native/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
276
packages/react-native/src/components/DTAccordion.tsx
Normal file
276
packages/react-native/src/components/DTAccordion.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
151
packages/react-native/src/components/DTButton.tsx
Normal file
151
packages/react-native/src/components/DTButton.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
221
packages/react-native/src/components/DTCard.tsx
Normal file
221
packages/react-native/src/components/DTCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
140
packages/react-native/src/components/DTCheckbox.tsx
Normal file
140
packages/react-native/src/components/DTCheckbox.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
95
packages/react-native/src/components/DTChip.tsx
Normal file
95
packages/react-native/src/components/DTChip.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
279
packages/react-native/src/components/DTDrawer.tsx
Normal file
279
packages/react-native/src/components/DTDrawer.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
366
packages/react-native/src/components/DTGallery.tsx
Normal file
366
packages/react-native/src/components/DTGallery.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
210
packages/react-native/src/components/DTHexagon.tsx
Normal file
210
packages/react-native/src/components/DTHexagon.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
313
packages/react-native/src/components/DTLabel.tsx
Normal file
313
packages/react-native/src/components/DTLabel.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
210
packages/react-native/src/components/DTMediaFrame.tsx
Normal file
210
packages/react-native/src/components/DTMediaFrame.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
371
packages/react-native/src/components/DTMenu.tsx
Normal file
371
packages/react-native/src/components/DTMenu.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
115
packages/react-native/src/components/DTModal.tsx
Normal file
115
packages/react-native/src/components/DTModal.tsx
Normal 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%',
|
||||
},
|
||||
});
|
||||
183
packages/react-native/src/components/DTProgressBar.tsx
Normal file
183
packages/react-native/src/components/DTProgressBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
234
packages/react-native/src/components/DTQuantityStepper.tsx
Normal file
234
packages/react-native/src/components/DTQuantityStepper.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
229
packages/react-native/src/components/DTRadioGroup.tsx
Normal file
229
packages/react-native/src/components/DTRadioGroup.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
163
packages/react-native/src/components/DTSearchInput.tsx
Normal file
163
packages/react-native/src/components/DTSearchInput.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
152
packages/react-native/src/components/DTSwitch.tsx
Normal file
152
packages/react-native/src/components/DTSwitch.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
172
packages/react-native/src/components/DTTextInput.tsx
Normal file
172
packages/react-native/src/components/DTTextInput.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
77
packages/react-native/src/index.ts
Normal file
77
packages/react-native/src/index.ts
Normal 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';
|
||||
80
packages/react-native/src/theme/DTThemeProvider.tsx
Normal file
80
packages/react-native/src/theme/DTThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
69
packages/react-native/src/theme/colors.ts
Normal file
69
packages/react-native/src/theme/colors.ts
Normal 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})`;
|
||||
}
|
||||
149
packages/react-native/src/theme/paperTheme.ts
Normal file
149
packages/react-native/src/theme/paperTheme.ts
Normal 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';
|
||||
190
packages/react-native/src/theme/typography.ts
Normal file
190
packages/react-native/src/theme/typography.ts
Normal 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;
|
||||
152
packages/react-native/src/utils/bevelPaths.ts
Normal file
152
packages/react-native/src/utils/bevelPaths.ts
Normal 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},
|
||||
});
|
||||
}
|
||||
33
packages/react-native/src/utils/useComponentLayout.ts
Normal file
33
packages/react-native/src/utils/useComponentLayout.ts
Normal 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};
|
||||
}
|
||||
27
packages/react-native/src/utils/variantColors.ts
Normal file
27
packages/react-native/src/utils/variantColors.ts
Normal 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];
|
||||
}
|
||||
15
packages/react-native/tsconfig.json
Normal file
15
packages/react-native/tsconfig.json
Normal 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"]
|
||||
}
|
||||
36
packages/tokens/package.json
Normal file
36
packages/tokens/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
66
packages/tokens/src/brands/classic.ts
Normal file
66
packages/tokens/src/brands/classic.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
66
packages/tokens/src/brands/dt.ts
Normal file
66
packages/tokens/src/brands/dt.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
66
packages/tokens/src/brands/supra.ts
Normal file
66
packages/tokens/src/brands/supra.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
46
packages/tokens/src/index.ts
Normal file
46
packages/tokens/src/index.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
129
packages/tokens/src/scripts/generate-css.ts
Normal file
129
packages/tokens/src/scripts/generate-css.ts
Normal 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");
|
||||
65
packages/tokens/src/types.ts
Normal file
65
packages/tokens/src/types.ts
Normal 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;
|
||||
}
|
||||
8
packages/tokens/tsconfig.json
Normal file
8
packages/tokens/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
BIN
packages/web/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
BIN
packages/web/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
30
packages/web/package.json
Normal file
30
packages/web/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
87
packages/web/scripts/build.js
Normal file
87
packages/web/scripts/build.js
Normal 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
31
packages/web/src/base.css
Normal 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;
|
||||
}
|
||||
290
packages/web/src/components/bevels.css
Normal file
290
packages/web/src/components/bevels.css
Normal 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);
|
||||
}
|
||||
96
packages/web/src/components/elevation.css
Normal file
96
packages/web/src/components/elevation.css
Normal 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);
|
||||
}
|
||||
329
packages/web/src/components/forms-dt.css
Normal file
329
packages/web/src/components/forms-dt.css
Normal 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);
|
||||
}
|
||||
80
packages/web/src/components/glows.css
Normal file
80
packages/web/src/components/glows.css
Normal 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;
|
||||
}
|
||||
9
packages/web/src/dt-font.css
Normal file
9
packages/web/src/dt-font.css
Normal 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;
|
||||
}
|
||||
21
packages/web/src/index.css
Normal file
21
packages/web/src/index.css
Normal 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
16
tsconfig.base.json
Normal 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
16
turbo.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user