Add storefront component migration: cards, badges, progress bars, animations, and React wrapper package
Major feature migration from dt-shopify-storefront into the design system: - Card system: per-card color modes (normal/emphasis/warning/success/other), progress bars, full-bleed card body (card-body-flush), image bevel clip-paths, and visible structural borders at bevel corners - Badge system: dt-card-badge with position-aware offsets for cards vs media frames - Media frames: dt-bevel-media with inner surface fill, image clipping, and "MISSING MEDIA" placeholder for empty containers - Interactive bevel buttons: dt-btn with hover/selected states and pulse animation - Menu items: dt-menu-item with active states and level indentation - Animations: scale-in, fade-in, slide-up, pulse, ping, spin keyframes - Feature legend: CSS grid for product feature icons with state colors - Scrollbar styling for DT brand - New @dangerousthings/react package wrapping web CSS components - New @dangerousthings/tailwind-preset package - New @dangerousthings/hex-background package - Desktop showcase rewritten in React with Tailwind CSS - Mobile showcase updated with new screens (animations, filters, advanced cards) - Tokens: added mode color tokens, RGB variants, and selected-state tokens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
packages/hex-background/package.json
Normal file
51
packages/hex-background/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@dangerousthings/hex-background",
|
||||
"version": "0.1.0",
|
||||
"description": "3D hexagon grid background for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./native": {
|
||||
"types": "./dist/native.d.ts",
|
||||
"import": "./dist/native.js",
|
||||
"default": "./dist/native.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"react-native": "dist/native.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"@react-three/fiber": ">=8",
|
||||
"three": ">=0.150"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo-gl": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/three": "^0.160.0",
|
||||
"react": "^18.0.0",
|
||||
"three": "^0.160.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
36
packages/hex-background/src/HexCamera.tsx
Normal file
36
packages/hex-background/src/HexCamera.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* HexCamera — Orbiting camera for hexagon grid
|
||||
*
|
||||
* Continuously rotates around the grid center at a fixed radius and height.
|
||||
*/
|
||||
|
||||
import {useRef} from 'react';
|
||||
import {useFrame, useThree} from '@react-three/fiber';
|
||||
|
||||
export interface HexCameraProps {
|
||||
/** Orbital rotation speed in radians per second @default 0.02 */
|
||||
speed?: number;
|
||||
/** Orbital distance from center @default 8 */
|
||||
radius?: number;
|
||||
/** Camera Y height @default 5 */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function HexCamera({
|
||||
speed = 0.02,
|
||||
radius = 8,
|
||||
height = 5,
|
||||
}: HexCameraProps) {
|
||||
const angleRef = useRef(0);
|
||||
const {camera} = useThree();
|
||||
|
||||
useFrame((_state, delta) => {
|
||||
angleRef.current = (angleRef.current + speed * delta) % (Math.PI * 2);
|
||||
const x = Math.cos(angleRef.current) * radius;
|
||||
const z = Math.sin(angleRef.current) * radius;
|
||||
camera.position.set(x, height, z);
|
||||
camera.lookAt(0, 0, 0);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
148
packages/hex-background/src/HexGrid.tsx
Normal file
148
packages/hex-background/src/HexGrid.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* HexGrid — Core 3D hexagon grid renderer
|
||||
*
|
||||
* Renders an instanced mesh of animated hexagonal columns using Three.js.
|
||||
* Source: dt-shopify-storefront HexagonGrid.tsx
|
||||
*/
|
||||
|
||||
import {useRef, useMemo, useEffect} from 'react';
|
||||
import {useFrame} from '@react-three/fiber';
|
||||
import {
|
||||
InstancedMesh,
|
||||
Object3D,
|
||||
Shape,
|
||||
ExtrudeGeometry,
|
||||
MeshStandardMaterial,
|
||||
MathUtils,
|
||||
} from 'three';
|
||||
|
||||
export interface HexGridProps {
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** Hexagon radius in world units @default 0.5 */
|
||||
hexRadius?: number;
|
||||
/** Extrude depth of hex geometry @default 1 */
|
||||
extrudeHeight?: number;
|
||||
/** Spacing between hexagons @default 0.05 */
|
||||
margin?: number;
|
||||
/** Maximum random height for animation @default 3 */
|
||||
maxHeight?: number;
|
||||
/** Interval (ms) between height target changes @default 1500 */
|
||||
animationInterval?: number;
|
||||
}
|
||||
|
||||
function computeHexPositions(
|
||||
rows: number,
|
||||
cols: number,
|
||||
hexWidth: number,
|
||||
hexHeight: number,
|
||||
margin: number,
|
||||
) {
|
||||
const positions: {x: number; z: number}[] = [];
|
||||
const horizontalStep = hexWidth * 0.75 + margin;
|
||||
const verticalStep = hexHeight * 0.866 + margin;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const x = col * horizontalStep;
|
||||
const z = row * verticalStep + (col % 2 ? verticalStep / 2 : 0);
|
||||
const centerX = ((cols - 1) * horizontalStep) / 2;
|
||||
const centerZ = ((rows - 1) * verticalStep) / 2;
|
||||
positions.push({x: x - centerX, z: z - centerZ});
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function HexGrid({
|
||||
rows,
|
||||
cols,
|
||||
hexRadius = 0.5,
|
||||
extrudeHeight = 1,
|
||||
margin = 0.05,
|
||||
maxHeight = 3,
|
||||
animationInterval = 1500,
|
||||
}: HexGridProps) {
|
||||
const meshRef = useRef<InstancedMesh>(null);
|
||||
|
||||
const positions = useMemo(
|
||||
() => computeHexPositions(rows, cols, hexRadius * 2, extrudeHeight, margin),
|
||||
[rows, cols, hexRadius, extrudeHeight, margin],
|
||||
);
|
||||
|
||||
const targetHeightsRef = useRef(
|
||||
positions.map(() => 1 + Math.random() * maxHeight),
|
||||
);
|
||||
const currentHeightsRef = useRef(
|
||||
positions.map(() => 1 + Math.random() * maxHeight),
|
||||
);
|
||||
|
||||
// Reuse dummy object for matrix updates — 15-20% FPS improvement
|
||||
const dummyRef = useRef(new Object3D());
|
||||
|
||||
const hexGeometry = useMemo(() => {
|
||||
const shape = new Shape();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i;
|
||||
const x = hexRadius * Math.cos(angle);
|
||||
const z = hexRadius * Math.sin(angle);
|
||||
i === 0 ? shape.moveTo(x, z) : shape.lineTo(x, z);
|
||||
}
|
||||
shape.closePath();
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
});
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}, [hexRadius]);
|
||||
|
||||
// Regenerate target heights at interval
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
targetHeightsRef.current = positions.map(
|
||||
() => 0.5 + Math.random() * maxHeight,
|
||||
);
|
||||
}, animationInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [positions, maxHeight, animationInterval]);
|
||||
|
||||
// Animate per frame: lerp current heights toward targets
|
||||
useFrame((_state, delta) => {
|
||||
if (!meshRef.current) return;
|
||||
const dummy = dummyRef.current;
|
||||
|
||||
positions.forEach(({x, z}, i) => {
|
||||
currentHeightsRef.current[i] = MathUtils.lerp(
|
||||
currentHeightsRef.current[i],
|
||||
targetHeightsRef.current[i],
|
||||
delta * 0.5,
|
||||
);
|
||||
const height = currentHeightsRef.current[i];
|
||||
|
||||
dummy.position.set(x, height / 2, z);
|
||||
dummy.scale.set(1, height, 1);
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.updateMatrix();
|
||||
meshRef.current!.setMatrixAt(i, dummy.matrix);
|
||||
});
|
||||
|
||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
ref={meshRef}
|
||||
args={[
|
||||
hexGeometry,
|
||||
new MeshStandardMaterial({
|
||||
color: 'black',
|
||||
metalness: 0.3,
|
||||
roughness: 0.7,
|
||||
}),
|
||||
positions.length,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
141
packages/hex-background/src/HexGridBackground.tsx
Normal file
141
packages/hex-background/src/HexGridBackground.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* HexGridBackground — Full-viewport hex grid background
|
||||
*
|
||||
* Drop-in component that renders a fixed, semi-transparent 3D hexagon grid
|
||||
* behind page content. Automatically sizes grid to viewport and detects
|
||||
* automated browsers to skip rendering.
|
||||
*/
|
||||
|
||||
import {useState, useEffect, useRef, Suspense} from 'react';
|
||||
import {Canvas} from '@react-three/fiber';
|
||||
import {HexGrid} from './HexGrid';
|
||||
import {HexCamera} from './HexCamera';
|
||||
|
||||
export interface HexGridBackgroundProps {
|
||||
/** Canvas opacity @default 0.5 */
|
||||
opacity?: number;
|
||||
/** Hexagon radius @default 0.5 */
|
||||
hexRadius?: number;
|
||||
/** Spacing between hexagons @default 0.05 */
|
||||
margin?: number;
|
||||
/** Maximum animation height @default 3 */
|
||||
maxHeight?: number;
|
||||
/** Height target refresh interval in ms @default 1500 */
|
||||
animationInterval?: number;
|
||||
/** Camera orbital speed @default 0.02 */
|
||||
cameraSpeed?: number;
|
||||
/** Camera orbital radius @default 8 */
|
||||
cameraRadius?: number;
|
||||
/** Camera field of view @default 40 */
|
||||
fov?: number;
|
||||
}
|
||||
|
||||
function calculateGridDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
cameraDistance: number,
|
||||
fov: number,
|
||||
hexRadius: number,
|
||||
margin: number,
|
||||
): {rows: number; cols: number} {
|
||||
const aspect = width / height;
|
||||
const fovRadians = (fov * Math.PI) / 180;
|
||||
const visibleHeight = 2 * cameraDistance * Math.tan(fovRadians / 2);
|
||||
const visibleWidth = visibleHeight * aspect;
|
||||
|
||||
const hexWidth = hexRadius * 2;
|
||||
const horizontalStep = hexWidth * 0.75 + margin;
|
||||
const verticalStep = hexWidth * 0.866 + margin;
|
||||
|
||||
const cols = Math.ceil((visibleWidth / horizontalStep) * 1.5);
|
||||
const rows = Math.ceil((visibleHeight / verticalStep) * 1.5);
|
||||
|
||||
return {
|
||||
rows: Math.min(Math.max(rows, 15), 35),
|
||||
cols: Math.min(Math.max(cols, 15), 35),
|
||||
};
|
||||
}
|
||||
|
||||
export function HexGridBackground({
|
||||
opacity = 0.5,
|
||||
hexRadius = 0.5,
|
||||
margin = 0.05,
|
||||
maxHeight = 3,
|
||||
animationInterval = 1500,
|
||||
cameraSpeed = 0.02,
|
||||
cameraRadius = 8,
|
||||
fov = 40,
|
||||
}: HexGridBackgroundProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isAutomated, setIsAutomated] = useState(false);
|
||||
const [gridDimensions, setGridDimensions] = useState({rows: 20, cols: 20});
|
||||
const resizeTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Detect automated browsers (Selenium, Puppeteer, etc.)
|
||||
if (typeof navigator !== 'undefined' && (navigator as any).webdriver) {
|
||||
setIsAutomated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateDimensions = () => {
|
||||
const dims = calculateGridDimensions(
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
cameraRadius,
|
||||
fov,
|
||||
hexRadius,
|
||||
margin,
|
||||
);
|
||||
setGridDimensions(dims);
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(updateDimensions, 250);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
}, [cameraRadius, fov, hexRadius, margin]);
|
||||
|
||||
if (!mounted || isAutomated) return null;
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{position: [5, 5, 5], fov, near: 0.5, far: 50}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
opacity,
|
||||
margin: 0,
|
||||
}}>
|
||||
<HexCamera speed={cameraSpeed} radius={cameraRadius} />
|
||||
<ambientLight intensity={20} />
|
||||
<directionalLight position={[20, 40, 20]} intensity={1.5} />
|
||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||
<Suspense fallback={null}>
|
||||
<HexGrid
|
||||
rows={gridDimensions.rows}
|
||||
cols={gridDimensions.cols}
|
||||
hexRadius={hexRadius}
|
||||
margin={margin}
|
||||
maxHeight={maxHeight}
|
||||
animationInterval={animationInterval}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
23
packages/hex-background/src/index.ts
Normal file
23
packages/hex-background/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @dangerousthings/hex-background
|
||||
*
|
||||
* 3D hexagon grid background for the Dangerous Things design system.
|
||||
* Uses Three.js + React Three Fiber for instanced WebGL rendering.
|
||||
*
|
||||
* Web usage:
|
||||
* import { HexGridBackground } from '@dangerousthings/hex-background';
|
||||
* <HexGridBackground />
|
||||
*
|
||||
* React Native usage:
|
||||
* import { HexGridBackground } from '@dangerousthings/hex-background/native';
|
||||
* <HexGridBackground />
|
||||
*/
|
||||
|
||||
export { HexGridBackground } from './HexGridBackground';
|
||||
export type { HexGridBackgroundProps } from './HexGridBackground';
|
||||
|
||||
export { HexGrid } from './HexGrid';
|
||||
export type { HexGridProps } from './HexGrid';
|
||||
|
||||
export { HexCamera } from './HexCamera';
|
||||
export type { HexCameraProps } from './HexCamera';
|
||||
25
packages/hex-background/src/native.ts
Normal file
25
packages/hex-background/src/native.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* React Native entry point for @dangerousthings/hex-background
|
||||
*
|
||||
* Uses @react-three/fiber/native renderer with expo-gl.
|
||||
* Consumers must install: expo-gl, @react-three/fiber, three
|
||||
*
|
||||
* Note: The HexGridBackground component uses web-specific APIs (window, CSS units).
|
||||
* For React Native, use HexGrid and HexCamera directly within a native Canvas:
|
||||
*
|
||||
* import { Canvas } from '@react-three/fiber/native';
|
||||
* import { HexGrid, HexCamera } from '@dangerousthings/hex-background/native';
|
||||
*
|
||||
* <Canvas camera={{ position: [5, 5, 5], fov: 40 }}>
|
||||
* <HexCamera />
|
||||
* <ambientLight intensity={20} />
|
||||
* <directionalLight position={[20, 40, 20]} intensity={1.5} />
|
||||
* <HexGrid rows={20} cols={20} />
|
||||
* </Canvas>
|
||||
*/
|
||||
|
||||
export { HexGrid } from './HexGrid';
|
||||
export type { HexGridProps } from './HexGrid';
|
||||
|
||||
export { HexCamera } from './HexCamera';
|
||||
export type { HexCameraProps } from './HexCamera';
|
||||
9
packages/hex-background/tsconfig.json
Normal file
9
packages/hex-background/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# @dangerousthings/react-native
|
||||
|
||||
React Native components for the Dangerous Things design system — 18 themed components built on React Native Paper with cyberpunk beveled aesthetics.
|
||||
React Native components for the Dangerous Things design system — 22 themed components built on React Native Paper with cyberpunk beveled aesthetics.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -74,6 +74,9 @@ Options:
|
||||
| `DTMediaFrame` | Diagonal beveled frame for images |
|
||||
| `DTAccordion` | Collapsible sections with accent border |
|
||||
| `DTHexagon` | Decorative hexagon SVG shape |
|
||||
| `DTBadgeOverlay` | Absolute-positioned badge wrapper (top-left, top-right, bottom-left, bottom-right) |
|
||||
| `DTStaggerContainer` | Staggered scale-in entrance animation for child elements |
|
||||
| `DTFeatureLegend` | Product feature grid with icons and rotated labels |
|
||||
|
||||
### Interactive
|
||||
|
||||
@@ -83,6 +86,14 @@ Options:
|
||||
| `DTDrawer` | Side drawer with edge bevels |
|
||||
| `DTGallery` | Image gallery |
|
||||
| `DTMenu` | Dropdown menu with item variants |
|
||||
| `DTMobileFilterOverlay` | Full-screen slide-up filter overlay with backdrop |
|
||||
|
||||
### Animation Hooks
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `useScaleIn` | Scale 0→1 entrance animation |
|
||||
| `usePulse` | Looping opacity pulse animation |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -99,6 +110,11 @@ import { DTButton, DTCard, DTTextInput } from "@dangerousthings/react-native";
|
||||
<Text>Chip detected</Text>
|
||||
</DTCard>
|
||||
|
||||
// Card with selected state and progress bar
|
||||
<DTCard title="PRODUCT" mode="emphasis" selected progress={0.6}>
|
||||
<Text>Selected with 60% progress</Text>
|
||||
</DTCard>
|
||||
|
||||
// Text input with error state
|
||||
<DTTextInput
|
||||
variant="normal"
|
||||
|
||||
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal file
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* DT Badge Overlay
|
||||
*
|
||||
* Positioning wrapper for badges on cards and media frames.
|
||||
* Places children absolutely at a specified corner with configurable offset.
|
||||
*/
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import {View, ViewStyle, StyleProp} from 'react-native';
|
||||
|
||||
type BadgePosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
interface DTBadgeOverlayProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Corner position for the badge
|
||||
* @default 'bottom-right'
|
||||
*/
|
||||
position?: BadgePosition;
|
||||
/**
|
||||
* Offset from the corner in pixels
|
||||
* @default 8
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DTBadgeOverlay({
|
||||
children,
|
||||
position = 'bottom-right',
|
||||
offset = 8,
|
||||
style,
|
||||
}: DTBadgeOverlayProps) {
|
||||
const positionStyle: ViewStyle = {
|
||||
position: 'absolute',
|
||||
zIndex: 4,
|
||||
...(position.includes('top') ? {top: offset} : {bottom: offset}),
|
||||
...(position.includes('right') ? {right: offset} : {left: offset}),
|
||||
};
|
||||
|
||||
return <View style={[positionStyle, style]}>{children}</View>;
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* DT Button Component
|
||||
*
|
||||
* A themed button following the Dangerous Things design language
|
||||
* with SVG-based beveled corners.
|
||||
* Interactive bevel button matching the storefront .menu-item-clipped pattern:
|
||||
* - Default: outlined rectangle, no bevel, colored border with thick top
|
||||
* - Pressed/hover: bottom-right bevel appears, fills with mode color, text goes dark
|
||||
* - Selected: bevel persists, fills with 70% opacity mode color
|
||||
*/
|
||||
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {useRef, useEffect} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Animated} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import Svg, {Path, Rect} from 'react-native-svg';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
||||
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface DTButtonProps {
|
||||
/**
|
||||
@@ -26,11 +28,16 @@ interface DTButtonProps {
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Button display mode
|
||||
* - 'outlined': Border with transparent background (default)
|
||||
* - 'contained': Filled background
|
||||
* - 'outlined': Border with transparent background, bevel on press (default)
|
||||
* - 'contained': Filled background with static bevel
|
||||
* @default 'outlined'
|
||||
*/
|
||||
mode?: 'outlined' | 'contained';
|
||||
/**
|
||||
* Whether the button is in a persistent selected state
|
||||
* (bevel visible, filled with 70% opacity mode color)
|
||||
*/
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
@@ -53,91 +60,169 @@ interface DTButtonProps {
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Bevel size in pixels
|
||||
* @default 8
|
||||
* Bevel size in pixels (bottom-right corner)
|
||||
* @default 16
|
||||
*/
|
||||
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',
|
||||
selected = false,
|
||||
color,
|
||||
onPress,
|
||||
disabled = false,
|
||||
style,
|
||||
borderWidth = 2,
|
||||
bevelSize = 8,
|
||||
bevelSize = 16,
|
||||
}: DTButtonProps) {
|
||||
const theme = useDTTheme();
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const [pressed, setPressed] = useState(false);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const pressed = useRef(false);
|
||||
|
||||
const accentColor = getVariantColor(theme, variant, color);
|
||||
const selectedColor = accentColor + 'B3'; // ~70% opacity
|
||||
const isContained = mode === 'contained';
|
||||
const bgColor = isContained ? accentColor : 'transparent';
|
||||
const textColor = isContained ? theme.colors.onPrimary : accentColor;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
|
||||
// Build SVG paths: rectangle (default) and beveled (active)
|
||||
const rectPath = hasDimensions
|
||||
? buildBeveledRectPath(width, height, {corners: {}, strokeWidth: borderWidth})
|
||||
: '';
|
||||
const bevelPath = useBevels && hasDimensions
|
||||
? buildBeveledRectPath(width, height, {
|
||||
corners: {bottomRight: bevelSize},
|
||||
strokeWidth: borderWidth,
|
||||
})
|
||||
: '';
|
||||
|
||||
// Pulse animation for pressed/selected hover
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
// No pulse when just selected (matches storefront: animation: none on .selected)
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
return () => {
|
||||
pulseAnim.stopAnimation();
|
||||
};
|
||||
}, [selected, pulseAnim]);
|
||||
|
||||
const startPulse = () => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0.5,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
).start();
|
||||
};
|
||||
|
||||
const stopPulse = () => {
|
||||
pulseAnim.stopAnimation();
|
||||
pulseAnim.setValue(1);
|
||||
};
|
||||
|
||||
const handlePressIn = () => {
|
||||
pressed.current = true;
|
||||
startPulse();
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressed.current = false;
|
||||
if (!selected) {
|
||||
stopPulse();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine visual state
|
||||
const showBevel = isContained || selected;
|
||||
|
||||
const getBgColor = (isPressed: boolean) => {
|
||||
if (isContained) return accentColor;
|
||||
if (selected) return selectedColor;
|
||||
if (isPressed) return accentColor;
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
const getTextColor = (isPressed: boolean) => {
|
||||
if (isContained || selected || isPressed) return theme.colors.onPrimary;
|
||||
return accentColor;
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
onPressIn={() => setPressed(true)}
|
||||
onPressOut={() => setPressed(false)}
|
||||
style={[{opacity}, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
!useBevels && {
|
||||
borderWidth,
|
||||
borderColor: accentColor,
|
||||
borderRadius: theme.custom.radiusSm,
|
||||
backgroundColor: bgColor,
|
||||
},
|
||||
]}
|
||||
onLayout={onLayout}>
|
||||
{useBevels && 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}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[{opacity: disabled ? 0.5 : 1}, style]}>
|
||||
{({pressed: isPressedState}) => (
|
||||
<Animated.View
|
||||
style={[styles.container, {opacity: pulseAnim}]}
|
||||
onLayout={onLayout}>
|
||||
{/* Non-beveled mode (classic brand) */}
|
||||
{!useBevels && (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
borderWidth,
|
||||
borderColor: accentColor,
|
||||
borderTopWidth: 5,
|
||||
borderRadius: theme.custom.radiusSm,
|
||||
backgroundColor: getBgColor(isPressedState),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.content}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text variant="labelLarge" style={[styles.label, {color: textColor}]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* Beveled mode (DT brand) */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path
|
||||
d={(isPressedState || showBevel) ? bevelPath : rectPath}
|
||||
fill={getBgColor(isPressedState)}
|
||||
stroke={accentColor}
|
||||
strokeWidth={borderWidth}
|
||||
/>
|
||||
{/* Thick top accent — rendered inside SVG for exact alignment */}
|
||||
<Rect
|
||||
x={borderWidth / 2}
|
||||
y={0}
|
||||
width={width - borderWidth}
|
||||
height={5}
|
||||
fill={accentColor}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.content}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text
|
||||
variant="labelLarge"
|
||||
style={[styles.label, {color: getTextColor(isPressedState)}]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* - Bottom-right bevel: 2em (~32px)
|
||||
* - Bottom-left bevel: 1em (~16px)
|
||||
* - Border width: 0.2em (~3px)
|
||||
*
|
||||
* The progress bar is a structural element on the left edge (0 to bevelSizeSmall).
|
||||
* It is always present. At 0 progress it shows surface color (empty bar).
|
||||
* As progress increases, accent color fills from the bottom up.
|
||||
*/
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
@@ -39,6 +43,12 @@ interface DTCardProps {
|
||||
* @default true when title is provided
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
/**
|
||||
* Progress value (0–1) for left-edge vertical progress bar.
|
||||
* Always present. At 0 the bar shows surface color (empty).
|
||||
* @default 0
|
||||
*/
|
||||
progress?: number;
|
||||
/**
|
||||
* Additional styles for the card container
|
||||
*/
|
||||
@@ -77,6 +87,114 @@ interface DTCardProps {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inner card path with left edge at bevelSizeSmall (for progress bar zone).
|
||||
* 5-point polygon — no bottom-left bevel, straight vertical left edge.
|
||||
*/
|
||||
function buildInnerCardPath(
|
||||
w: number,
|
||||
h: number,
|
||||
bevelBR: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
): string {
|
||||
const right = w - bw;
|
||||
const top = bw;
|
||||
const bottom = h - bw;
|
||||
const br = Math.min(
|
||||
bevelBR - bw,
|
||||
(right - bevelSizeSmall) / 3,
|
||||
(bottom - top) / 3,
|
||||
);
|
||||
if (right <= bevelSizeSmall || bottom <= top) return '';
|
||||
return [
|
||||
`M ${bevelSizeSmall} ${top}`,
|
||||
`L ${right} ${top}`,
|
||||
`L ${right} ${bottom - br}`,
|
||||
`L ${right - br} ${bottom}`,
|
||||
`L ${bevelSizeSmall} ${bottom}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full progress bar fill area path (inset 3px from frame on all sides).
|
||||
* This is the region where the surface/accent gradient fills.
|
||||
*/
|
||||
function buildProgressAreaPath(
|
||||
cardH: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
): string {
|
||||
const left = bw;
|
||||
const right = bevelSizeSmall - bw;
|
||||
const top = bw;
|
||||
const bevelStartY = cardH - bevelSizeSmall;
|
||||
const bottomRight = cardH - bw * 2; // y at bottom-right of fill area (on bevel diagonal)
|
||||
|
||||
if (right <= left || bevelStartY <= top) return '';
|
||||
|
||||
return [
|
||||
`M ${left} ${top}`,
|
||||
`L ${right} ${top}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
`L ${left} ${bevelStartY}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the accent-colored portion of the progress bar (fills from bottom).
|
||||
* Returns empty string when progress is 0.
|
||||
*/
|
||||
function buildProgressFillPath(
|
||||
cardH: number,
|
||||
bw: number,
|
||||
bevelSizeSmall: number,
|
||||
progress: number,
|
||||
): string {
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
if (p === 0) return '';
|
||||
|
||||
const left = bw;
|
||||
const right = bevelSizeSmall - bw;
|
||||
const top = bw;
|
||||
const bevelStartY = cardH - bevelSizeSmall;
|
||||
const bottomRight = cardH - bw * 2;
|
||||
|
||||
if (right <= left || bevelStartY <= top) return '';
|
||||
|
||||
// Total fill area height (along the left edge, from top to bevelStartY)
|
||||
const areaH = bevelStartY - top;
|
||||
// How much of the area is filled from the bottom
|
||||
const fillTop = top + areaH * (1 - p);
|
||||
|
||||
if (fillTop >= bevelStartY) {
|
||||
// Fill is entirely within the bevel zone
|
||||
// The bevel diagonal goes from (left, bevelStartY) to (right, bottomRight)
|
||||
// At y=fillTop, x on the diagonal: x = left + (fillTop - bevelStartY) * (right - left) / (bottomRight - bevelStartY)
|
||||
const bevelH = bottomRight - bevelStartY;
|
||||
if (bevelH <= 0) return '';
|
||||
const xAtFillTop = left + ((fillTop - bevelStartY) / bevelH) * (right - left);
|
||||
if (xAtFillTop >= right) return '';
|
||||
return [
|
||||
`M ${xAtFillTop} ${fillTop}`,
|
||||
`L ${right} ${fillTop}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
// Fill extends above the bevel zone — rectangle + bevel triangle
|
||||
return [
|
||||
`M ${left} ${fillTop}`,
|
||||
`L ${right} ${fillTop}`,
|
||||
`L ${right} ${bottomRight}`,
|
||||
`L ${left} ${bevelStartY}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Card component with SVG beveled corners
|
||||
*
|
||||
@@ -96,6 +214,7 @@ export function DTCard({
|
||||
borderColor,
|
||||
title,
|
||||
showHeader,
|
||||
progress = 0,
|
||||
style,
|
||||
contentStyle,
|
||||
padding = 16,
|
||||
@@ -118,9 +237,21 @@ export function DTCard({
|
||||
const outerPath = useBevels && hasDimensions
|
||||
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
|
||||
: '';
|
||||
// Inner bevel path at border inset (for background + frame cutout)
|
||||
// Inner bevel path — left edge at bevelSizeSmall (progress bar zone)
|
||||
const innerPath = useBevels && hasDimensions
|
||||
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
|
||||
? buildInnerCardPath(width, height, bevelSize, borderWidth, bevelSizeSmall)
|
||||
: '';
|
||||
|
||||
// Progress bar paths — computed once, reused in progress SVG and frame overlay
|
||||
const progressAreaPath = useBevels && hasDimensions
|
||||
? buildProgressAreaPath(height, borderWidth, bevelSizeSmall)
|
||||
: '';
|
||||
const progressFillPath = useBevels && hasDimensions && progress > 0
|
||||
? buildProgressFillPath(height, borderWidth, bevelSizeSmall, progress)
|
||||
: '';
|
||||
// Frame overlay: outer + inner + progressArea hole (evenodd punches window for progress bar)
|
||||
const framePath = useBevels && hasDimensions
|
||||
? outerPath + ' ' + innerPath + (progressAreaPath ? ' ' + progressAreaPath : '')
|
||||
: '';
|
||||
|
||||
const content = (
|
||||
@@ -147,7 +278,54 @@ export function DTCard({
|
||||
<Path d={innerPath} fill={bgColor} />
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.innerContainer}>
|
||||
{/* Progress bar fill area — beveled mode */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, {zIndex: 1}]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
{/* Accent base — visible through the semi-transparent surface above.
|
||||
Matches web where ::after sits over the accent-colored card bg. */}
|
||||
<Path d={progressAreaPath} fill={accentColor} />
|
||||
{/* Surface fill over accent base — 0.6 opacity lets accent bleed through.
|
||||
Matches web --dt-progress-empty-opacity default. */}
|
||||
<Path
|
||||
d={progressAreaPath}
|
||||
fill={bgColor}
|
||||
opacity={0.6}
|
||||
/>
|
||||
{/* Accent fill from bottom (progressed portion) */}
|
||||
{progressFillPath !== '' && (
|
||||
<Path
|
||||
d={progressFillPath}
|
||||
fill={accentColor}
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
)}
|
||||
{/* Progress bar — non-beveled (classic) mode */}
|
||||
{!useBevels && (
|
||||
<View style={[styles.progressBar, {
|
||||
width: bevelSizeSmall,
|
||||
left: borderWidth,
|
||||
top: borderWidth,
|
||||
bottom: borderWidth,
|
||||
borderBottomLeftRadius: theme.custom.radius > 0 ? Math.max(0, theme.custom.radius - borderWidth) : 0,
|
||||
}]}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
backgroundColor: accentColor,
|
||||
height: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.innerContainer, useBevels && {paddingLeft: bevelSizeSmall - borderWidth}]}>
|
||||
{shouldShowHeader && (
|
||||
<View style={[styles.header, {backgroundColor: accentColor}]}>
|
||||
{title && (
|
||||
@@ -161,7 +339,10 @@ export function DTCard({
|
||||
)}
|
||||
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
|
||||
</View>
|
||||
{/* Frame overlay (above content) — beveled mode only */}
|
||||
{/* Frame overlay (above content) — beveled mode only.
|
||||
Uses evenodd with 3 sub-paths: outer + inner + progressArea.
|
||||
The progress area path punches a hole in the frame so the
|
||||
progress bar SVG underneath (zIndex:1) shows through. */}
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||
@@ -170,7 +351,7 @@ export function DTCard({
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
<Path
|
||||
d={outerPath + ' ' + innerPath}
|
||||
d={framePath}
|
||||
fillRule="evenodd"
|
||||
fill={accentColor}
|
||||
/>
|
||||
@@ -221,7 +402,8 @@ const styles = StyleSheet.create({
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
headerText: {
|
||||
@@ -232,4 +414,17 @@ const styles = StyleSheet.create({
|
||||
frameOverlay: {
|
||||
zIndex: 2,
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute' as const,
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 3,
|
||||
justifyContent: 'flex-end' as const,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
progressFill: {
|
||||
width: '100%' as const,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export function DTChip({
|
||||
<Chip
|
||||
{...props}
|
||||
mode="outlined"
|
||||
showSelectedCheck={false}
|
||||
textStyle={[
|
||||
styles.text,
|
||||
{ color: selected ? theme.colors.onPrimary : color },
|
||||
|
||||
136
packages/react-native/src/components/DTFeatureLegend.tsx
Normal file
136
packages/react-native/src/components/DTFeatureLegend.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DT Feature Legend
|
||||
*
|
||||
* Displays product features in a grid with icons and rotated labels.
|
||||
* Source: dt-shopify-storefront UseCaseLegend component.
|
||||
*/
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
export interface DTFeatureItem {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
state: 'supported' | 'disabled' | 'unsupported';
|
||||
}
|
||||
|
||||
const stateToVariant: Record<DTFeatureItem['state'], DTVariant> = {
|
||||
supported: 'normal',
|
||||
disabled: 'emphasis',
|
||||
unsupported: 'warning',
|
||||
};
|
||||
|
||||
interface DTFeatureLegendProps {
|
||||
features: DTFeatureItem[];
|
||||
/**
|
||||
* Header title text
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Color variant for the header bar
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Number of columns in the grid
|
||||
* @default 5
|
||||
*/
|
||||
columns?: number;
|
||||
/**
|
||||
* Icon size in pixels
|
||||
* @default 42
|
||||
*/
|
||||
iconSize?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DTFeatureLegend({
|
||||
features,
|
||||
title,
|
||||
variant = 'normal',
|
||||
columns = 5,
|
||||
iconSize = 42,
|
||||
style,
|
||||
}: DTFeatureLegendProps) {
|
||||
const theme = useDTTheme();
|
||||
const headerColor = getVariantColor(theme, variant);
|
||||
const itemWidth = `${100 / columns}%` as const;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{title && (
|
||||
<View style={[styles.header, {backgroundColor: headerColor}]}>
|
||||
<Text
|
||||
variant="labelLarge"
|
||||
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.grid}>
|
||||
{features.map(feature => {
|
||||
const featureColor = getVariantColor(
|
||||
theme,
|
||||
stateToVariant[feature.state],
|
||||
);
|
||||
return (
|
||||
<View
|
||||
key={feature.key}
|
||||
style={[styles.item, {width: itemWidth as unknown as number}]}>
|
||||
<View style={[styles.iconContainer, {width: iconSize, height: iconSize}]}>
|
||||
{feature.icon}
|
||||
</View>
|
||||
<View style={styles.labelContainer}>
|
||||
<Text
|
||||
style={[styles.label, {color: featureColor}]}>
|
||||
{feature.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
headerText: {
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
item: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
gap: 4,
|
||||
},
|
||||
iconContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
labelContainer: {
|
||||
paddingTop: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal file
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* DT Mobile Filter Overlay
|
||||
*
|
||||
* Full-screen slide-up overlay for mobile filter menus.
|
||||
* Source: dt-shopify-storefront MobileFilterMenu component.
|
||||
*/
|
||||
|
||||
import {ReactNode, useRef, useEffect} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Pressable,
|
||||
Animated,
|
||||
Dimensions,
|
||||
BackHandler,
|
||||
} from 'react-native';
|
||||
import {Text, Portal} from 'react-native-paper';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
interface DTMobileFilterOverlayProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
/**
|
||||
* Header title
|
||||
*/
|
||||
heading?: string;
|
||||
/**
|
||||
* Number of active filters (shown as badge in header)
|
||||
*/
|
||||
activeFilterCount?: number;
|
||||
/**
|
||||
* Callback to clear all filters
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
/**
|
||||
* Color variant for the header
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DTMobileFilterOverlay({
|
||||
visible,
|
||||
onDismiss,
|
||||
heading = 'Filters',
|
||||
activeFilterCount,
|
||||
onClearAll,
|
||||
variant = 'normal',
|
||||
children,
|
||||
style,
|
||||
}: DTMobileFilterOverlayProps) {
|
||||
const theme = useDTTheme();
|
||||
const accentColor = getVariantColor(theme, variant);
|
||||
const slideAnim = useRef(new Animated.Value(0)).current;
|
||||
const backdropAnim = useRef(new Animated.Value(0)).current;
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, backdropAnim, slideAnim]);
|
||||
|
||||
// Android back button
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onDismiss();
|
||||
return true;
|
||||
});
|
||||
return () => handler.remove();
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const translateY = slideAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [screenHeight, 0],
|
||||
});
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View style={styles.overlay}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, {opacity: backdropAnim}]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onDismiss} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
transform: [{translateY}],
|
||||
maxHeight: screenHeight * 0.85,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, {backgroundColor: accentColor}]}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
|
||||
{heading}
|
||||
</Text>
|
||||
{activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||
<View style={[styles.countBadge, {backgroundColor: theme.colors.onPrimary}]}>
|
||||
<Text style={[styles.countText, {color: accentColor}]}>
|
||||
{activeFilterCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Pressable onPress={onDismiss} hitSlop={8}>
|
||||
<Text style={[styles.closeText, {color: theme.colors.onPrimary}]}>
|
||||
CLOSE
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Clear all button */}
|
||||
{onClearAll && activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||
<Pressable
|
||||
onPress={onClearAll}
|
||||
style={[styles.clearAll, {borderBottomColor: accentColor}]}>
|
||||
<Text style={[styles.clearAllText, {color: accentColor}]}>
|
||||
Clear All Filters
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Filter content */}
|
||||
<View style={styles.body}>{children}</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 1000,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
content: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
headerText: {
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
countBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
clearAll: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
clearAllText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
body: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal file
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* DT Stagger Container
|
||||
*
|
||||
* Animates children with staggered scale-in entrances.
|
||||
* Source: dt-shopify-storefront framer-motion staggered card entrance pattern.
|
||||
*/
|
||||
|
||||
import {ReactNode, Children, useRef, useEffect} from 'react';
|
||||
import {Animated, ViewStyle, StyleProp} from 'react-native';
|
||||
|
||||
interface DTStaggerContainerProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Duration of each child's scale-in animation in ms
|
||||
* @default 330
|
||||
*/
|
||||
duration?: number;
|
||||
/**
|
||||
* Delay between each child's animation start in ms
|
||||
* @default 75
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* Additional styles for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function DTStaggerContainer({
|
||||
children,
|
||||
duration = 330,
|
||||
interval = 75,
|
||||
style,
|
||||
}: DTStaggerContainerProps) {
|
||||
const childArray = Children.toArray(children);
|
||||
const animations = useRef(childArray.map(() => new Animated.Value(0))).current;
|
||||
|
||||
useEffect(() => {
|
||||
// Reset animations if child count changes
|
||||
while (animations.length < childArray.length) {
|
||||
animations.push(new Animated.Value(0));
|
||||
}
|
||||
|
||||
const staggered = childArray.map((_, i) =>
|
||||
Animated.timing(animations[i], {
|
||||
toValue: 1,
|
||||
duration,
|
||||
delay: i * interval,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
);
|
||||
Animated.parallel(staggered).start();
|
||||
}, [childArray.length, duration, interval]);
|
||||
|
||||
return (
|
||||
<Animated.View style={style}>
|
||||
{childArray.map((child, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={{transform: [{scale: animations[i] ?? new Animated.Value(1)}]}}>
|
||||
{child}
|
||||
</Animated.View>
|
||||
))}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export type { DTVariant } from './utils/variantColors';
|
||||
export { getVariantColor } from './utils/variantColors';
|
||||
export { buildBeveledRectPath } from './utils/bevelPaths';
|
||||
export { useComponentLayout } from './utils/useComponentLayout';
|
||||
export { useScaleIn, usePulse } from './utils/animations';
|
||||
|
||||
// Component exports — existing
|
||||
export { DTCard, DTCardClipPath } from './components/DTCard';
|
||||
@@ -50,6 +51,10 @@ export { DTMediaFrame } from './components/DTMediaFrame';
|
||||
export { DTAccordion } from './components/DTAccordion';
|
||||
export type { DTAccordionSection } from './components/DTAccordion';
|
||||
|
||||
// Component exports — positioning & animation containers
|
||||
export { DTBadgeOverlay } from './components/DTBadgeOverlay';
|
||||
export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||
|
||||
// Component exports — complex interactive
|
||||
export { DTModal } from './components/DTModal';
|
||||
export { DTDrawer } from './components/DTDrawer';
|
||||
@@ -57,6 +62,11 @@ export { DTGallery } from './components/DTGallery';
|
||||
export type { DTGalleryItem } from './components/DTGallery';
|
||||
export { DTSearchInput } from './components/DTSearchInput';
|
||||
|
||||
// Component exports — filter & feature
|
||||
export { DTFeatureLegend } from './components/DTFeatureLegend';
|
||||
export type { DTFeatureItem } from './components/DTFeatureLegend';
|
||||
export { DTMobileFilterOverlay } from './components/DTMobileFilterOverlay';
|
||||
|
||||
// Component exports — navigation & decorative
|
||||
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
|
||||
export type { DTMenuItem } from './components/DTMenu';
|
||||
|
||||
73
packages/react-native/src/utils/animations.ts
Normal file
73
packages/react-native/src/utils/animations.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Reusable animation hooks for DT components
|
||||
*
|
||||
* Provides common animation patterns (scale-in, pulse) as hooks
|
||||
* that consumers and new components can use consistently.
|
||||
*/
|
||||
|
||||
import {useRef, useEffect} from 'react';
|
||||
import {Animated} from 'react-native';
|
||||
|
||||
/**
|
||||
* Scale-in entrance animation.
|
||||
* Returns an Animated.Value (0→1) to use as a scale transform.
|
||||
*/
|
||||
export function useScaleIn(options?: {
|
||||
enabled?: boolean;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
}): Animated.Value {
|
||||
const {enabled = true, duration = 330, delay = 0} = options ?? {};
|
||||
const anim = useRef(new Animated.Value(enabled ? 0 : 1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
anim.setValue(0);
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration,
|
||||
delay,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
anim.setValue(1);
|
||||
}
|
||||
}, [enabled, duration, delay, anim]);
|
||||
|
||||
return anim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulse (ping) opacity animation loop.
|
||||
* Returns an Animated.Value that oscillates between 1 and 0.3.
|
||||
*/
|
||||
export function usePulse(enabled = true): Animated.Value {
|
||||
const anim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(anim, {
|
||||
toValue: 0.3,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => {
|
||||
loop.stop();
|
||||
anim.setValue(1);
|
||||
};
|
||||
}
|
||||
anim.setValue(1);
|
||||
}, [enabled, anim]);
|
||||
|
||||
return anim;
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
|
||||
import type {DTExtendedTheme} from '../theme/paperTheme';
|
||||
import type {DTVariant} from '@dangerousthings/tokens';
|
||||
|
||||
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
||||
// Re-export DTVariant from the canonical source (tokens package)
|
||||
export type {DTVariant} from '@dangerousthings/tokens';
|
||||
|
||||
/** String-typed keys in DTExtendedTheme['custom'] that map to mode colors */
|
||||
type ModeColorKey = 'modeNormal' | 'modeEmphasis' | 'modeWarning' | 'modeSuccess' | 'modeOther';
|
||||
|
||||
42
packages/react/package.json
Normal file
42
packages/react/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@dangerousthings/react",
|
||||
"version": "0.1.0",
|
||||
"description": "React web components for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"@dangerousthings/tokens": "*",
|
||||
"@dangerousthings/web": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dangerousthings/tokens": "*",
|
||||
"@dangerousthings/web": "*",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
83
packages/react/src/components/DTAccordion.tsx
Normal file
83
packages/react/src/components/DTAccordion.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* DTAccordion — Expandable sections with animated height and chevron.
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-accordion, .dt-accordion-header,
|
||||
* .dt-accordion-chevron, .dt-accordion-content
|
||||
*/
|
||||
|
||||
import { useState, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
export interface DTAccordionSection {
|
||||
key: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface DTAccordionProps {
|
||||
sections: DTAccordionSection[];
|
||||
/** Header variant when closed @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Header variant when open @default 'emphasis' */
|
||||
activeVariant?: DTVariant;
|
||||
/** Allow multiple sections open @default false */
|
||||
allowMultiple?: boolean;
|
||||
initialOpenKeys?: string[];
|
||||
onSectionToggle?: (key: string, isOpen: boolean) => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTAccordion({
|
||||
sections,
|
||||
allowMultiple = false,
|
||||
initialOpenKeys,
|
||||
onSectionToggle,
|
||||
className,
|
||||
style,
|
||||
}: DTAccordionProps) {
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
new Set(initialOpenKeys),
|
||||
);
|
||||
|
||||
const toggle = 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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('dt-accordion', className)} style={style}>
|
||||
{sections.map(section => {
|
||||
const isOpen = openKeys.has(section.key);
|
||||
return (
|
||||
<div key={section.key}>
|
||||
<button
|
||||
className="dt-accordion-header"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => toggle(section.key)}
|
||||
type="button"
|
||||
style={{ width: '100%' }}>
|
||||
<span>{section.title}</span>
|
||||
<span className="dt-accordion-chevron" />
|
||||
</button>
|
||||
<div
|
||||
className="dt-accordion-content"
|
||||
data-open={isOpen}>
|
||||
{section.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/react/src/components/DTBadgeOverlay.tsx
Normal file
50
packages/react/src/components/DTBadgeOverlay.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DTBadgeOverlay — Absolute-positioned badge container.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-badge-bottom-right, .dt-badge-top-right, etc.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
type BadgePosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
interface DTBadgeOverlayProps {
|
||||
children: ReactNode;
|
||||
/** Badge position @default 'bottom-right' */
|
||||
position?: BadgePosition;
|
||||
/** Offset from corner in px */
|
||||
offset?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const positionClassMap: Record<BadgePosition, string> = {
|
||||
'top-left': 'dt-badge-top-left',
|
||||
'top-right': 'dt-badge-top-right',
|
||||
'bottom-left': 'dt-badge-overlay',
|
||||
'bottom-right': 'dt-badge-bottom-right',
|
||||
};
|
||||
|
||||
export function DTBadgeOverlay({
|
||||
children,
|
||||
position = 'bottom-right',
|
||||
offset,
|
||||
className,
|
||||
style,
|
||||
}: DTBadgeOverlayProps) {
|
||||
const offsetStyle: CSSProperties = offset
|
||||
? {
|
||||
...(position.includes('top') ? { top: `${offset}px` } : { bottom: `${offset}px` }),
|
||||
...(position.includes('left') ? { left: `${offset}px` } : { right: `${offset}px` }),
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(positionClassMap[position], className)}
|
||||
style={{ ...offsetStyle, ...style }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
packages/react/src/components/DTButton.tsx
Normal file
66
packages/react/src/components/DTButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* DTButton — Interactive bevel button with mode colors.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-btn, .btn-primary, .mode-*, .selected
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties, MouseEvent } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTButtonProps {
|
||||
children: ReactNode;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Display mode @default 'outlined' */
|
||||
mode?: 'outlined' | 'contained';
|
||||
/** Persistent selected state (bevel + 70% fill) */
|
||||
selected?: boolean;
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTButton({
|
||||
children,
|
||||
variant = 'normal',
|
||||
mode = 'outlined',
|
||||
selected = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
className,
|
||||
style,
|
||||
}: DTButtonProps) {
|
||||
if (mode === 'contained') {
|
||||
return (
|
||||
<button
|
||||
className={cx('btn-primary', getVariantClass(variant), className)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
style={style}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
'dt-btn',
|
||||
getVariantClass(variant),
|
||||
selected && 'selected',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
style={style}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
66
packages/react/src/components/DTCard.tsx
Normal file
66
packages/react/src/components/DTCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* DTCard — Beveled card with header, progress bar, and mode colors.
|
||||
*
|
||||
* CSS reference: bevels.css .card / .dt-bevel-card, .card-title,
|
||||
* ::after progress bar, .mode-*
|
||||
*
|
||||
* The progress bar is a structural element on the left edge (0 to bevel-sm).
|
||||
* It is always present. At 0 progress it shows surface color (empty bar).
|
||||
* As progress increases, accent color fills from the bottom up.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTCardProps {
|
||||
children: ReactNode;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Card title (displayed in header bar) */
|
||||
title?: string;
|
||||
/** Whether to show the accent header bar @default true when title is provided */
|
||||
showHeader?: boolean;
|
||||
/** Progress value (0–100) for left-edge vertical progress bar @default 0 */
|
||||
progress?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function DTCard({
|
||||
children,
|
||||
variant = 'normal',
|
||||
title,
|
||||
showHeader,
|
||||
progress = 0,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
}: DTCardProps) {
|
||||
const shouldShowHeader = showHeader ?? !!title;
|
||||
const Tag = onClick ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cx(
|
||||
'card',
|
||||
getVariantClass(variant),
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
'--dt-card-progress': progress,
|
||||
} as CSSProperties}
|
||||
onClick={onClick}
|
||||
type={onClick ? 'button' : undefined}>
|
||||
{shouldShowHeader && (
|
||||
<div className="card-title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
46
packages/react/src/components/DTCheckbox.tsx
Normal file
46
packages/react/src/components/DTCheckbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* DTCheckbox — Beveled diamond checkbox.
|
||||
*
|
||||
* CSS reference: forms-dt.css input[type="checkbox"]
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
}: DTCheckboxProps) {
|
||||
return (
|
||||
<label
|
||||
className={cx('dt-checkbox-wrapper', className)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
...style,
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label && <span>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
47
packages/react/src/components/DTChip.tsx
Normal file
47
packages/react/src/components/DTChip.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DTChip — Small badge/chip with variant-based status colors.
|
||||
*
|
||||
* CSS reference: bevels.css .badge, .badge-success, .badge-error, etc.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass, variantToBadgeClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTChipProps {
|
||||
children: ReactNode;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Use mode-colored fill instead of status border color */
|
||||
filled?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTChip({
|
||||
children,
|
||||
variant = 'normal',
|
||||
filled = false,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
}: DTChipProps) {
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={cx(
|
||||
'badge',
|
||||
filled ? 'badge-mode' : variantToBadgeClass(variant),
|
||||
getVariantClass(variant),
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
type={onClick ? 'button' : undefined}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
144
packages/react/src/components/DTDrawer.tsx
Normal file
144
packages/react/src/components/DTDrawer.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* DTDrawer — Sliding side panel with beveled edges.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-drawer-right, .dt-bevel-drawer-left
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTDrawerProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
heading: string;
|
||||
/** Color variant for heading bar @default 'emphasis' */
|
||||
headingVariant?: DTVariant;
|
||||
/** Slide direction @default 'right' */
|
||||
position?: 'right' | 'left';
|
||||
/** Panel width @default '400px' */
|
||||
width?: string | number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTDrawer({
|
||||
visible,
|
||||
onDismiss,
|
||||
heading,
|
||||
headingVariant = 'emphasis',
|
||||
position = 'right',
|
||||
width = 400,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: DTDrawerProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onDismiss();
|
||||
},
|
||||
[onDismiss],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [visible, handleKeyDown]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const bevelClass = position === 'right' ? 'dt-bevel-drawer-right' : 'dt-bevel-drawer-left';
|
||||
const widthValue = typeof width === 'number' ? `${width}px` : width;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
animation: 'dt-fade-in 0.2s ease-in-out both',
|
||||
}}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cx(
|
||||
bevelClass,
|
||||
getVariantClass(headingVariant),
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
[position]: 0,
|
||||
width: widthValue,
|
||||
maxWidth: '100vw',
|
||||
background: 'var(--color-bg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: `dt-slide-${position === 'right' ? 'up' : 'up'} 0.2s ease-in-out both`,
|
||||
...style,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: `var(${getVariantCSSVar(headingVariant)}, var(--color-primary))`,
|
||||
color: 'var(--color-bg)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '18px',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
<span>{heading}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
}}
|
||||
type="button">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function getVariantCSSVar(variant: DTVariant): string {
|
||||
const map: Record<DTVariant, string> = {
|
||||
normal: '--mode-normal',
|
||||
emphasis: '--mode-emphasis',
|
||||
warning: '--mode-warning',
|
||||
success: '--mode-success',
|
||||
other: '--mode-other',
|
||||
};
|
||||
return map[variant];
|
||||
}
|
||||
77
packages/react/src/components/DTFeatureLegend.tsx
Normal file
77
packages/react/src/components/DTFeatureLegend.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* DTFeatureLegend — Grid of product feature icons with rotated labels.
|
||||
*
|
||||
* CSS reference: feature-legend.css .dt-feature-legend, .dt-feature-legend-header,
|
||||
* .dt-feature-legend-grid, .dt-feature-legend-item, .dt-feature-supported, etc.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass, featureStateToVariant } from '../utils/variantClasses';
|
||||
|
||||
export interface DTFeatureItem {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
state: 'supported' | 'disabled' | 'unsupported';
|
||||
}
|
||||
|
||||
const stateClassMap: Record<DTFeatureItem['state'], string> = {
|
||||
supported: 'dt-feature-supported',
|
||||
disabled: 'dt-feature-disabled',
|
||||
unsupported: 'dt-feature-unsupported',
|
||||
};
|
||||
|
||||
interface DTFeatureLegendProps {
|
||||
features: DTFeatureItem[];
|
||||
title?: string;
|
||||
/** Header variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Grid columns @default 5 */
|
||||
columns?: number;
|
||||
/** Icon size in px @default 42 */
|
||||
iconSize?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTFeatureLegend({
|
||||
features,
|
||||
title,
|
||||
variant = 'normal',
|
||||
columns = 5,
|
||||
iconSize = 42,
|
||||
className,
|
||||
style,
|
||||
}: DTFeatureLegendProps) {
|
||||
// Suppress unused import warning — featureStateToVariant is available for consumers
|
||||
void featureStateToVariant;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-feature-legend', getVariantClass(variant), className)}
|
||||
style={style}>
|
||||
{title && (
|
||||
<div className="dt-feature-legend-header">{title}</div>
|
||||
)}
|
||||
<div
|
||||
className="dt-feature-legend-grid"
|
||||
style={{ '--dt-feature-columns': columns } as CSSProperties}>
|
||||
{features.map(feature => (
|
||||
<div
|
||||
key={feature.key}
|
||||
className={cx('dt-feature-legend-item', stateClassMap[feature.state])}
|
||||
style={{ width: `${100 / columns}%` }}>
|
||||
<div
|
||||
className="dt-feature-legend-icon"
|
||||
style={{ width: `${iconSize}px`, height: `${iconSize}px` }}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className="dt-feature-legend-label">{feature.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
packages/react/src/components/DTGallery.tsx
Normal file
91
packages/react/src/components/DTGallery.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* DTGallery — Image gallery with beveled media frame and thumbnails.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-media
|
||||
*/
|
||||
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
export interface DTGalleryItem {
|
||||
key: string;
|
||||
src: string;
|
||||
alt?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
interface DTGalleryProps {
|
||||
items: DTGalleryItem[];
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTGallery({
|
||||
items,
|
||||
variant = 'normal',
|
||||
className,
|
||||
style,
|
||||
}: DTGalleryProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const current = items[activeIndex];
|
||||
|
||||
return (
|
||||
<div className={cx(getVariantClass(variant), className)} style={style}>
|
||||
{/* Main image */}
|
||||
<div className="dt-bevel-media" style={{ overflow: 'hidden' }}>
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
transition: 'opacity 300ms ease-in-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Thumbnails */}
|
||||
{items.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginTop: '8px',
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
type="button"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
padding: 0,
|
||||
border: i === activeIndex
|
||||
? '2px solid var(--dt-card-color, var(--color-primary))'
|
||||
: '2px solid transparent',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<img
|
||||
src={item.thumbnail || item.src}
|
||||
alt={item.alt || ''}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
packages/react/src/components/DTHexagon.tsx
Normal file
109
packages/react/src/components/DTHexagon.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* DTHexagon — Decorative SVG hexagon with optional animations.
|
||||
*
|
||||
* Uses inline SVG (not CSS classes) — same approach as the RN version.
|
||||
* Animation classes from animations.css applied to wrapper div.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTHexagonProps {
|
||||
/** Diameter in pixels */
|
||||
size: number;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Filled or outline @default true */
|
||||
filled?: boolean;
|
||||
/** Enable animation @default false */
|
||||
animated?: boolean;
|
||||
/** Animation type @default 'rotate' */
|
||||
animationType?: 'rotate' | 'pulse' | 'none';
|
||||
/** Animation duration in ms @default 2000 */
|
||||
animationDuration?: number;
|
||||
/** Outline stroke width @default 2 */
|
||||
borderWidth?: number;
|
||||
/** Custom color override */
|
||||
color?: string;
|
||||
/** Opacity @default 1 */
|
||||
opacity?: number;
|
||||
/** Centered content inside hexagon */
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** Flat-top hexagon points for given size */
|
||||
function hexPoints(size: number): string {
|
||||
const r = size / 2;
|
||||
const cx = r;
|
||||
const cy = r;
|
||||
const points: [number, number][] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
||||
points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]);
|
||||
}
|
||||
return points.map(([x, y]) => `${x},${y}`).join(' ');
|
||||
}
|
||||
|
||||
export function DTHexagon({
|
||||
size,
|
||||
variant = 'normal',
|
||||
filled = true,
|
||||
animated = false,
|
||||
animationType = 'rotate',
|
||||
animationDuration = 2000,
|
||||
borderWidth = 2,
|
||||
color,
|
||||
opacity = 1,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: DTHexagonProps) {
|
||||
const animClass = animated
|
||||
? animationType === 'rotate'
|
||||
? 'dt-animate-spin'
|
||||
: animationType === 'pulse'
|
||||
? 'dt-animate-pulse'
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(getVariantClass(variant), animClass, className)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size,
|
||||
height: size,
|
||||
position: 'relative',
|
||||
opacity,
|
||||
...(animated && animationDuration !== 2000
|
||||
? { animationDuration: `${animationDuration}ms` }
|
||||
: {}),
|
||||
...style,
|
||||
}}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||
<polygon
|
||||
points={hexPoints(size - borderWidth)}
|
||||
transform={`translate(${borderWidth / 2}, ${borderWidth / 2})`}
|
||||
fill={filled ? (color || 'var(--dt-card-color, var(--color-primary))') : 'none'}
|
||||
stroke={color || 'var(--dt-card-color, var(--color-primary))'}
|
||||
strokeWidth={borderWidth}
|
||||
/>
|
||||
</svg>
|
||||
{children && (
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
packages/react/src/components/DTLabel.tsx
Normal file
33
packages/react/src/components/DTLabel.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* DTLabel — Filled badge/label with top-right bevel and mode color.
|
||||
*
|
||||
* CSS reference: bevels.css .badge, .badge-mode, .mode-*
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTLabelProps {
|
||||
children: ReactNode;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTLabel({
|
||||
children,
|
||||
variant = 'normal',
|
||||
className,
|
||||
style,
|
||||
}: DTLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cx('badge', 'badge-mode', getVariantClass(variant), className)}
|
||||
style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
26
packages/react/src/components/DTMediaFrame.tsx
Normal file
26
packages/react/src/components/DTMediaFrame.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-media
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTMediaFrameProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTMediaFrame({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: DTMediaFrameProps) {
|
||||
return (
|
||||
<div className={cx('dt-bevel-media', className)} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
packages/react/src/components/DTMenu.tsx
Normal file
108
packages/react/src/components/DTMenu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* DTMenu — Hierarchical menu with beveled items.
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-menu-item, .active, .selected
|
||||
*/
|
||||
|
||||
import { useState, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
export interface DTMenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
items?: DTMenuItem[];
|
||||
isActive?: boolean;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
interface DTMenuProps {
|
||||
items: DTMenuItem[];
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
onItemClick?: (id: string) => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTMenu({
|
||||
items,
|
||||
variant = 'normal',
|
||||
onItemClick,
|
||||
className,
|
||||
style,
|
||||
}: DTMenuProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(getVariantClass(variant), className)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '4px', ...style }}>
|
||||
{items.map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
level={0}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
item,
|
||||
level,
|
||||
onItemClick,
|
||||
}: {
|
||||
item: DTMenuItem;
|
||||
level: number;
|
||||
onItemClick?: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = item.items && item.items.length > 0;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (hasChildren) {
|
||||
setExpanded(prev => !prev);
|
||||
}
|
||||
onItemClick?.(item.id);
|
||||
}, [hasChildren, item.id, onItemClick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={cx(
|
||||
'dt-menu-item',
|
||||
item.isActive && 'active',
|
||||
item.isSelected && 'selected',
|
||||
)}
|
||||
style={{ '--dt-menu-level': level } as CSSProperties}
|
||||
onClick={handleClick}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
type="button">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
</span>
|
||||
{hasChildren && (
|
||||
<span
|
||||
className="dt-accordion-chevron"
|
||||
style={{
|
||||
transition: 'transform 250ms ease-in-out',
|
||||
transform: expanded ? 'rotate(180deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && expanded && item.items!.map(child => (
|
||||
<MenuItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
level={level + 1}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal file
120
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* DTMobileFilterOverlay — Full-screen slide-up filter panel.
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-filter-overlay, .dt-filter-overlay-backdrop,
|
||||
* .dt-filter-overlay-content
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTMobileFilterOverlayProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
/** Heading text @default 'Filters' */
|
||||
heading?: string;
|
||||
/** Number of active filters (shown as badge) */
|
||||
activeFilterCount?: number;
|
||||
/** Called when "Clear All" is pressed */
|
||||
onClearAll?: () => void;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTMobileFilterOverlay({
|
||||
visible,
|
||||
onDismiss,
|
||||
heading = 'Filters',
|
||||
activeFilterCount,
|
||||
onClearAll,
|
||||
variant = 'normal',
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: DTMobileFilterOverlayProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onDismiss();
|
||||
},
|
||||
[onDismiss],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [visible, handleKeyDown]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}>
|
||||
<div className="dt-filter-overlay-backdrop" onClick={onDismiss} />
|
||||
<div className="dt-filter-overlay-content">
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: '1.125rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{heading}
|
||||
{activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||
<span
|
||||
className="badge badge-mode"
|
||||
style={{ marginLeft: '8px', fontSize: '0.75rem' }}>
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{onClearAll && activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
type="button"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--color-primary)',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div style={{ padding: '16px', overflow: 'auto', maxHeight: 'calc(85vh - 60px)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
96
packages/react/src/components/DTModal.tsx
Normal file
96
packages/react/src/components/DTModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* DTModal — Portal-based modal with beveled card content.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-modal, .card, .mode-*
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTModalProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
title?: string;
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
children: ReactNode;
|
||||
/** Whether clicking backdrop dismisses @default true */
|
||||
dismissable?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTModal({
|
||||
visible,
|
||||
onDismiss,
|
||||
title,
|
||||
variant = 'normal',
|
||||
children,
|
||||
dismissable = true,
|
||||
className,
|
||||
style,
|
||||
}: DTModalProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && dismissable) onDismiss();
|
||||
},
|
||||
[onDismiss, dismissable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [visible, handleKeyDown]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="dt-modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<div
|
||||
className="dt-modal-backdrop"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={dismissable ? onDismiss : undefined}
|
||||
/>
|
||||
<div
|
||||
className={cx(
|
||||
'card',
|
||||
'dt-bevel-modal',
|
||||
getVariantClass(variant),
|
||||
'dt-animate-scale-in',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
...style,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
{title && <div className="card-title">{title}</div>}
|
||||
<div style={{ padding: 'var(--space-6, 24px)' }}>{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
45
packages/react/src/components/DTProgressBar.tsx
Normal file
45
packages/react/src/components/DTProgressBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* DTProgressBar — Angular progress bar (horizontal or vertical).
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-progress, .dt-progress-fill, .dt-progress-label
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTProgressBarProps {
|
||||
/** Progress value (0–1) */
|
||||
value: number;
|
||||
/** Text label below/beside the bar */
|
||||
label?: string;
|
||||
/** Layout direction @default 'horizontal' */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTProgressBar({
|
||||
value,
|
||||
label,
|
||||
direction = 'horizontal',
|
||||
className,
|
||||
style,
|
||||
}: DTProgressBarProps) {
|
||||
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className={cx('dt-progress', direction === 'vertical' && 'vertical')}>
|
||||
<div
|
||||
className="dt-progress-fill"
|
||||
style={
|
||||
direction === 'vertical'
|
||||
? { height: `${pct}%` }
|
||||
: { width: `${pct}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{label && <span className="dt-progress-label">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/react/src/components/DTQuantityStepper.tsx
Normal file
50
packages/react/src/components/DTQuantityStepper.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DTQuantityStepper — Beveled +/- stepper.
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-stepper, .dt-stepper-btn, .dt-stepper-value
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTQuantityStepperProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTQuantityStepper({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 99,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: DTQuantityStepperProps) {
|
||||
return (
|
||||
<div className={cx('dt-stepper', className)} style={style}>
|
||||
<button
|
||||
className="dt-stepper-btn"
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
disabled={disabled || value <= min}
|
||||
type="button"
|
||||
aria-label="Decrease">
|
||||
−
|
||||
</button>
|
||||
<span className="dt-stepper-value">{value}</span>
|
||||
<button
|
||||
className="dt-stepper-btn"
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
disabled={disabled || value >= max}
|
||||
type="button"
|
||||
aria-label="Increase">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
packages/react/src/components/DTRadioGroup.tsx
Normal file
58
packages/react/src/components/DTRadioGroup.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* DTRadioGroup — Hexagonal radio buttons.
|
||||
*
|
||||
* CSS reference: forms-dt.css input[type="radio"], .dt-radio-option
|
||||
*/
|
||||
|
||||
import { useId, type CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTRadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DTRadioGroupProps {
|
||||
options: DTRadioOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
name?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export { type DTRadioOption };
|
||||
|
||||
export function DTRadioGroup({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
className,
|
||||
style,
|
||||
}: DTRadioGroupProps) {
|
||||
const groupId = useId();
|
||||
const groupName = name || `dt-radio-${groupId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-radio-group', className)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '4px', ...style }}
|
||||
role="radiogroup">
|
||||
{options.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cx('dt-radio-option', value === option.value && 'selected')}>
|
||||
<input
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
packages/react/src/components/DTSearchInput.tsx
Normal file
70
packages/react/src/components/DTSearchInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* DTSearchInput — Search field with icon.
|
||||
*
|
||||
* CSS reference: forms-dt.css .input
|
||||
*/
|
||||
|
||||
import { useCallback, type CSSProperties, type KeyboardEvent } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onSearch?: (value: string) => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
onSearch,
|
||||
className,
|
||||
style,
|
||||
}: DTSearchInputProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && onSearch) {
|
||||
onSearch(value);
|
||||
}
|
||||
},
|
||||
[onSearch, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-search-input-wrapper', className)}
|
||||
style={{ position: 'relative', ...style }}>
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
style={{ width: '100%', padding: '8px 12px 8px 36px' }}
|
||||
/>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
packages/react/src/components/DTStaggerContainer.tsx
Normal file
39
packages/react/src/components/DTStaggerContainer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DTStaggerContainer — Staggered scale-in animation for children.
|
||||
*
|
||||
* CSS reference: animations.css .dt-stagger-container
|
||||
* CSS handles all stagger timing via nth-child — no JS animation needed.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTStaggerContainerProps {
|
||||
children: ReactNode;
|
||||
/** Duration per child animation @default '0.33s' */
|
||||
duration?: string;
|
||||
/** Delay between each child @default '75ms' */
|
||||
interval?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTStaggerContainer({
|
||||
children,
|
||||
duration,
|
||||
interval,
|
||||
className,
|
||||
style,
|
||||
}: DTStaggerContainerProps) {
|
||||
const cssVars: Record<string, string> = {};
|
||||
if (duration) cssVars['--dt-stagger-duration'] = duration;
|
||||
if (interval) cssVars['--dt-stagger-interval'] = interval;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-stagger-container', className)}
|
||||
style={{ ...cssVars, ...style } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
packages/react/src/components/DTSwitch.tsx
Normal file
44
packages/react/src/components/DTSwitch.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* DTSwitch — Angular toggle switch.
|
||||
*
|
||||
* CSS reference: forms-dt.css .dt-switch, .dt-switch-track, .dt-switch-thumb
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
}: DTSwitchProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx('dt-switch-wrapper', className)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '12px', ...style }}>
|
||||
<label className="dt-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="dt-switch-track" />
|
||||
<span className="dt-switch-thumb" />
|
||||
</label>
|
||||
{label && <span>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
packages/react/src/components/DTTextInput.tsx
Normal file
53
packages/react/src/components/DTTextInput.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* DTTextInput — Styled text input with focus glow.
|
||||
*
|
||||
* CSS reference: forms-dt.css input[type="text"], .input, .error
|
||||
*/
|
||||
|
||||
import type { CSSProperties, InputHTMLAttributes } from 'react';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTTextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'style'> {
|
||||
/** Show error state */
|
||||
error?: boolean;
|
||||
/** Label text above input */
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTTextInput({
|
||||
error = false,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
id,
|
||||
...inputProps
|
||||
}: DTTextInputProps) {
|
||||
const inputId = id || (label ? `dt-input-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
|
||||
|
||||
return (
|
||||
<div className={cx('dt-text-input-wrapper', className)} style={style}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
className={cx('input', error && 'error')}
|
||||
style={{ width: '100%', padding: '8px 12px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
packages/react/src/components/DTWebThemeProvider.tsx
Normal file
42
packages/react/src/components/DTWebThemeProvider.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* DTWebThemeProvider
|
||||
*
|
||||
* Sets data-brand and data-theme attributes on a wrapper div.
|
||||
* Provides React context for child components to read the active brand/theme.
|
||||
*/
|
||||
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
|
||||
interface DTWebThemeContextValue {
|
||||
brand: ThemeBrand;
|
||||
theme: ThemeMode;
|
||||
}
|
||||
|
||||
export const DTWebThemeContext = createContext<DTWebThemeContextValue | null>(null);
|
||||
|
||||
interface DTWebThemeProviderProps {
|
||||
/** Brand to apply @default 'dt' */
|
||||
brand?: ThemeBrand;
|
||||
/** Theme mode @default 'dark' */
|
||||
theme?: ThemeMode;
|
||||
/** Additional className for the wrapper div */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DTWebThemeProvider({
|
||||
brand = 'dt',
|
||||
theme = 'dark',
|
||||
className,
|
||||
children,
|
||||
}: DTWebThemeProviderProps) {
|
||||
return (
|
||||
<DTWebThemeContext.Provider value={{ brand, theme }}>
|
||||
<div data-brand={brand} data-theme={theme} className={cx('dt-theme-root', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</DTWebThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
14
packages/react/src/hooks/useDTWebTheme.ts
Normal file
14
packages/react/src/hooks/useDTWebTheme.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Hook to access the DTWebThemeProvider context.
|
||||
*/
|
||||
import { useContext } from 'react';
|
||||
import { DTWebThemeContext } from '../components/DTWebThemeProvider';
|
||||
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
|
||||
export function useDTWebTheme(): { brand: ThemeBrand; theme: ThemeMode } {
|
||||
const ctx = useContext(DTWebThemeContext);
|
||||
if (!ctx) {
|
||||
return { brand: 'dt', theme: 'dark' };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
24
packages/react/src/hooks/usePulse.ts
Normal file
24
packages/react/src/hooks/usePulse.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Hook: toggles dt-animate-pulse CSS class based on enabled flag.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function usePulse(
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
enabled = false,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (enabled) {
|
||||
el.classList.add('dt-animate-pulse');
|
||||
} else {
|
||||
el.classList.remove('dt-animate-pulse');
|
||||
}
|
||||
|
||||
return () => {
|
||||
el.classList.remove('dt-animate-pulse');
|
||||
};
|
||||
}, [ref, enabled]);
|
||||
}
|
||||
29
packages/react/src/hooks/useScaleIn.ts
Normal file
29
packages/react/src/hooks/useScaleIn.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Hook: applies dt-animate-scale-in CSS class on mount.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useScaleIn(
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options?: { duration?: number; delay?: number },
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (options?.duration) {
|
||||
el.style.animationDuration = `${options.duration}ms`;
|
||||
}
|
||||
if (options?.delay) {
|
||||
el.style.animationDelay = `${options.delay}ms`;
|
||||
}
|
||||
|
||||
el.classList.add('dt-animate-scale-in');
|
||||
|
||||
return () => {
|
||||
el.classList.remove('dt-animate-scale-in');
|
||||
el.style.animationDuration = '';
|
||||
el.style.animationDelay = '';
|
||||
};
|
||||
}, [ref, options?.duration, options?.delay]);
|
||||
}
|
||||
60
packages/react/src/index.ts
Normal file
60
packages/react/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @dangerousthings/react
|
||||
*
|
||||
* React web components for the Dangerous Things design system.
|
||||
* Wraps @dangerousthings/web CSS classes with React component APIs
|
||||
* matching @dangerousthings/react-native for cross-platform parity.
|
||||
*/
|
||||
|
||||
// Theme
|
||||
export { DTWebThemeProvider } from './components/DTWebThemeProvider';
|
||||
export { useDTWebTheme } from './hooks/useDTWebTheme';
|
||||
|
||||
// Types (re-exported from tokens for convenience)
|
||||
export type { DTVariant, ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
export { variantToClassName, variantToCSSProperty } from '@dangerousthings/tokens';
|
||||
|
||||
// Utilities
|
||||
export { cx } from './utils/cx';
|
||||
export { getVariantClass, featureStateToVariant, variantToBadgeClass } from './utils/variantClasses';
|
||||
|
||||
// Hooks
|
||||
export { useScaleIn } from './hooks/useScaleIn';
|
||||
export { usePulse } from './hooks/usePulse';
|
||||
|
||||
// Components — bevel & layout
|
||||
export { DTCard } from './components/DTCard';
|
||||
export { DTButton } from './components/DTButton';
|
||||
export { DTLabel } from './components/DTLabel';
|
||||
export { DTChip } from './components/DTChip';
|
||||
export { DTMediaFrame } from './components/DTMediaFrame';
|
||||
export { DTModal } from './components/DTModal';
|
||||
export { DTDrawer } from './components/DTDrawer';
|
||||
|
||||
// Components — forms
|
||||
export { DTTextInput } from './components/DTTextInput';
|
||||
export { DTCheckbox } from './components/DTCheckbox';
|
||||
export { DTSwitch } from './components/DTSwitch';
|
||||
export { DTRadioGroup } from './components/DTRadioGroup';
|
||||
export type { DTRadioOption } from './components/DTRadioGroup';
|
||||
export { DTQuantityStepper } from './components/DTQuantityStepper';
|
||||
export { DTSearchInput } from './components/DTSearchInput';
|
||||
|
||||
// Components — layout & animation
|
||||
export { DTProgressBar } from './components/DTProgressBar';
|
||||
export { DTAccordion } from './components/DTAccordion';
|
||||
export type { DTAccordionSection } from './components/DTAccordion';
|
||||
export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||
export { DTBadgeOverlay } from './components/DTBadgeOverlay';
|
||||
|
||||
// Components — filter & feature
|
||||
export { DTMenu } from './components/DTMenu';
|
||||
export type { DTMenuItem } from './components/DTMenu';
|
||||
export { DTFeatureLegend } from './components/DTFeatureLegend';
|
||||
export type { DTFeatureItem } from './components/DTFeatureLegend';
|
||||
export { DTMobileFilterOverlay } from './components/DTMobileFilterOverlay';
|
||||
|
||||
// Components — media & decorative
|
||||
export { DTGallery } from './components/DTGallery';
|
||||
export type { DTGalleryItem } from './components/DTGallery';
|
||||
export { DTHexagon } from './components/DTHexagon';
|
||||
7
packages/react/src/utils/cx.ts
Normal file
7
packages/react/src/utils/cx.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Lightweight className composition utility.
|
||||
* Filters out falsy values and joins with spaces.
|
||||
*/
|
||||
export function cx(...args: (string | false | null | undefined)[]): string {
|
||||
return args.filter(Boolean).join(' ');
|
||||
}
|
||||
31
packages/react/src/utils/variantClasses.ts
Normal file
31
packages/react/src/utils/variantClasses.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* DTVariant → CSS class name mapping
|
||||
*/
|
||||
import { variantToClassName } from '@dangerousthings/tokens';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
|
||||
export function getVariantClass(variant: DTVariant): string {
|
||||
return variantToClassName[variant];
|
||||
}
|
||||
|
||||
/** Map feature state to variant */
|
||||
export function featureStateToVariant(state: 'supported' | 'disabled' | 'unsupported'): DTVariant {
|
||||
const map: Record<typeof state, DTVariant> = {
|
||||
supported: 'normal',
|
||||
disabled: 'emphasis',
|
||||
unsupported: 'warning',
|
||||
};
|
||||
return map[state];
|
||||
}
|
||||
|
||||
/** Map variant to badge CSS class (for status badge colors) */
|
||||
export function variantToBadgeClass(variant: DTVariant): string {
|
||||
const map: Record<DTVariant, string> = {
|
||||
normal: 'badge-info',
|
||||
emphasis: 'badge-warning',
|
||||
warning: 'badge-error',
|
||||
success: 'badge-success',
|
||||
other: 'badge-info',
|
||||
};
|
||||
return map[variant];
|
||||
}
|
||||
9
packages/react/tsconfig.json
Normal file
9
packages/react/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -24,13 +24,24 @@
|
||||
"clean": "rm -rf dist release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dangerousthings/react": "*",
|
||||
"@dangerousthings/tokens": "*",
|
||||
"@dangerousthings/web": "*"
|
||||
"@dangerousthings/web": "*",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dangerousthings/tailwind-preset": "*",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"electron": "33.4.11",
|
||||
"electron-builder": "^25.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"wait-on": "^8.0.0"
|
||||
|
||||
6
packages/showcase/desktop/postcss.config.mjs
Normal file
6
packages/showcase/desktop/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
100
packages/showcase/desktop/src/renderer/App.tsx
Normal file
100
packages/showcase/desktop/src/renderer/App.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { themes } from '@dangerousthings/tokens';
|
||||
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { BevelsPage } from './pages/BevelsPage';
|
||||
import { GlowsPage } from './pages/GlowsPage';
|
||||
import { FormsPage } from './pages/FormsPage';
|
||||
import { AnimationsPage } from './pages/AnimationsPage';
|
||||
import { CardsAdvancedPage } from './pages/CardsAdvancedPage';
|
||||
import { TokensPage } from './pages/TokensPage';
|
||||
|
||||
const navPages = [
|
||||
{ hash: '', label: 'Home' },
|
||||
{ hash: 'bevels', label: 'Bevels' },
|
||||
{ hash: 'glows', label: 'Glows' },
|
||||
{ hash: 'forms', label: 'Forms' },
|
||||
{ hash: 'animations', label: 'Animations' },
|
||||
{ hash: 'cards-advanced', label: 'Advanced Cards' },
|
||||
{ hash: 'tokens', label: 'Tokens' },
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const [brand, setBrand] = useState<ThemeBrand>('dt');
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
const [currentHash, setCurrentHash] = useState('');
|
||||
|
||||
// Sync brand/theme to <html> so CSS custom properties cascade everywhere
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-brand', brand);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [brand, theme]);
|
||||
|
||||
// Hash-based routing
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
setCurrentHash(window.location.hash.replace('#/', '').replace('#', ''));
|
||||
}
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
function renderPage() {
|
||||
switch (currentHash) {
|
||||
case 'bevels': return <BevelsPage />;
|
||||
case 'glows': return <GlowsPage />;
|
||||
case 'forms': return <FormsPage />;
|
||||
case 'animations': return <AnimationsPage />;
|
||||
case 'cards-advanced': return <CardsAdvancedPage />;
|
||||
case 'tokens': return <TokensPage brand={brand} />;
|
||||
default: return <HomePage />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav id="sidebar">
|
||||
<div className="nav-title">DT DESIGN SYSTEM</div>
|
||||
<div className="brand-switcher-container">
|
||||
{themes.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`brand-btn${t.id === brand ? ' active' : ''}`}
|
||||
onClick={() => setBrand(t.id)}
|
||||
type="button"
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="mode-switcher">
|
||||
{(['dark', 'light', 'auto'] as ThemeMode[]).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`mode-btn${m === theme ? ' active' : ''}`}
|
||||
onClick={() => setTheme(m)}
|
||||
type="button"
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
{navPages.map(p => (
|
||||
<a
|
||||
key={p.hash}
|
||||
href={`#/${p.hash}`}
|
||||
className={`nav-link${p.hash === currentHash ? ' active' : ''}`}
|
||||
>
|
||||
{p.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<main id="content">
|
||||
{renderPage()}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { themes } from '@dangerousthings/tokens';
|
||||
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||
|
||||
let currentBrand: ThemeBrand = 'dt';
|
||||
let currentMode: ThemeMode = 'dark';
|
||||
|
||||
export function initBrandSwitcher(container: HTMLElement) {
|
||||
// Brand buttons
|
||||
for (const theme of themes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = theme.name;
|
||||
btn.className = 'brand-btn';
|
||||
btn.dataset.brand = theme.id;
|
||||
if (theme.id === currentBrand) btn.classList.add('active');
|
||||
btn.addEventListener('click', () => switchBrand(theme.id));
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
// Mode switcher
|
||||
const modeContainer = document.createElement('div');
|
||||
modeContainer.className = 'mode-switcher';
|
||||
|
||||
const modes: ThemeMode[] = ['dark', 'light', 'auto'];
|
||||
for (const mode of modes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = mode;
|
||||
btn.className = 'mode-btn';
|
||||
btn.dataset.mode = mode;
|
||||
if (mode === currentMode) btn.classList.add('active');
|
||||
btn.addEventListener('click', () => switchMode(mode));
|
||||
modeContainer.appendChild(btn);
|
||||
}
|
||||
|
||||
container.appendChild(modeContainer);
|
||||
|
||||
// Set initial state
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
function switchBrand(id: ThemeBrand) {
|
||||
currentBrand = id;
|
||||
applyTheme();
|
||||
updateBrandButtons();
|
||||
}
|
||||
|
||||
function switchMode(mode: ThemeMode) {
|
||||
currentMode = mode;
|
||||
applyTheme();
|
||||
updateModeButtons();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.setAttribute('data-brand', currentBrand);
|
||||
document.documentElement.setAttribute('data-theme', currentMode);
|
||||
}
|
||||
|
||||
function updateBrandButtons() {
|
||||
document.querySelectorAll('.brand-btn').forEach((btn) => {
|
||||
const el = btn as HTMLButtonElement;
|
||||
el.classList.toggle('active', el.dataset.brand === currentBrand);
|
||||
});
|
||||
}
|
||||
|
||||
function updateModeButtons() {
|
||||
document.querySelectorAll('.mode-btn').forEach((btn) => {
|
||||
const el = btn as HTMLButtonElement;
|
||||
el.classList.toggle('active', el.dataset.mode === currentMode);
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentBrand(): ThemeBrand {
|
||||
return currentBrand;
|
||||
}
|
||||
|
||||
export function getCurrentMode(): ThemeMode {
|
||||
return currentMode;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
|
||||
export function Section({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="demo-section">
|
||||
<h2>{title}</h2>
|
||||
<p className="demo-description">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||
return <div className="demo-row" style={style}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CodeLabel({ text }: { text: string }) {
|
||||
return <div className="code-label">{text}</div>;
|
||||
}
|
||||
|
||||
export function DemoLabel({ text }: { text: string }) {
|
||||
return <div className="demo-label">{text}</div>;
|
||||
}
|
||||
@@ -6,10 +6,7 @@
|
||||
<title>DT Design System — Desktop Showcase</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="sidebar"></nav>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import '@dangerousthings/web/index.css';
|
||||
import './style.css';
|
||||
|
||||
import { initRouter } from './utils/router';
|
||||
import { initBrandSwitcher } from './brand-switcher';
|
||||
import { renderHomePage } from './pages/home';
|
||||
import { renderBevelsPage } from './pages/bevels';
|
||||
import { renderGlowsPage } from './pages/glows';
|
||||
import { renderFormsPage } from './pages/forms';
|
||||
import { renderTokensPage } from './pages/tokens';
|
||||
|
||||
const routes: Record<string, (container: HTMLElement) => void> = {
|
||||
'': renderHomePage,
|
||||
'bevels': renderBevelsPage,
|
||||
'glows': renderGlowsPage,
|
||||
'forms': renderFormsPage,
|
||||
'tokens': renderTokensPage,
|
||||
};
|
||||
|
||||
const sidebar = document.getElementById('sidebar')!;
|
||||
const content = document.getElementById('content')!;
|
||||
|
||||
// Build sidebar navigation
|
||||
const navTitle = document.createElement('div');
|
||||
navTitle.className = 'nav-title';
|
||||
navTitle.textContent = 'DT DESIGN SYSTEM';
|
||||
sidebar.appendChild(navTitle);
|
||||
|
||||
const brandContainer = document.createElement('div');
|
||||
brandContainer.className = 'brand-switcher-container';
|
||||
sidebar.appendChild(brandContainer);
|
||||
initBrandSwitcher(brandContainer);
|
||||
|
||||
const navLinks = document.createElement('div');
|
||||
navLinks.className = 'nav-links';
|
||||
|
||||
const pages = [
|
||||
{ hash: '', label: 'Home' },
|
||||
{ hash: 'bevels', label: 'Bevels' },
|
||||
{ hash: 'glows', label: 'Glows' },
|
||||
{ hash: 'forms', label: 'Forms' },
|
||||
{ hash: 'tokens', label: 'Tokens' },
|
||||
];
|
||||
|
||||
for (const page of pages) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `#/${page.hash}`;
|
||||
link.textContent = page.label;
|
||||
link.className = 'nav-link';
|
||||
navLinks.appendChild(link);
|
||||
}
|
||||
|
||||
sidebar.appendChild(navLinks);
|
||||
|
||||
// Initialize router
|
||||
initRouter(routes, content);
|
||||
6
packages/showcase/desktop/src/renderer/main.tsx
Normal file
6
packages/showcase/desktop/src/renderer/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import '@dangerousthings/web/index.css';
|
||||
import './style.css';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
createRoot(document.getElementById('app')!).render(<App />);
|
||||
150
packages/showcase/desktop/src/renderer/pages/AnimationsPage.tsx
Normal file
150
packages/showcase/desktop/src/renderer/pages/AnimationsPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { DTCard, DTStaggerContainer } from '@dangerousthings/react';
|
||||
import { Section, Row, CodeLabel } from '../components/Section';
|
||||
|
||||
function AnimBox({ className, label }: { className: string; label: string }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
background: 'var(--color-primary)',
|
||||
width: 80,
|
||||
height: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-bg)',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimationsPage() {
|
||||
const [entranceKey, setEntranceKey] = useState(0);
|
||||
const [staggerKey, setStaggerKey] = useState(0);
|
||||
const [accordionExpanded, setAccordionExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Animations</h1>
|
||||
<p className="page-subtitle">Entrance animations, interactive effects, stagger containers, and transition utilities.</p>
|
||||
|
||||
<Section title="Entrance Animations" description="One-shot animations for elements entering the viewport.">
|
||||
<Row key={entranceKey}>
|
||||
<AnimBox className="dt-animate-scale-in" label="SCALE IN" />
|
||||
<AnimBox className="dt-animate-fade-in" label="FADE IN" />
|
||||
<AnimBox className="dt-animate-slide-up" label="SLIDE UP" />
|
||||
</Row>
|
||||
<button className="btn-secondary" style={{ marginTop: 'var(--space-4)' }} onClick={() => setEntranceKey(k => k + 1)} type="button">
|
||||
REPLAY
|
||||
</button>
|
||||
<CodeLabel text=".dt-animate-scale-in | .dt-animate-fade-in | .dt-animate-slide-up" />
|
||||
</Section>
|
||||
|
||||
<Section title="Interactive Animations" description="Looping animations for active/loading states.">
|
||||
<Row>
|
||||
<AnimBox className="dt-animate-pulse" label="PULSE" />
|
||||
<AnimBox className="dt-animate-ping" label="PING" />
|
||||
<AnimBox className="dt-animate-spin" label="SPIN" />
|
||||
</Row>
|
||||
<CodeLabel text=".dt-animate-pulse | .dt-animate-ping | .dt-animate-spin" />
|
||||
</Section>
|
||||
|
||||
<Section title="Stagger Container" description="Automatic staggered scale-in animation for child elements. Customizable via --dt-stagger-duration and --dt-stagger-interval.">
|
||||
<DTStaggerContainer
|
||||
key={staggerKey}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-dt-3"
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<DTCard key={i} title={`CARD ${i + 1}`} style={{ textAlign: 'center' }}>
|
||||
<div className="card-body" style={{ fontSize: '0.75rem' }}>Item #{i + 1}</div>
|
||||
</DTCard>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => setStaggerKey(k => k + 1)}
|
||||
type="button"
|
||||
>
|
||||
REPLAY STAGGER
|
||||
</button>
|
||||
<CodeLabel text="<DTStaggerContainer> — nth-child delays up to 24 children" />
|
||||
</Section>
|
||||
|
||||
<Section title="Transition Utilities" description="Accordion expand, chevron rotation, and progress bar transitions.">
|
||||
<div style={{ maxWidth: 400 }}>
|
||||
<button
|
||||
className="dt-accent-top"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text)',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
aria-expanded={accordionExpanded}
|
||||
onClick={() => setAccordionExpanded(!accordionExpanded)}
|
||||
type="button"
|
||||
>
|
||||
Click to expand
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
transition: 'transform 250ms ease-in-out',
|
||||
transform: accordionExpanded ? 'rotate(180deg)' : undefined,
|
||||
}}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div style={{
|
||||
maxHeight: accordionExpanded ? 200 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 250ms ease-in-out',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: 'var(--space-4)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderTop: 'none',
|
||||
}}>
|
||||
This content expands with a smooth max-height transition. The chevron rotates 180 degrees.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CodeLabel text=".dt-transition-accordion | .dt-transition-chevron | .dt-transition-progress" />
|
||||
</Section>
|
||||
|
||||
<Section title="Scrollbar Styling" description="Thin neon scrollbar scoped under [data-brand='dt'].">
|
||||
<div
|
||||
className="dt-scrollbar"
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: 'var(--space-3)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<div key={i} style={{ padding: '4px 0', color: 'var(--color-text)' }}>
|
||||
Scrollbar line {i + 1} — thin neon scrollbar styled with --color-primary
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-scrollbar — scrollbar-color: var(--color-primary)" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
packages/showcase/desktop/src/renderer/pages/BevelsPage.tsx
Normal file
103
packages/showcase/desktop/src/renderer/pages/BevelsPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { DTCard, DTMediaFrame } from '@dangerousthings/react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { Section, Row, CodeLabel } from '../components/Section';
|
||||
|
||||
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||
|
||||
export function BevelsPage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Bevels</h1>
|
||||
<p className="page-subtitle">Angular clip-path patterns from the DT design system. Active on the DT brand.</p>
|
||||
|
||||
<Section title="Card Bevels" description="Dual bottom bevels using clip-path. Outer bg = border color, ::before = surface fill.">
|
||||
<DTCard title="CARD TITLE">
|
||||
<div className="card-body">Dual bottom bevels — bottom-right (bevel-md) and bottom-left (bevel-sm). Uses the dual-element technique with ::before pseudo-element.</div>
|
||||
</DTCard>
|
||||
<CodeLabel text="<DTCard title='...'> or .card" />
|
||||
<DTCard>
|
||||
<div className="card-body">Card without title — no header bar, just the beveled container.</div>
|
||||
</DTCard>
|
||||
<CodeLabel text="<DTCard> (no title prop)" />
|
||||
</Section>
|
||||
|
||||
<Section title="Button Bevels" description="Top-right corner cut. Filled buttons use direct clip-path, outline uses dual-element.">
|
||||
<Row>
|
||||
<button className="btn-primary" type="button">PRIMARY</button>
|
||||
<button className="btn-secondary" type="button">SECONDARY</button>
|
||||
<button className="btn-danger" type="button">DANGER</button>
|
||||
</Row>
|
||||
<CodeLabel text=".btn-primary | .btn-secondary | .btn-danger" />
|
||||
</Section>
|
||||
|
||||
<Section title="Badge Bevels" description="Top-right bevel with dual-element technique. Color variants via CSS custom properties.">
|
||||
<Row>
|
||||
<span className="badge">Default</span>
|
||||
<span className="badge badge-success">Success</span>
|
||||
<span className="badge badge-error">Error</span>
|
||||
<span className="badge badge-warning">Warning</span>
|
||||
<span className="badge badge-info">Info</span>
|
||||
</Row>
|
||||
<CodeLabel text=".badge | .badge-success | .badge-error | .badge-warning | .badge-info" />
|
||||
</Section>
|
||||
|
||||
<Section title="Media Frame Bevels" description="Diagonal opposite corners (top-left + bottom-right).">
|
||||
<DTMediaFrame>
|
||||
<div style={{ background: 'var(--color-primary)', width: '100%', aspectRatio: '16/9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'var(--color-bg)', fontWeight: 700 }}>MEDIA FRAME</span>
|
||||
</div>
|
||||
</DTMediaFrame>
|
||||
<CodeLabel text="<DTMediaFrame> or .dt-bevel-media" />
|
||||
</Section>
|
||||
|
||||
<Section title="Modal Bevels" description="Dual bottom bevels at bevel-lg scale.">
|
||||
<div className="dt-bevel-modal" style={{ background: 'var(--color-primary)', padding: 'var(--space-8)', textAlign: 'center' }}>
|
||||
<div style={{ background: 'var(--color-surface)', padding: 'var(--space-6)' }}>
|
||||
<div style={{ fontWeight: 700, textTransform: 'uppercase' }}>Modal Content</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', marginTop: 'var(--space-2)' }}>Large dual bottom bevels</div>
|
||||
</div>
|
||||
</div>
|
||||
<CodeLabel text=".dt-bevel-modal" />
|
||||
</Section>
|
||||
|
||||
<Section title="Drawer Bevels" description="Exposed-edge bevels for sliding panels.">
|
||||
<Row>
|
||||
<div className="dt-bevel-drawer-right" style={{ background: 'var(--color-primary)', padding: 'var(--space-6)', width: 200, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'var(--color-bg)', fontWeight: 700 }}>RIGHT</span>
|
||||
</div>
|
||||
<div className="dt-bevel-drawer-left" style={{ background: 'var(--color-primary)', padding: 'var(--space-6)', width: 200, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'var(--color-bg)', fontWeight: 700 }}>LEFT</span>
|
||||
</div>
|
||||
</Row>
|
||||
<CodeLabel text=".dt-bevel-drawer-right | .dt-bevel-drawer-left" />
|
||||
</Section>
|
||||
|
||||
<Section title="Small Bevel Utility" description="For compact elements — arrows, stepper buttons.">
|
||||
<Row>
|
||||
<div className="dt-bevel-sm" style={{ background: 'var(--color-primary)', width: 60, height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'var(--color-bg)', fontWeight: 700, fontSize: '0.75rem' }}>SM</span>
|
||||
</div>
|
||||
</Row>
|
||||
<CodeLabel text=".dt-bevel-sm" />
|
||||
</Section>
|
||||
|
||||
<Section title="Accent Top" description="Used on accordion headers and menu items.">
|
||||
<div className="dt-accent-top" style={{ padding: 'var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
<span style={{ fontWeight: 600, textTransform: 'uppercase' }}>Thick Top Border Accent</span>
|
||||
</div>
|
||||
<CodeLabel text=".dt-accent-top" />
|
||||
</Section>
|
||||
|
||||
<Section title="Card Color Modes" description="Per-card mode coloring — see Advanced Cards page for full demos.">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||
{modes.map(mode => (
|
||||
<DTCard key={mode} variant={mode} title={mode.toUpperCase()}>
|
||||
<div className="card-body" style={{ fontSize: '0.75rem' }}>mode-{mode}</div>
|
||||
</DTCard>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text="<DTCard variant='normal'> | 'emphasis' | 'warning' | 'success' | 'other'" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { createElement, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
DTCard,
|
||||
DTButton,
|
||||
DTStaggerContainer,
|
||||
DTFeatureLegend,
|
||||
} from '@dangerousthings/react';
|
||||
import type { DTFeatureItem } from '@dangerousthings/react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { Section, Row, CodeLabel } from '../components/Section';
|
||||
|
||||
// Feature icons from dt-shopify-storefront
|
||||
import {
|
||||
MdOutlinePhonelinkRing,
|
||||
MdOutlineVpnKey,
|
||||
MdOutlineMobileScreenShare,
|
||||
MdOutlineCreditCard,
|
||||
MdOutlineCopyAll,
|
||||
MdOutlineLightbulb,
|
||||
MdOutlineThermostat,
|
||||
MdOutlineSensors,
|
||||
MdOutlineFitbit,
|
||||
MdOutlineVibration,
|
||||
MdOutlineExplore,
|
||||
MdLightbulbOutline,
|
||||
MdOutlineVisibility,
|
||||
} from 'react-icons/md';
|
||||
import { FaUserShield } from 'react-icons/fa';
|
||||
import { LuBinary } from 'react-icons/lu';
|
||||
|
||||
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||
|
||||
// Wrap react-icons to avoid IconType/ReactNode type mismatch
|
||||
const ico = (C: any) => createElement(C, { style: { fontSize: '2rem' } });
|
||||
|
||||
// Full chip feature legend (9 features — from storefront UseCaseLegend)
|
||||
const chipFeatures: DTFeatureItem[] = [
|
||||
{ key: 'smartphone', name: 'Smartphone', icon: ico(MdOutlinePhonelinkRing), state: 'supported' },
|
||||
{ key: 'access_control', name: 'Access Control', icon: ico(MdOutlineVpnKey), state: 'supported' },
|
||||
{ key: 'digital_security', name: 'Digital Security', icon: ico(FaUserShield), state: 'supported' },
|
||||
{ key: 'cryptography', name: 'Cryptography', icon: ico(LuBinary), state: 'unsupported' },
|
||||
{ key: 'data_sharing', name: 'Data Sharing', icon: ico(MdOutlineMobileScreenShare), state: 'supported' },
|
||||
{ key: 'payment', name: 'Payment', icon: ico(MdOutlineCreditCard), state: 'disabled' },
|
||||
{ key: 'magic', name: 'UID Magic', icon: ico(MdOutlineCopyAll), state: 'supported' },
|
||||
{ key: 'illumination', name: 'Illumination', icon: ico(MdOutlineLightbulb), state: 'unsupported' },
|
||||
{ key: 'temperature', name: 'Temperature', icon: ico(MdOutlineThermostat), state: 'unsupported' },
|
||||
];
|
||||
|
||||
// Full biomagnet feature legend (4 features — from storefront MagnetUseCaseLegend)
|
||||
const magnetFeatures: DTFeatureItem[] = [
|
||||
{ key: 'sensing', name: 'Sensing', icon: ico(MdOutlineSensors), state: 'supported' },
|
||||
{ key: 'lifting', name: 'Lifting', icon: ico(MdOutlineFitbit), state: 'supported' },
|
||||
{ key: 'haptics', name: 'Haptics', icon: ico(MdOutlineVibration), state: 'supported' },
|
||||
{ key: 'polarity', name: 'Polarity Detection', icon: ico(MdOutlineExplore), state: 'unsupported' },
|
||||
];
|
||||
|
||||
// Full aesthetic feature legend (2 features — from storefront AestheticUseCaseLegend)
|
||||
const aestheticFeatures: DTFeatureItem[] = [
|
||||
{ key: 'illumination', name: 'Illumination', icon: ico(MdLightbulbOutline), state: 'supported' },
|
||||
{ key: 'prominence', name: 'Prominence', icon: ico(MdOutlineVisibility), state: 'supported' },
|
||||
];
|
||||
|
||||
export function CardsAdvancedPage() {
|
||||
const [staggerKey, setStaggerKey] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Advanced Cards</h1>
|
||||
<p className="page-subtitle">Card color modes, progress bars, badge overlays, and interactive bevel buttons.</p>
|
||||
|
||||
<Section title="Card Color Modes" description="Per-card color via variant prop. Sets --dt-card-color, glow color, and accent.">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||
{modes.map(mode => (
|
||||
<DTCard key={mode} variant={mode} title={mode.toUpperCase()}>
|
||||
<div className="card-body" style={{ fontSize: '0.8rem' }}>mode-{mode}</div>
|
||||
</DTCard>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text="<DTCard variant='normal'> | 'emphasis' | 'warning' | 'success' | 'other'" />
|
||||
</Section>
|
||||
|
||||
|
||||
<Section title="Card Progress Bar" description="Vertical left-edge bar driven by progress prop (0-100).">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||
{[0, 25, 50, 75, 100].map((val, i) => (
|
||||
<DTCard key={val} variant={modes[i % modes.length]} title={`${val}%`} progress={val}>
|
||||
<div className="card-body" style={{ fontSize: '0.8rem' }}>Progress: {val}%</div>
|
||||
</DTCard>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text="<DTCard progress={50} /> — height driven by --dt-card-progress" />
|
||||
</Section>
|
||||
|
||||
<Section title="Card Badges" description="Bottom-right chip badge on product image area. Inherits card mode color.">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-dt-4">
|
||||
{[
|
||||
{ label: 'LAB', mode: 'warning' as DTVariant, img: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400&h=400&fit=crop' },
|
||||
{ label: 'BUNDLE', mode: 'other' as DTVariant, img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?w=400&h=400&fit=crop' },
|
||||
{ label: 'NFC', mode: 'normal' as DTVariant, img: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400&h=400&fit=crop' },
|
||||
].map(badge => (
|
||||
<DTCard key={badge.label} variant={badge.mode} title={badge.label + ' PRODUCT'}>
|
||||
<div className="card-body-flush dt-badge-parent" style={{ aspectRatio: '1' }}>
|
||||
<img src={badge.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
<span className="dt-card-badge">{badge.label}</span>
|
||||
</div>
|
||||
</DTCard>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-badge-parent > .dt-card-badge — badge positioned on image container" />
|
||||
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', margin: 'var(--space-4) 0 var(--space-2)' }}>
|
||||
Also works on beveled media frames:
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-dt-4">
|
||||
{[
|
||||
{ label: 'LAB', mode: 'warning' as DTVariant, img: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400&h=400&fit=crop' },
|
||||
{ label: 'BUNDLE', mode: 'other' as DTVariant, img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?w=400&h=400&fit=crop' },
|
||||
{ label: 'NFC', mode: 'normal' as DTVariant, img: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400&h=400&fit=crop' },
|
||||
].map(badge => (
|
||||
<div key={badge.label} className={`dt-bevel-media mode-${badge.mode}`} style={{ aspectRatio: '1', overflow: 'hidden' }}>
|
||||
<img src={badge.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<span className="dt-card-badge">{badge.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-bevel-media.mode-warning > .dt-card-badge" />
|
||||
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', margin: 'var(--space-4) 0 var(--space-2)' }}>
|
||||
Missing image placeholder (no img element):
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-dt-4">
|
||||
{(['warning', 'other', 'normal'] as DTVariant[]).map(mode => (
|
||||
<div key={mode} className={`dt-bevel-media mode-${mode}`} style={{ aspectRatio: '1' }} />
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-bevel-media without child img — shows 'MISSING MEDIA' placeholder" />
|
||||
</Section>
|
||||
|
||||
<Section title="Interactive Bevel Buttons" description="Outlined rectangle by default. Bottom-right bevel appears on hover/select, fills with mode color.">
|
||||
<Row>
|
||||
{modes.map(mode => (
|
||||
<DTButton key={mode} variant={mode}>{mode.toUpperCase()}</DTButton>
|
||||
))}
|
||||
</Row>
|
||||
<CodeLabel text="<DTButton variant='normal'> — hover for bevel + fill" />
|
||||
<Row style={{ marginTop: 'var(--space-4)' }}>
|
||||
{modes.map(mode => (
|
||||
<DTButton key={mode} variant={mode} selected>{mode.toUpperCase()} SEL</DTButton>
|
||||
))}
|
||||
</Row>
|
||||
<CodeLabel text="<DTButton variant='emphasis' selected /> — persistent bevel + fill" />
|
||||
</Section>
|
||||
|
||||
<Section title="Menu Items" description="Filter-style beveled menu items with active/selected states and level indentation.">
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
{['All Products', 'NFC Implants', 'RFID Tags', 'Accessories', 'Lab Products'].map((item, i) => (
|
||||
<button
|
||||
key={item}
|
||||
className={`dt-menu-item${i === 0 ? ' active' : ''}${i === 4 ? ' mode-warning' : ''}`}
|
||||
style={i > 1 && i < 4 ? { '--dt-menu-level': '1' } as CSSProperties : undefined}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-menu-item | .dt-menu-item.active | --dt-menu-level: 1" />
|
||||
</Section>
|
||||
|
||||
<Section title="Feature Legends" description="Product feature grids from the storefront. Icons indicate feature capabilities; color indicates state.">
|
||||
<DTFeatureLegend features={chipFeatures} title="NFC CHIP FEATURES" variant="normal" columns={5} />
|
||||
<div style={{ height: 'var(--space-6)' }} />
|
||||
<DTFeatureLegend features={magnetFeatures} title="BIOMAGNET FEATURES" variant="emphasis" columns={4} />
|
||||
<div style={{ height: 'var(--space-6)' }} />
|
||||
<DTFeatureLegend features={aestheticFeatures} title="AESTHETIC FEATURES" variant="other" columns={2} />
|
||||
<div style={{ height: 'var(--space-4)' }} />
|
||||
<CodeLabel text="<DTFeatureLegend features={chipFeatures} title='NFC CHIP FEATURES' variant='normal' />" />
|
||||
<CodeLabel text="<DTFeatureLegend features={magnetFeatures} variant='emphasis' columns={4} />" />
|
||||
<CodeLabel text="<DTFeatureLegend features={aestheticFeatures} variant='other' columns={2} />" />
|
||||
<div style={{ marginTop: 'var(--space-3)', display: 'flex', gap: 'var(--space-4)', fontSize: '0.75rem' }}>
|
||||
<span><span style={{ color: 'var(--mode-normal)' }}>●</span> Supported</span>
|
||||
<span><span style={{ color: 'var(--mode-emphasis)' }}>●</span> Disabled</span>
|
||||
<span><span style={{ color: 'var(--mode-warning)' }}>●</span> Unsupported</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Stagger + Modes" description="Mode-colored cards in a stagger container.">
|
||||
<DTStaggerContainer
|
||||
key={staggerKey}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3"
|
||||
>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<DTCard key={i} variant={modes[i % modes.length]} title={modes[i % modes.length].toUpperCase()} style={{ textAlign: 'center' }}>
|
||||
{null}
|
||||
</DTCard>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => setStaggerKey(k => k + 1)}
|
||||
type="button"
|
||||
>
|
||||
REPLAY
|
||||
</button>
|
||||
<CodeLabel text="<DTStaggerContainer> with <DTCard variant='...' />" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
packages/showcase/desktop/src/renderer/pages/FormsPage.tsx
Normal file
122
packages/showcase/desktop/src/renderer/pages/FormsPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DTTextInput,
|
||||
DTCheckbox,
|
||||
DTSwitch,
|
||||
DTRadioGroup,
|
||||
DTProgressBar,
|
||||
DTAccordion,
|
||||
DTQuantityStepper,
|
||||
} from '@dangerousthings/react';
|
||||
import type { DTAccordionSection } from '@dangerousthings/react';
|
||||
import { Section, Row, CodeLabel, DemoLabel } from '../components/Section';
|
||||
|
||||
export function FormsPage() {
|
||||
const [checks, setChecks] = useState({ a: false, b: true, c: true, d: false });
|
||||
const [switches, setSwitches] = useState({ a: false, b: true, c: true, d: false });
|
||||
const [radioValue, setRadioValue] = useState('nfc');
|
||||
const [stepperValue, setStepperValue] = useState(1);
|
||||
|
||||
const accordionSections: DTAccordionSection[] = [
|
||||
{ key: 'size', title: 'Size', children: <div style={{ padding: 'var(--space-4)' }}>Small, Medium, Large options available.</div> },
|
||||
{ key: 'chip', title: 'Chip Type', children: <div style={{ padding: 'var(--space-4)' }}>NTAG, DESFire, MIFARE Classic.</div> },
|
||||
{ key: 'freq', title: 'Frequency', children: <div style={{ padding: 'var(--space-4)' }}>13.56 MHz (HF), 125 kHz (LF).</div> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Forms</h1>
|
||||
<p className="page-subtitle">DT-branded form components using @dangerousthings/react.</p>
|
||||
|
||||
<Section title="Text Input" description="Sharp corners + focus glow bar. 2px solid border, no border-radius.">
|
||||
<DTTextInput placeholder="Normal input — click to focus" style={{ maxWidth: 400 }} />
|
||||
<CodeLabel text="<DTTextInput /> — focus glow bar" />
|
||||
<br />
|
||||
<DTTextInput error placeholder="Error state input" style={{ maxWidth: 400 }} />
|
||||
<CodeLabel text="<DTTextInput error /> — red focus bar" />
|
||||
<br />
|
||||
<textarea placeholder="Textarea element" style={{ maxWidth: 400, minHeight: 80 }} />
|
||||
<CodeLabel text="textarea — same styling" />
|
||||
</Section>
|
||||
|
||||
<Section title="Checkbox" description="Beveled hexagon shape via clip-path. 30% corner cuts.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<DTCheckbox checked={checks.a} onChange={v => setChecks(p => ({ ...p, a: v }))} label="Unchecked checkbox" />
|
||||
<DTCheckbox checked={checks.b} onChange={v => setChecks(p => ({ ...p, b: v }))} label="Checked checkbox" />
|
||||
<DTCheckbox checked={checks.c} onChange={() => {}} disabled label="Disabled checked" />
|
||||
<DTCheckbox checked={checks.d} onChange={() => {}} disabled label="Disabled unchecked" />
|
||||
</div>
|
||||
<CodeLabel text="<DTCheckbox checked onChange label />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Switch / Toggle" description="Angular track + sliding square thumb. 48x26 track, 20x20 thumb.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<DTSwitch checked={switches.a} onChange={v => setSwitches(p => ({ ...p, a: v }))} label="Off" />
|
||||
<DTSwitch checked={switches.b} onChange={v => setSwitches(p => ({ ...p, b: v }))} label="On" />
|
||||
<DTSwitch checked={switches.c} onChange={() => {}} disabled label="Disabled (on)" />
|
||||
<DTSwitch checked={switches.d} onChange={() => {}} disabled label="Disabled (off)" />
|
||||
</div>
|
||||
<CodeLabel text="<DTSwitch checked onChange label />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Radio Button" description="Hexagonal indicator via clip-path. Flat-top hexagon shape.">
|
||||
<DTRadioGroup
|
||||
options={[
|
||||
{ value: 'nfc', label: 'NFC (Near Field Communication)' },
|
||||
{ value: 'rfid', label: 'RFID (Radio Frequency ID)' },
|
||||
{ value: 'ble', label: 'BLE (Bluetooth Low Energy)' },
|
||||
]}
|
||||
value={radioValue}
|
||||
onChange={setRadioValue}
|
||||
/>
|
||||
<CodeLabel text="<DTRadioGroup options value onChange />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Progress Bar" description="Angular, no border-radius. 4px default height.">
|
||||
<DemoLabel text="25%" />
|
||||
<DTProgressBar value={0.25} />
|
||||
<DemoLabel text="50% WITH LABEL" />
|
||||
<DTProgressBar value={0.5} label="50%" />
|
||||
<DemoLabel text="75%" />
|
||||
<DTProgressBar value={0.75} />
|
||||
<DemoLabel text="100%" />
|
||||
<DTProgressBar value={1.0} label="100%" />
|
||||
<CodeLabel text="<DTProgressBar value={0.5} label='50%' />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Accordion" description="Click headers to expand. 5px top border, chevron rotation.">
|
||||
<DTAccordion sections={accordionSections} />
|
||||
<CodeLabel text="<DTAccordion sections={[{ key, title, children }]} />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Quantity Stepper" description="Beveled +/- buttons with center display.">
|
||||
<DTQuantityStepper value={stepperValue} onChange={setStepperValue} min={0} max={10} />
|
||||
<CodeLabel text="<DTQuantityStepper value onChange min max />" />
|
||||
</Section>
|
||||
|
||||
<Section title="Filter Header" description="Thick top border accent. Active state switches to secondary color.">
|
||||
<div style={{ maxWidth: 400 }}>
|
||||
<button className="dt-filter-header" type="button">FILTER CATEGORY</button>
|
||||
<button className="dt-filter-header active" type="button">ACTIVE FILTER</button>
|
||||
</div>
|
||||
<CodeLabel text=".dt-filter-header | .dt-filter-header.active" />
|
||||
</Section>
|
||||
|
||||
<Section title="Menu Items" description="Beveled filter menu items with active state and level indentation.">
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
{['All Products', 'NFC Implants', 'RFID Tags', 'Accessories', 'Lab Products'].map((name, i) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`dt-menu-item${i === 0 ? ' active' : ''}${i === 4 ? ' mode-warning' : ''}`}
|
||||
style={(i === 2 || i === 3) ? { '--dt-menu-level': '1' } as React.CSSProperties : undefined}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CodeLabel text=".dt-menu-item | .active | --dt-menu-level: 1" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
packages/showcase/desktop/src/renderer/pages/GlowsPage.tsx
Normal file
81
packages/showcase/desktop/src/renderer/pages/GlowsPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { DTCard } from '@dangerousthings/react';
|
||||
import { Section, Row, CodeLabel } from '../components/Section';
|
||||
|
||||
export function GlowsPage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Glows</h1>
|
||||
<p className="page-subtitle">Neon drop-shadow and text-shadow effects. Active on the DT brand.</p>
|
||||
|
||||
<Section title="Button Glows" description="filter: drop-shadow() follows clip-path shape. Hover for enhanced glow.">
|
||||
<Row>
|
||||
<button className="btn-primary" type="button">PRIMARY GLOW</button>
|
||||
<button className="btn-danger" type="button">DANGER GLOW</button>
|
||||
</Row>
|
||||
<CodeLabel text={'.btn-primary / .btn-danger — automatic on [data-brand="dt"]'} />
|
||||
<br />
|
||||
<Row>
|
||||
<button className="btn-secondary" type="button">SECONDARY (HOVER)</button>
|
||||
</Row>
|
||||
<CodeLabel text=".btn-secondary — glow on hover" />
|
||||
</Section>
|
||||
|
||||
<Section title="Link Glow" description="text-shadow on hover. Respects --dt-glow-color when set by a parent mode.">
|
||||
<Row>
|
||||
<a href="#" style={{ color: 'var(--color-primary)', fontWeight: 600, fontSize: '1.125rem' }}>
|
||||
Hover this link for text glow
|
||||
</a>
|
||||
</Row>
|
||||
<CodeLabel text="a:hover — uses var(--dt-glow-color, var(--color-primary))" />
|
||||
</Section>
|
||||
|
||||
<Section title="Mode-Aware Link Glow" description="Links inside mode containers glow with that mode's color.">
|
||||
<Row>
|
||||
{(['normal', 'emphasis', 'warning', 'success', 'other'] as const).map(mode => (
|
||||
<div key={mode} className={`mode-${mode}`} style={{ padding: 'var(--space-3)' }}>
|
||||
<a href="#" style={{ fontWeight: 600, fontSize: '1rem' }}>{mode.toUpperCase()}</a>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
<CodeLabel text=".mode-emphasis a:hover — glows yellow via --dt-glow-color" />
|
||||
</Section>
|
||||
|
||||
<Section title="Terminal Inset Glow" description="Inset + outer box-shadow. No clip-path so box-shadow works directly.">
|
||||
<div className="terminal dt-accent-top">
|
||||
<code>{'$ dt-web-theme --install\n> Installing design tokens...\n> Applying cyberpunk aesthetic...\n> Done. Welcome to the future.'}</code>
|
||||
</div>
|
||||
<CodeLabel text={'.terminal — automatic on [data-brand="dt"]'} />
|
||||
</Section>
|
||||
|
||||
<Section title="Card Hover Glow" description="filter: drop-shadow() respects the beveled clip-path.">
|
||||
<DTCard title="HOVER ME" style={{ cursor: 'pointer' }}>
|
||||
<div className="card-body">Cards get a drop-shadow glow on hover.</div>
|
||||
</DTCard>
|
||||
<CodeLabel text={'.card:hover — automatic on [data-brand="dt"]'} />
|
||||
</Section>
|
||||
|
||||
<Section title="Input Focus Glow" description="box-shadow: 0 4px 0 1px — bright bar beneath the input.">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Click to focus — see the glow bar"
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
<CodeLabel text={'.input:focus — automatic on [data-brand="dt"]'} />
|
||||
</Section>
|
||||
|
||||
<Section title="Glow Utilities" description="Generic utility classes for applying glow effects to any element.">
|
||||
<Row>
|
||||
<div className="dt-glow" style={{ background: 'var(--color-primary)', padding: 'var(--space-4) var(--space-6)', display: 'inline-block', fontWeight: 700, color: 'var(--color-bg)' }}>.dt-glow</div>
|
||||
<div className="dt-glow-strong" style={{ background: 'var(--color-primary)', padding: 'var(--space-4) var(--space-6)', display: 'inline-block', fontWeight: 700, color: 'var(--color-bg)' }}>.dt-glow-strong</div>
|
||||
</Row>
|
||||
<CodeLabel text=".dt-glow | .dt-glow-strong" />
|
||||
<Row>
|
||||
<div className="dt-glow-inset" style={{ padding: 'var(--space-4) var(--space-6)', display: 'inline-block', fontWeight: 700, border: '1px solid var(--color-border)' }}>.dt-glow-inset</div>
|
||||
<div className="dt-text-glow" style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)', display: 'inline-block' }}>.dt-text-glow</div>
|
||||
</Row>
|
||||
<CodeLabel text=".dt-glow-inset | .dt-text-glow" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
packages/showcase/desktop/src/renderer/pages/HomePage.tsx
Normal file
103
packages/showcase/desktop/src/renderer/pages/HomePage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { DTCard, DTStaggerContainer } from '@dangerousthings/react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { Section, CodeLabel } from '../components/Section';
|
||||
|
||||
const categories: { hash: string; title: string; desc: string; mode: DTVariant; count: number }[] = [
|
||||
{ hash: 'bevels', title: 'Bevels', desc: 'Angular clip-path patterns — cards, buttons, badges, media frames, modals, drawers', mode: 'normal', count: 8 },
|
||||
{ hash: 'glows', title: 'Glows', desc: 'Neon drop-shadow and text-shadow effects for the DT brand', mode: 'emphasis', count: 6 },
|
||||
{ hash: 'forms', title: 'Forms', desc: 'Text inputs, checkboxes, switches, radios, progress bars, accordions, steppers', mode: 'success', count: 7 },
|
||||
{ hash: 'animations', title: 'Animations', desc: 'Entrance animations, stagger containers, transition utilities, scrollbar styling', mode: 'other', count: 5 },
|
||||
{ hash: 'cards-advanced', title: 'Advanced Cards', desc: 'Card color modes, progress bars, badge overlays, interactive bevel buttons, feature legend', mode: 'warning', count: 6 },
|
||||
{ hash: 'tokens', title: 'Tokens', desc: 'Color palette, typography, spacing, and shape values for the active brand', mode: 'normal', count: 3 },
|
||||
];
|
||||
|
||||
function HexDecor({ color, size = 14, opacity = 0.5 }: { color: string; size?: number; opacity?: number }) {
|
||||
return (
|
||||
<svg width={size * 2} height={size * 2} viewBox="0 0 28 28" style={{ opacity }}>
|
||||
<polygon
|
||||
points="14,1 25.5,7.5 25.5,20.5 14,27 2.5,20.5 2.5,7.5"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: 'var(--space-8)' }}>
|
||||
<h1 style={{
|
||||
color: 'var(--color-primary)',
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 900,
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '0.25rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
DANGEROUS THINGS
|
||||
</h1>
|
||||
<p style={{
|
||||
color: 'var(--mode-emphasis, var(--color-secondary))',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.2em',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 0,
|
||||
}}>
|
||||
DESIGN SYSTEM
|
||||
</p>
|
||||
<p style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.8rem',
|
||||
marginTop: 'var(--space-2)',
|
||||
}}>
|
||||
React component showcase — switch brands and modes in the sidebar
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginTop: 'var(--space-6)',
|
||||
}}>
|
||||
<HexDecor color="var(--mode-normal, var(--color-primary))" opacity={0.4} />
|
||||
<HexDecor color="var(--mode-emphasis, var(--color-secondary))" opacity={0.6} />
|
||||
<HexDecor color="var(--mode-warning, var(--color-error))" opacity={0.4} />
|
||||
<HexDecor color="var(--mode-success, var(--color-accent))" opacity={0.6} />
|
||||
<HexDecor color="var(--mode-other, var(--color-other))" opacity={0.4} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 'var(--space-4)',
|
||||
}}>
|
||||
COMPONENT CATALOG
|
||||
</p>
|
||||
|
||||
<DTStaggerContainer className="grid grid-cols-1 md:grid-cols-2 gap-dt-4">
|
||||
{categories.map(cat => (
|
||||
<a key={cat.hash} href={`#/${cat.hash}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<DTCard variant={cat.mode} title={cat.title.toUpperCase()}>
|
||||
<div className="card-body">{cat.desc}</div>
|
||||
{cat.count > 0 && (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', marginTop: 'var(--space-2)' }}>
|
||||
{cat.count} components
|
||||
</div>
|
||||
)}
|
||||
</DTCard>
|
||||
</a>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
|
||||
<Section title="Quick Start" description="Import the React components and CSS to get started.">
|
||||
<div className="terminal dt-accent-top">
|
||||
<code>{`import { DTCard, DTButton } from '@dangerousthings/react';\nimport '@dangerousthings/web/index.css';`}</code>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
packages/showcase/desktop/src/renderer/pages/TokensPage.tsx
Normal file
99
packages/showcase/desktop/src/renderer/pages/TokensPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { brands } from '@dangerousthings/tokens';
|
||||
import type { ThemeBrand } from '@dangerousthings/tokens';
|
||||
import { Section, CodeLabel } from '../components/Section';
|
||||
|
||||
interface TokensPageProps {
|
||||
brand: ThemeBrand;
|
||||
}
|
||||
|
||||
export function TokensPage({ brand: brandId }: TokensPageProps) {
|
||||
const brand = brands[brandId];
|
||||
if (!brand) return null;
|
||||
|
||||
const darkColors = brand.dark;
|
||||
const darkEntries = Object.entries(darkColors) as [string, string][];
|
||||
const lightColors = brand.light;
|
||||
const lightEntries = Object.entries(lightColors) as [string, string][];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Tokens</h1>
|
||||
<p className="page-subtitle">Design token values for the active brand. Switch brands in the sidebar.</p>
|
||||
|
||||
<Section title={`Color Palette — ${brand.name} (Dark Mode)`} description={brand.description}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||
{darkEntries.map(([name, hex]) => (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<div style={{ width: 32, height: 32, background: hex, border: '1px solid rgba(255,255,255,0.15)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.8125rem' }}>{name}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.6875rem', opacity: 0.5 }}>{hex}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={`Color Palette — ${brand.name} (Light Mode)`} description="Light mode color tokens.">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||
{lightEntries.map(([name, hex]) => (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<div style={{ width: 32, height: 32, background: hex, border: '1px solid rgba(255,255,255,0.15)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.8125rem' }}>{name}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.6875rem', opacity: 0.5 }}>{hex}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Typography" description="Font family tokens for headings, body text, and monospace.">
|
||||
<div className="terminal" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<code>{`heading: ${brand.typography.heading}\nbody: ${brand.typography.body}\nmono: ${brand.typography.mono}`}</code>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Typography Scale" description="Live rendering with the current brand's font families.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '2rem', fontWeight: 900 }}>Heading 1</h1>
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 700 }}>Heading 2</h2>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.25rem', fontWeight: 600 }}>Heading 3</h3>
|
||||
<p style={{ fontFamily: 'var(--font-body)', fontSize: '1rem' }}>Body text — the quick brown fox jumps over the lazy dog</p>
|
||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>{'const monospace = "code";'}</code>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Shape Tokens" description="Bevel and radius values determine the visual language — angular (DT) vs rounded (Classic).">
|
||||
<div className="terminal" style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<code>{`bevelSm: ${brand.shape.bevelSm}\nbevelMd: ${brand.shape.bevelMd}\nbevelLg: ${brand.shape.bevelLg}\nradiusSm: ${brand.shape.radiusSm}\nradius: ${brand.shape.radius}\nradiusLg: ${brand.shape.radiusLg}`}</code>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Spacing Scale" description="Shared spacing tokens across all brands.">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{[
|
||||
{ name: '--space-1', value: '0.25rem (4px)', width: '1.5rem' },
|
||||
{ name: '--space-2', value: '0.5rem (8px)', width: '3rem' },
|
||||
{ name: '--space-3', value: '0.75rem (12px)', width: '4.5rem' },
|
||||
{ name: '--space-4', value: '1rem (16px)', width: '6rem' },
|
||||
{ name: '--space-6', value: '1.5rem (24px)', width: '9rem' },
|
||||
{ name: '--space-8', value: '2rem (32px)', width: '12rem' },
|
||||
].map(sp => (
|
||||
<div key={sp.name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<div style={{ width: sp.width, height: 12, background: 'var(--color-primary)', opacity: 0.6 }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', opacity: 0.6 }}>{sp.name}: {sp.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="CSS Custom Properties" description="These are generated from TypeScript tokens and applied per-brand via data-brand attribute.">
|
||||
<div className="terminal">
|
||||
<code>{`/* Generated CSS custom properties */\n--color-bg: ${darkColors.bg};\n--color-primary: ${darkColors.primary};\n--color-secondary: ${darkColors.secondary};\n--color-error: ${darkColors.error};\n--color-success: ${darkColors.success};\n--bevel-sm: ${brand.shape.bevelSm};\n--bevel-md: ${brand.shape.bevelMd};\n--font-heading: ${brand.typography.heading};`}</code>
|
||||
</div>
|
||||
<CodeLabel text="See packages/tokens/src/scripts/generate-css.ts" />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { el, section, row, label, codeLabel } from '../utils/dom';
|
||||
|
||||
export function renderBevelsPage(container: HTMLElement) {
|
||||
container.appendChild(el('h1', { className: 'page-title' }, 'Bevels'));
|
||||
container.appendChild(el('p', { className: 'page-subtitle' }, 'Angular clip-path patterns from the DT design system. Active on the DT brand.'));
|
||||
|
||||
// Card Bevels
|
||||
const card1 = el('div', { className: 'card', style: 'padding: var(--space-6);' });
|
||||
card1.appendChild(el('div', { className: 'card-title' }, 'CARD TITLE'));
|
||||
card1.appendChild(el('div', { className: 'card-body' }, 'Dual bottom bevels — bottom-right (bevel-md) and bottom-left (bevel-sm). Uses the dual-element technique with ::before pseudo-element.'));
|
||||
const card2 = el('div', { className: 'card', style: 'padding: var(--space-6);' });
|
||||
card2.appendChild(el('div', { className: 'card-body' }, 'Card without title — no header bar, just the beveled container.'));
|
||||
|
||||
container.appendChild(
|
||||
section('Card Bevels', 'Dual bottom bevels using clip-path. Outer bg = border color, ::before = surface fill.',
|
||||
card1,
|
||||
codeLabel('.card or .dt-bevel-card'),
|
||||
card2,
|
||||
codeLabel('.card (no .card-title child)'),
|
||||
),
|
||||
);
|
||||
|
||||
// Button Bevels
|
||||
container.appendChild(
|
||||
section('Button Bevels', 'Top-right corner cut. Filled buttons use direct clip-path, outline uses dual-element.',
|
||||
row(
|
||||
el('button', { className: 'btn-primary' }, 'PRIMARY'),
|
||||
el('button', { className: 'btn-secondary' }, 'SECONDARY'),
|
||||
el('button', { className: 'btn-danger' }, 'DANGER'),
|
||||
),
|
||||
codeLabel('.btn-primary | .btn-secondary | .btn-danger'),
|
||||
),
|
||||
);
|
||||
|
||||
// Badge Bevels
|
||||
container.appendChild(
|
||||
section('Badge Bevels', 'Top-right bevel with dual-element technique. Color variants via CSS custom properties.',
|
||||
row(
|
||||
el('span', { className: 'badge' }, 'Default'),
|
||||
el('span', { className: 'badge badge-success' }, 'Success'),
|
||||
el('span', { className: 'badge badge-error' }, 'Error'),
|
||||
el('span', { className: 'badge badge-warning' }, 'Warning'),
|
||||
el('span', { className: 'badge badge-info' }, 'Info'),
|
||||
),
|
||||
codeLabel('.badge | .badge-success | .badge-error | .badge-warning | .badge-info'),
|
||||
),
|
||||
);
|
||||
|
||||
// Media Frame
|
||||
const mediaFrame = el('div', {
|
||||
className: 'dt-bevel-media',
|
||||
style: 'background: var(--color-primary); width: 100%; aspect-ratio: 16/9; display: flex; align-items: center; justify-content: center;',
|
||||
});
|
||||
mediaFrame.appendChild(el('span', { style: 'color: var(--color-bg); font-weight: 700;' }, 'MEDIA FRAME'));
|
||||
|
||||
container.appendChild(
|
||||
section('Media Frame Bevels', 'Diagonal opposite corners (top-left + bottom-right).',
|
||||
mediaFrame,
|
||||
codeLabel('.dt-bevel-media'),
|
||||
),
|
||||
);
|
||||
|
||||
// Modal Bevel
|
||||
const modalDemo = el('div', {
|
||||
className: 'dt-bevel-modal',
|
||||
style: 'background: var(--color-primary); padding: var(--space-8); text-align: center;',
|
||||
});
|
||||
const modalInner = el('div', { style: 'background: var(--color-surface); padding: var(--space-6);' });
|
||||
modalInner.appendChild(el('div', { style: 'font-weight: 700; text-transform: uppercase;' }, 'Modal Content'));
|
||||
modalInner.appendChild(el('div', { style: 'color: var(--color-text-muted); margin-top: var(--space-2);' }, 'Large dual bottom bevels'));
|
||||
modalDemo.appendChild(modalInner);
|
||||
|
||||
container.appendChild(
|
||||
section('Modal Bevels', 'Dual bottom bevels at bevel-lg scale.',
|
||||
modalDemo,
|
||||
codeLabel('.dt-bevel-modal'),
|
||||
),
|
||||
);
|
||||
|
||||
// Drawer Bevels
|
||||
const drawerRight = el('div', {
|
||||
className: 'dt-bevel-drawer-right',
|
||||
style: 'background: var(--color-primary); padding: var(--space-6); width: 200px; height: 120px; display: flex; align-items: center; justify-content: center;',
|
||||
});
|
||||
drawerRight.appendChild(el('span', { style: 'color: var(--color-bg); font-weight: 700;' }, 'RIGHT'));
|
||||
const drawerLeft = el('div', {
|
||||
className: 'dt-bevel-drawer-left',
|
||||
style: 'background: var(--color-primary); padding: var(--space-6); width: 200px; height: 120px; display: flex; align-items: center; justify-content: center;',
|
||||
});
|
||||
drawerLeft.appendChild(el('span', { style: 'color: var(--color-bg); font-weight: 700;' }, 'LEFT'));
|
||||
|
||||
container.appendChild(
|
||||
section('Drawer Bevels', 'Exposed-edge bevels for sliding panels.',
|
||||
row(drawerRight, drawerLeft),
|
||||
codeLabel('.dt-bevel-drawer-right | .dt-bevel-drawer-left'),
|
||||
),
|
||||
);
|
||||
|
||||
// Small Bevel Utility
|
||||
const smallBevel = el('div', {
|
||||
className: 'dt-bevel-sm',
|
||||
style: 'background: var(--color-primary); width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;',
|
||||
});
|
||||
smallBevel.appendChild(el('span', { style: 'color: var(--color-bg); font-weight: 700; font-size: 0.75rem;' }, 'SM'));
|
||||
|
||||
container.appendChild(
|
||||
section('Small Bevel Utility', 'For compact elements — arrows, stepper buttons.',
|
||||
row(smallBevel),
|
||||
codeLabel('.dt-bevel-sm'),
|
||||
),
|
||||
);
|
||||
|
||||
// Accent Top
|
||||
const accentDemo = el('div', {
|
||||
className: 'dt-accent-top',
|
||||
style: 'padding: var(--space-4); background: var(--color-surface); border: 1px solid var(--color-border);',
|
||||
});
|
||||
accentDemo.appendChild(el('span', { style: 'font-weight: 600; text-transform: uppercase;' }, 'Thick Top Border Accent'));
|
||||
|
||||
container.appendChild(
|
||||
section('Accent Top', 'Used on accordion headers and menu items.',
|
||||
accentDemo,
|
||||
codeLabel('.dt-accent-top'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { el, section, row, label, codeLabel } from '../utils/dom';
|
||||
|
||||
export function renderFormsPage(container: HTMLElement) {
|
||||
container.appendChild(el('h1', { className: 'page-title' }, 'Forms'));
|
||||
container.appendChild(el('p', { className: 'page-subtitle' }, 'DT-branded form components. Angular styling with focus effects.'));
|
||||
|
||||
// Text Inputs
|
||||
const normalInput = el('input', { type: 'text', placeholder: 'Normal input — click to focus', style: 'max-width: 400px;' }) as HTMLInputElement;
|
||||
const errorInput = el('input', { type: 'text', className: 'error', placeholder: 'Error state input', style: 'max-width: 400px;' }) as HTMLInputElement;
|
||||
const textarea = el('textarea', { placeholder: 'Textarea element', style: 'max-width: 400px; min-height: 80px;' }) as HTMLTextAreaElement;
|
||||
|
||||
container.appendChild(
|
||||
section('Text Input', 'Sharp corners + focus glow bar. 2px solid border, no border-radius.',
|
||||
normalInput,
|
||||
codeLabel('input[type="text"] — focus glow bar on [data-brand="dt"]'),
|
||||
el('br'),
|
||||
errorInput,
|
||||
codeLabel('input.error — red focus bar on [data-brand="dt"]'),
|
||||
el('br'),
|
||||
textarea,
|
||||
codeLabel('textarea — same styling'),
|
||||
),
|
||||
);
|
||||
|
||||
// Checkboxes
|
||||
function checkbox(labelText: string, checked: boolean, disabled = false): HTMLElement {
|
||||
const wrapper = el('label', { style: 'display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 4px 0;' });
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
if (checked) input.checked = true;
|
||||
if (disabled) input.disabled = true;
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(el('span', {}, labelText));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Checkbox', 'Beveled hexagon shape via clip-path. 30% corner cuts.',
|
||||
checkbox('Unchecked checkbox', false),
|
||||
checkbox('Checked checkbox', true),
|
||||
checkbox('Disabled checked', true, true),
|
||||
checkbox('Disabled unchecked', false, true),
|
||||
codeLabel('input[type="checkbox"] — hex bevel on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Switch / Toggle
|
||||
function switchEl(labelText: string, checked: boolean, disabled = false): HTMLElement {
|
||||
const wrapper = el('div', { style: 'display: flex; align-items: center; gap: 12px; margin-bottom: 8px;' });
|
||||
const sw = el('label', { className: 'dt-switch' });
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
if (checked) input.checked = true;
|
||||
if (disabled) input.disabled = true;
|
||||
sw.appendChild(input);
|
||||
sw.appendChild(el('div', { className: 'dt-switch-track' }));
|
||||
sw.appendChild(el('div', { className: 'dt-switch-thumb' }));
|
||||
wrapper.appendChild(sw);
|
||||
wrapper.appendChild(el('span', {}, labelText));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Switch / Toggle', 'Angular track + sliding square thumb. 48x26 track, 20x20 thumb.',
|
||||
switchEl('Off', false),
|
||||
switchEl('On', true),
|
||||
switchEl('Disabled (on)', true, true),
|
||||
switchEl('Disabled (off)', false, true),
|
||||
codeLabel('.dt-switch > input + .dt-switch-track + .dt-switch-thumb'),
|
||||
),
|
||||
);
|
||||
|
||||
// Radio Buttons
|
||||
function radioGroup(name: string, options: { value: string; label: string; checked?: boolean; disabled?: boolean }[]): HTMLElement {
|
||||
const group = el('div', { style: 'display: flex; flex-direction: column; gap: 4px;' });
|
||||
for (const opt of options) {
|
||||
const wrapper = el('label', { className: `dt-radio-option${opt.checked ? ' selected' : ''}`, style: 'cursor: pointer;' });
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.name = name;
|
||||
input.value = opt.value;
|
||||
if (opt.checked) input.checked = true;
|
||||
if (opt.disabled) input.disabled = true;
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
group.querySelectorAll('.dt-radio-option').forEach((o) => o.classList.remove('selected'));
|
||||
if (input.checked) wrapper.classList.add('selected');
|
||||
});
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(el('span', {}, opt.label));
|
||||
group.appendChild(wrapper);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Radio Button', 'Hexagonal indicator via clip-path. Flat-top hexagon shape.',
|
||||
radioGroup('demo-radio', [
|
||||
{ value: 'nfc', label: 'NFC (Near Field Communication)', checked: true },
|
||||
{ value: 'rfid', label: 'RFID (Radio Frequency ID)' },
|
||||
{ value: 'ble', label: 'BLE (Bluetooth Low Energy)' },
|
||||
]),
|
||||
codeLabel('input[type="radio"] — hex indicator on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Progress Bar
|
||||
function progressBar(value: number, showLabel = false): HTMLElement {
|
||||
const wrapper = el('div');
|
||||
const bar = el('div', { className: 'dt-progress' });
|
||||
const fill = el('div', { className: 'dt-progress-fill', style: `width: ${value * 100}%;` });
|
||||
bar.appendChild(fill);
|
||||
wrapper.appendChild(bar);
|
||||
if (showLabel) {
|
||||
wrapper.appendChild(el('div', { className: 'dt-progress-label' }, `${Math.round(value * 100)}%`));
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Progress Bar', 'Angular, no border-radius. 4px default height.',
|
||||
label('25%'),
|
||||
progressBar(0.25),
|
||||
label('50% WITH LABEL'),
|
||||
progressBar(0.5, true),
|
||||
label('75%'),
|
||||
progressBar(0.75),
|
||||
label('100%'),
|
||||
progressBar(1.0, true),
|
||||
codeLabel('.dt-progress > .dt-progress-fill'),
|
||||
),
|
||||
);
|
||||
|
||||
// Accordion
|
||||
function accordion(items: { title: string; content: string }[]): HTMLElement {
|
||||
const acc = el('div', { className: 'dt-accordion' });
|
||||
for (const item of items) {
|
||||
const header = el('button', { className: 'dt-accordion-header', 'aria-expanded': 'false' });
|
||||
header.appendChild(document.createTextNode(item.title));
|
||||
header.appendChild(el('span', { className: 'dt-accordion-chevron' }));
|
||||
|
||||
const content = el('div', { className: 'dt-accordion-content', 'data-open': 'false' });
|
||||
content.appendChild(el('div', { style: 'padding: var(--space-4);' }, item.content));
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const expanded = header.getAttribute('aria-expanded') === 'true';
|
||||
header.setAttribute('aria-expanded', String(!expanded));
|
||||
content.setAttribute('data-open', String(!expanded));
|
||||
});
|
||||
|
||||
acc.appendChild(header);
|
||||
acc.appendChild(content);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Accordion', 'Click headers to expand. 5px top border, chevron rotation.',
|
||||
accordion([
|
||||
{ title: 'Size', content: 'Small, Medium, Large options available.' },
|
||||
{ title: 'Chip Type', content: 'NTAG, DESFire, MIFARE Classic.' },
|
||||
{ title: 'Frequency', content: '13.56 MHz (HF), 125 kHz (LF).' },
|
||||
]),
|
||||
codeLabel('.dt-accordion > .dt-accordion-header + .dt-accordion-content'),
|
||||
),
|
||||
);
|
||||
|
||||
// Quantity Stepper
|
||||
function stepper(initialValue: number, min: number, max: number): HTMLElement {
|
||||
let value = initialValue;
|
||||
const wrapper = el('div', { className: 'dt-stepper' });
|
||||
const minusBtn = el('button', { className: 'dt-stepper-btn' }, '\u2212') as HTMLButtonElement;
|
||||
const valueEl = el('span', { className: 'dt-stepper-value' }, String(value));
|
||||
const plusBtn = el('button', { className: 'dt-stepper-btn' }, '+') as HTMLButtonElement;
|
||||
|
||||
function update() {
|
||||
valueEl.textContent = String(value);
|
||||
minusBtn.disabled = value <= min;
|
||||
plusBtn.disabled = value >= max;
|
||||
}
|
||||
|
||||
minusBtn.addEventListener('click', () => { value = Math.max(min, value - 1); update(); });
|
||||
plusBtn.addEventListener('click', () => { value = Math.min(max, value + 1); update(); });
|
||||
|
||||
wrapper.appendChild(minusBtn);
|
||||
wrapper.appendChild(valueEl);
|
||||
wrapper.appendChild(plusBtn);
|
||||
update();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Quantity Stepper', 'Beveled +/- buttons with center display.',
|
||||
stepper(1, 0, 10),
|
||||
codeLabel('.dt-stepper > .dt-stepper-btn + .dt-stepper-value + .dt-stepper-btn'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { el, section, row, codeLabel } from '../utils/dom';
|
||||
|
||||
export function renderGlowsPage(container: HTMLElement) {
|
||||
container.appendChild(el('h1', { className: 'page-title' }, 'Glows'));
|
||||
container.appendChild(el('p', { className: 'page-subtitle' }, 'Neon drop-shadow and text-shadow effects. Active on the DT brand.'));
|
||||
|
||||
// Button Glows
|
||||
container.appendChild(
|
||||
section('Button Glows', 'filter: drop-shadow() follows clip-path shape. Hover for enhanced glow.',
|
||||
row(
|
||||
el('button', { className: 'btn-primary' }, 'PRIMARY GLOW'),
|
||||
el('button', { className: 'btn-danger' }, 'DANGER GLOW'),
|
||||
),
|
||||
codeLabel('.btn-primary / .btn-danger — automatic on [data-brand="dt"]'),
|
||||
el('br'),
|
||||
row(
|
||||
el('button', { className: 'btn-secondary' }, 'SECONDARY (HOVER)'),
|
||||
),
|
||||
codeLabel('.btn-secondary — glow on hover'),
|
||||
),
|
||||
);
|
||||
|
||||
// Link Glow
|
||||
const link = el('a', { href: '#', style: 'color: var(--color-primary); font-weight: 600; font-size: 1.125rem;' }, 'Hover this link for text glow');
|
||||
|
||||
container.appendChild(
|
||||
section('Link Glow', 'text-shadow on hover for anchor elements.',
|
||||
row(link),
|
||||
codeLabel('a:hover — automatic on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Terminal Inset
|
||||
const terminal = el('div', { className: 'terminal dt-accent-top' });
|
||||
terminal.appendChild(el('code', {}, '$ dt-web-theme --install\n> Installing design tokens...\n> Applying cyberpunk aesthetic...\n> Done. Welcome to the future.'));
|
||||
|
||||
container.appendChild(
|
||||
section('Terminal Inset Glow', 'Inset + outer box-shadow. No clip-path so box-shadow works directly.',
|
||||
terminal,
|
||||
codeLabel('.terminal — automatic on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Card Hover Glow
|
||||
const card = el('div', { className: 'card', style: 'padding: var(--space-6); cursor: pointer;' });
|
||||
card.appendChild(el('div', { className: 'card-title' }, 'HOVER ME'));
|
||||
card.appendChild(el('div', { className: 'card-body' }, 'Cards get a drop-shadow glow on hover.'));
|
||||
|
||||
container.appendChild(
|
||||
section('Card Hover Glow', 'filter: drop-shadow() respects the beveled clip-path.',
|
||||
card,
|
||||
codeLabel('.card:hover — automatic on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Input Focus Glow
|
||||
const input = el('input', {
|
||||
type: 'text',
|
||||
className: 'input',
|
||||
placeholder: 'Click to focus — see the glow bar',
|
||||
style: 'max-width: 400px;',
|
||||
}) as HTMLInputElement;
|
||||
|
||||
container.appendChild(
|
||||
section('Input Focus Glow', 'box-shadow: 0 4px 0 1px — bright bar beneath the input.',
|
||||
input,
|
||||
codeLabel('.input:focus — automatic on [data-brand="dt"]'),
|
||||
),
|
||||
);
|
||||
|
||||
// Generic Glow Utilities
|
||||
const glowBox = el('div', {
|
||||
className: 'dt-glow',
|
||||
style: 'background: var(--color-primary); padding: var(--space-4) var(--space-6); display: inline-block; font-weight: 700; color: var(--color-bg);',
|
||||
}, '.dt-glow');
|
||||
const glowStrong = el('div', {
|
||||
className: 'dt-glow-strong',
|
||||
style: 'background: var(--color-primary); padding: var(--space-4) var(--space-6); display: inline-block; font-weight: 700; color: var(--color-bg);',
|
||||
}, '.dt-glow-strong');
|
||||
const glowInset = el('div', {
|
||||
className: 'dt-glow-inset',
|
||||
style: 'padding: var(--space-4) var(--space-6); display: inline-block; font-weight: 700; border: 1px solid var(--color-border);',
|
||||
}, '.dt-glow-inset');
|
||||
const textGlow = el('div', {
|
||||
className: 'dt-text-glow',
|
||||
style: 'font-size: 1.25rem; font-weight: 700; color: var(--color-primary); display: inline-block;',
|
||||
}, '.dt-text-glow');
|
||||
|
||||
container.appendChild(
|
||||
section('Glow Utilities', 'Generic utility classes for applying glow effects to any element.',
|
||||
row(glowBox, glowStrong),
|
||||
codeLabel('.dt-glow | .dt-glow-strong'),
|
||||
row(glowInset, textGlow),
|
||||
codeLabel('.dt-glow-inset | .dt-text-glow'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { el, section } from '../utils/dom';
|
||||
|
||||
export function renderHomePage(container: HTMLElement) {
|
||||
container.appendChild(el('h1', { className: 'page-title' }, 'DT Design System'));
|
||||
container.appendChild(el('p', { className: 'page-subtitle' }, 'Web CSS component showcase — switch brands and modes in the sidebar.'));
|
||||
|
||||
const categories = [
|
||||
{ hash: 'bevels', title: 'Bevels', desc: 'Angular clip-path patterns — cards, buttons, badges, media frames, modals, drawers' },
|
||||
{ hash: 'glows', title: 'Glows', desc: 'Neon drop-shadow and text-shadow effects for the DT brand' },
|
||||
{ hash: 'forms', title: 'Forms', desc: 'Text inputs, checkboxes, switches, radios, progress bars, accordions, steppers' },
|
||||
{ hash: 'tokens', title: 'Tokens', desc: 'Color palette, typography, spacing, and shape values for the active brand' },
|
||||
];
|
||||
|
||||
for (const cat of categories) {
|
||||
const link = el('a', { href: `#/${cat.hash}`, className: 'home-card-link' });
|
||||
const card = el('div', { className: 'card' });
|
||||
card.appendChild(el('div', { className: 'card-title' }, cat.title.toUpperCase()));
|
||||
card.appendChild(el('div', { className: 'card-body' }, cat.desc));
|
||||
link.appendChild(card);
|
||||
container.appendChild(link);
|
||||
}
|
||||
|
||||
// Quick overview terminal
|
||||
container.appendChild(
|
||||
section('Quick Start', 'Include the CSS and set data attributes on your HTML element.',
|
||||
el('div', { className: 'terminal dt-accent-top' },
|
||||
el('code', {},
|
||||
'<link rel="stylesheet" href="@dangerousthings/web">\n<html data-brand="dt" data-theme="dark">'
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { brands } from '@dangerousthings/tokens';
|
||||
import type { ThemeBrand } from '@dangerousthings/tokens';
|
||||
import { el, section, codeLabel } from '../utils/dom';
|
||||
import { getCurrentBrand } from '../brand-switcher';
|
||||
|
||||
export function renderTokensPage(container: HTMLElement) {
|
||||
container.appendChild(el('h1', { className: 'page-title' }, 'Tokens'));
|
||||
container.appendChild(el('p', { className: 'page-subtitle' }, 'Design token values for the active brand. Switch brands in the sidebar.'));
|
||||
|
||||
const brandId = getCurrentBrand();
|
||||
const brand = brands[brandId];
|
||||
|
||||
if (!brand) return;
|
||||
|
||||
// Color Palette — Dark Mode
|
||||
const darkColors = brand.dark;
|
||||
const colorEntries: [string, string][] = Object.entries(darkColors);
|
||||
|
||||
const colorsGrid = el('div', { style: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-3);' });
|
||||
|
||||
for (const [name, hex] of colorEntries) {
|
||||
const swatch = el('div', { style: 'display: flex; align-items: center; gap: var(--space-3);' });
|
||||
const colorBox = el('div', { style: `width: 32px; height: 32px; background: ${hex}; border: 1px solid rgba(255,255,255,0.15); flex-shrink: 0;` });
|
||||
const info = el('div');
|
||||
info.appendChild(el('div', { style: 'font-weight: 600; font-size: 0.8125rem;' }, name));
|
||||
info.appendChild(el('div', { style: 'font-family: var(--font-mono); font-size: 0.6875rem; opacity: 0.5;' }, hex));
|
||||
swatch.appendChild(colorBox);
|
||||
swatch.appendChild(info);
|
||||
colorsGrid.appendChild(swatch);
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section(`Color Palette — ${brand.name} (Dark Mode)`, brand.description,
|
||||
colorsGrid,
|
||||
),
|
||||
);
|
||||
|
||||
// Light Mode
|
||||
const lightColors = brand.light;
|
||||
const lightEntries: [string, string][] = Object.entries(lightColors);
|
||||
|
||||
const lightGrid = el('div', { style: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-3);' });
|
||||
|
||||
for (const [name, hex] of lightEntries) {
|
||||
const swatch = el('div', { style: 'display: flex; align-items: center; gap: var(--space-3);' });
|
||||
const colorBox = el('div', { style: `width: 32px; height: 32px; background: ${hex}; border: 1px solid rgba(255,255,255,0.15); flex-shrink: 0;` });
|
||||
const info = el('div');
|
||||
info.appendChild(el('div', { style: 'font-weight: 600; font-size: 0.8125rem;' }, name));
|
||||
info.appendChild(el('div', { style: 'font-family: var(--font-mono); font-size: 0.6875rem; opacity: 0.5;' }, hex));
|
||||
swatch.appendChild(colorBox);
|
||||
swatch.appendChild(info);
|
||||
lightGrid.appendChild(swatch);
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section(`Color Palette — ${brand.name} (Light Mode)`, 'Light mode color tokens.',
|
||||
lightGrid,
|
||||
),
|
||||
);
|
||||
|
||||
// Typography
|
||||
const typo = el('div', { className: 'terminal', style: 'margin-bottom: var(--space-4);' });
|
||||
typo.appendChild(el('code', {},
|
||||
`heading: ${brand.typography.heading}\nbody: ${brand.typography.body}\nmono: ${brand.typography.mono}`
|
||||
));
|
||||
|
||||
container.appendChild(
|
||||
section('Typography', 'Font family tokens for headings, body text, and monospace.',
|
||||
typo,
|
||||
),
|
||||
);
|
||||
|
||||
// Typography Scale Demo
|
||||
const scaleItems = [
|
||||
{ tag: 'h1', label: 'Heading 1', style: 'font-family: var(--font-heading); font-size: 2rem; font-weight: 900;' },
|
||||
{ tag: 'h2', label: 'Heading 2', style: 'font-family: var(--font-heading); font-size: 1.5rem; font-weight: 700;' },
|
||||
{ tag: 'h3', label: 'Heading 3', style: 'font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600;' },
|
||||
{ tag: 'p', label: 'Body text — the quick brown fox jumps over the lazy dog', style: 'font-family: var(--font-body); font-size: 1rem;' },
|
||||
{ tag: 'code', label: 'const monospace = "code";', style: 'font-family: var(--font-mono); font-size: 0.875rem;' },
|
||||
];
|
||||
|
||||
const scaleDemo = el('div', { style: 'display: flex; flex-direction: column; gap: var(--space-3);' });
|
||||
for (const item of scaleItems) {
|
||||
scaleDemo.appendChild(el(item.tag, { style: item.style }, item.label));
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Typography Scale', 'Live rendering with the current brand\'s font families.',
|
||||
scaleDemo,
|
||||
),
|
||||
);
|
||||
|
||||
// Shape Tokens
|
||||
const shape = el('div', { className: 'terminal', style: 'margin-bottom: var(--space-4);' });
|
||||
shape.appendChild(el('code', {},
|
||||
`bevelSm: ${brand.shape.bevelSm}\nbevelMd: ${brand.shape.bevelMd}\nbevelLg: ${brand.shape.bevelLg}\nradiusSm: ${brand.shape.radiusSm}\nradius: ${brand.shape.radius}\nradiusLg: ${brand.shape.radiusLg}`
|
||||
));
|
||||
|
||||
container.appendChild(
|
||||
section('Shape Tokens', 'Bevel and radius values determine the visual language — angular (DT) vs rounded (Classic).',
|
||||
shape,
|
||||
),
|
||||
);
|
||||
|
||||
// Spacing Scale
|
||||
const spacings = [
|
||||
{ name: '--space-1', value: '0.25rem (4px)' },
|
||||
{ name: '--space-2', value: '0.5rem (8px)' },
|
||||
{ name: '--space-3', value: '0.75rem (12px)' },
|
||||
{ name: '--space-4', value: '1rem (16px)' },
|
||||
{ name: '--space-6', value: '1.5rem (24px)' },
|
||||
{ name: '--space-8', value: '2rem (32px)' },
|
||||
];
|
||||
|
||||
const spacingDemo = el('div', { style: 'display: flex; flex-direction: column; gap: var(--space-2);' });
|
||||
for (const sp of spacings) {
|
||||
const row = el('div', { style: 'display: flex; align-items: center; gap: var(--space-3);' });
|
||||
row.appendChild(el('div', { style: `width: ${sp.name.replace('--', 'var(--') + ')'}; width: calc(${sp.value.split(' ')[0]} * 6); height: 12px; background: var(--color-primary); opacity: 0.6;` }));
|
||||
row.appendChild(el('span', { style: 'font-family: var(--font-mono); font-size: 0.75rem; opacity: 0.6;' }, `${sp.name}: ${sp.value}`));
|
||||
spacingDemo.appendChild(row);
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
section('Spacing Scale', 'Shared spacing tokens across all brands.',
|
||||
spacingDemo,
|
||||
),
|
||||
);
|
||||
|
||||
// CSS Custom Properties reference
|
||||
const cssRef = el('div', { className: 'terminal' });
|
||||
cssRef.appendChild(el('code', {},
|
||||
`/* Generated CSS custom properties */\n--color-bg: ${darkColors.bg};\n--color-primary: ${darkColors.primary};\n--color-secondary: ${darkColors.secondary};\n--color-error: ${darkColors.error};\n--color-success: ${darkColors.success};\n--bevel-sm: ${brand.shape.bevelSm};\n--bevel-md: ${brand.shape.bevelMd};\n--font-heading: ${brand.typography.heading};`
|
||||
));
|
||||
|
||||
container.appendChild(
|
||||
section('CSS Custom Properties', 'These are generated from TypeScript tokens and applied per-brand via data-brand attribute.',
|
||||
cssRef,
|
||||
codeLabel('See packages/tokens/src/scripts/generate-css.ts'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Showcase App Layout */
|
||||
|
||||
* {
|
||||
@@ -228,12 +231,16 @@ body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* Cards — DT brand padding is set in bevels.css via --_card-pad */
|
||||
.card {
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Classic brand: cards need explicit padding (no bevel CSS provides it) */
|
||||
[data-brand="classic"] .card {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 900;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
export function el(
|
||||
tag: string,
|
||||
attrs?: Record<string, string>,
|
||||
...children: (string | HTMLElement)[]
|
||||
): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
element.appendChild(child);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function section(title: string, description: string, ...children: HTMLElement[]): HTMLElement {
|
||||
const container = el('div', { className: 'demo-section' });
|
||||
container.appendChild(el('h2', {}, title));
|
||||
container.appendChild(el('p', { className: 'demo-description' }, description));
|
||||
for (const child of children) {
|
||||
container.appendChild(child);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
export function row(...children: HTMLElement[]): HTMLElement {
|
||||
return el('div', { className: 'demo-row' }, ...children);
|
||||
}
|
||||
|
||||
export function label(text: string): HTMLElement {
|
||||
return el('div', { className: 'demo-label' }, text);
|
||||
}
|
||||
|
||||
export function codeLabel(text: string): HTMLElement {
|
||||
return el('div', { className: 'code-label' }, text);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function initRouter(
|
||||
routes: Record<string, (container: HTMLElement) => void>,
|
||||
container: HTMLElement,
|
||||
) {
|
||||
function navigate() {
|
||||
const hash = window.location.hash.replace('#/', '').replace('#', '');
|
||||
const render = routes[hash] || routes[''];
|
||||
|
||||
// Update active nav link
|
||||
document.querySelectorAll('.nav-link').forEach((link) => {
|
||||
const href = (link as HTMLAnchorElement).getAttribute('href') || '';
|
||||
const linkHash = href.replace('#/', '').replace('#', '');
|
||||
link.classList.toggle('active', linkHash === hash);
|
||||
});
|
||||
|
||||
// Clear content safely
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
if (render) {
|
||||
render(container);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', navigate);
|
||||
navigate();
|
||||
}
|
||||
10
packages/showcase/desktop/tailwind.config.js
Normal file
10
packages/showcase/desktop/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import dtPreset from '@dangerousthings/tailwind-preset';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
presets: [dtPreset],
|
||||
content: ['./src/renderer/**/*.{tsx,ts,html}'],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
"outDir": "dist/renderer",
|
||||
"rootDir": "src/renderer",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/renderer"]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
root: 'src/renderer',
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../../dist/renderer',
|
||||
emptyOutDir: true,
|
||||
|
||||
13
packages/showcase/mobile/.expo/README.md
Normal file
13
packages/showcase/mobile/.expo/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
3
packages/showcase/mobile/.expo/devices.json
Normal file
3
packages/showcase/mobile/.expo/devices.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -46,3 +46,16 @@ export const dropdownItems = [
|
||||
{ id: 'd3', title: 'Archive', onPress: () => {} },
|
||||
{ id: 'd4', title: 'Delete', onPress: () => {} },
|
||||
];
|
||||
|
||||
export const featureLegendItems = [
|
||||
{ key: 'payment', name: 'Payment', icon: null, state: 'supported' as const },
|
||||
{ key: 'access', name: 'Access', icon: null, state: 'supported' as const },
|
||||
{ key: 'clone', name: 'Clone', icon: null, state: 'disabled' as const },
|
||||
{ key: 'crypto', name: 'Crypto', icon: null, state: 'unsupported' as const },
|
||||
{ key: 'sensing', name: 'Sensing', icon: null, state: 'supported' as const },
|
||||
{ key: 'temp', name: 'Temp', icon: null, state: 'supported' as const },
|
||||
{ key: 'fitness', name: 'Fitness', icon: null, state: 'disabled' as const },
|
||||
{ key: 'explore', name: 'Explore', icon: null, state: 'supported' as const },
|
||||
{ key: 'vibration', name: 'Vibration', icon: null, state: 'unsupported' as const },
|
||||
{ key: 'sharing', name: 'Sharing', icon: null, state: 'supported' as const },
|
||||
];
|
||||
|
||||
@@ -10,6 +10,9 @@ import { FormsScreen } from '../screens/FormsScreen';
|
||||
import { FeedbackScreen } from '../screens/FeedbackScreen';
|
||||
import { OverlaysScreen } from '../screens/OverlaysScreen';
|
||||
import { ThemeScreen } from '../screens/ThemeScreen';
|
||||
import { AnimationsScreen } from '../screens/AnimationsScreen';
|
||||
import { CardsAdvancedScreen } from '../screens/CardsAdvancedScreen';
|
||||
import { FiltersScreen } from '../screens/FiltersScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
@@ -42,6 +45,11 @@ export function RootNavigator() {
|
||||
component={CardsScreen}
|
||||
options={{ title: 'CARDS & LABELS' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CardsAdvanced"
|
||||
component={CardsAdvancedScreen}
|
||||
options={{ title: 'ADVANCED CARDS' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Forms"
|
||||
component={FormsScreen}
|
||||
@@ -57,6 +65,16 @@ export function RootNavigator() {
|
||||
component={OverlaysScreen}
|
||||
options={{ title: 'OVERLAYS & NAV' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Animations"
|
||||
component={AnimationsScreen}
|
||||
options={{ title: 'ANIMATIONS' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Filters"
|
||||
component={FiltersScreen}
|
||||
options={{ title: 'FILTERS & FEATURES' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Theme"
|
||||
component={ThemeScreen}
|
||||
|
||||
@@ -2,8 +2,11 @@ export type RootStackParamList = {
|
||||
Home: undefined;
|
||||
Buttons: undefined;
|
||||
Cards: undefined;
|
||||
CardsAdvanced: undefined;
|
||||
Forms: undefined;
|
||||
Feedback: undefined;
|
||||
Overlays: undefined;
|
||||
Animations: undefined;
|
||||
Filters: undefined;
|
||||
Theme: undefined;
|
||||
};
|
||||
|
||||
90
packages/showcase/mobile/src/screens/AnimationsScreen.tsx
Normal file
90
packages/showcase/mobile/src/screens/AnimationsScreen.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import {
|
||||
DTCard,
|
||||
DTLabel,
|
||||
DTStaggerContainer,
|
||||
useDTTheme,
|
||||
useScaleIn,
|
||||
usePulse,
|
||||
} from '@dangerousthings/react-native';
|
||||
import type { DTVariant } from '@dangerousthings/react-native';
|
||||
import { ScreenContainer } from '../components/ScreenContainer';
|
||||
import { DemoSection } from '../components/DemoSection';
|
||||
import { CodeLabel } from '../components/CodeLabel';
|
||||
import { Animated } from 'react-native';
|
||||
|
||||
export function AnimationsScreen() {
|
||||
const theme = useDTTheme();
|
||||
const scaleAnim = useScaleIn({ duration: 600 });
|
||||
const pulseAnim = usePulse(true);
|
||||
|
||||
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
{/* Stagger Container */}
|
||||
<DemoSection
|
||||
title="DTStaggerContainer"
|
||||
variant="normal"
|
||||
description="Staggered scale-in entrance animation for child elements."
|
||||
>
|
||||
<DTStaggerContainer duration={330} interval={75}>
|
||||
{modes.map((mode) => (
|
||||
<DTCard key={mode} mode={mode} title={mode.toUpperCase()} style={{ marginBottom: 12 }}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
Staggered entrance with scale animation
|
||||
</Text>
|
||||
</DTCard>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
<CodeLabel text="<DTStaggerContainer duration={330} interval={75}>" />
|
||||
</DemoSection>
|
||||
|
||||
{/* Stagger with Labels */}
|
||||
<DemoSection
|
||||
title="Staggered Labels"
|
||||
variant="emphasis"
|
||||
description="Labels with staggered entrance animation."
|
||||
>
|
||||
<DTStaggerContainer duration={400} interval={100}>
|
||||
{modes.map((mode) => (
|
||||
<View key={mode} style={{ marginBottom: 8 }}>
|
||||
<DTLabel primaryText={mode.toUpperCase()} mode={mode} />
|
||||
</View>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
<CodeLabel text="DTStaggerContainer > DTLabel — customizable timing" />
|
||||
</DemoSection>
|
||||
|
||||
{/* Scale-In Hook */}
|
||||
<DemoSection
|
||||
title="useScaleIn"
|
||||
variant="success"
|
||||
description="Scale 0→1 entrance animation hook for individual elements."
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
<DTCard mode="success" title="SCALE IN">
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
This card used useScaleIn({ duration: 600 })
|
||||
</Text>
|
||||
</DTCard>
|
||||
</Animated.View>
|
||||
<CodeLabel text="const scale = useScaleIn({ duration: 600 })" />
|
||||
</DemoSection>
|
||||
|
||||
{/* Pulse Hook */}
|
||||
<DemoSection
|
||||
title="usePulse"
|
||||
variant="warning"
|
||||
description="Looping opacity pulse animation for active/loading states."
|
||||
>
|
||||
<Animated.View style={{ opacity: pulseAnim }}>
|
||||
<DTLabel primaryText="PULSING LABEL" mode="warning" />
|
||||
</Animated.View>
|
||||
<CodeLabel text="const opacity = usePulse(true)" />
|
||||
</DemoSection>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
111
packages/showcase/mobile/src/screens/CardsAdvancedScreen.tsx
Normal file
111
packages/showcase/mobile/src/screens/CardsAdvancedScreen.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import {
|
||||
DTCard,
|
||||
DTChip,
|
||||
DTBadgeOverlay,
|
||||
DTStaggerContainer,
|
||||
useDTTheme,
|
||||
} from '@dangerousthings/react-native';
|
||||
import type { DTVariant } from '@dangerousthings/react-native';
|
||||
import { ScreenContainer } from '../components/ScreenContainer';
|
||||
import { DemoSection } from '../components/DemoSection';
|
||||
import { CodeLabel } from '../components/CodeLabel';
|
||||
|
||||
export function CardsAdvancedScreen() {
|
||||
const theme = useDTTheme();
|
||||
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
{/* Progress Bar */}
|
||||
<DemoSection
|
||||
title="Progress Bar"
|
||||
variant="normal"
|
||||
description="Vertical left-edge progress indicator (0–1)."
|
||||
>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((val, i) => (
|
||||
<DTCard
|
||||
key={val}
|
||||
mode={modes[i]}
|
||||
title={`${Math.round(val * 100)}% PROGRESS`}
|
||||
progress={val}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
progress={{val}}
|
||||
</Text>
|
||||
</DTCard>
|
||||
))}
|
||||
<CodeLabel text="<DTCard progress={0.5}> — width matches bevelSizeSmall" />
|
||||
</DemoSection>
|
||||
|
||||
{/* Card Badges */}
|
||||
<DemoSection
|
||||
title="Card Badges"
|
||||
variant="other"
|
||||
description="Bottom-right chip badges. Color matches badge category, not card mode."
|
||||
>
|
||||
<DTCard mode="normal" title="PRODUCT CARD" style={{ marginBottom: 16 }}>
|
||||
<View style={{ minHeight: 40 }}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
Badge color independent of card mode
|
||||
</Text>
|
||||
</View>
|
||||
<DTBadgeOverlay position="bottom-right">
|
||||
<DTChip variant="warning">LAB</DTChip>
|
||||
</DTBadgeOverlay>
|
||||
</DTCard>
|
||||
|
||||
<DTCard mode="emphasis" title="PRODUCT CARD" style={{ marginBottom: 16 }}>
|
||||
<View style={{ minHeight: 40 }}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
Badge color independent of card mode
|
||||
</Text>
|
||||
</View>
|
||||
<DTBadgeOverlay position="bottom-right">
|
||||
<DTChip variant="other">BUNDLE</DTChip>
|
||||
</DTBadgeOverlay>
|
||||
</DTCard>
|
||||
|
||||
<DTCard mode="success" title="PRODUCT CARD" style={{ marginBottom: 16 }}>
|
||||
<View style={{ minHeight: 40 }}>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
Badge color independent of card mode
|
||||
</Text>
|
||||
</View>
|
||||
<DTBadgeOverlay position="bottom-right">
|
||||
<DTChip variant="emphasis">NEW</DTChip>
|
||||
</DTBadgeOverlay>
|
||||
</DTCard>
|
||||
|
||||
<CodeLabel text="<DTBadgeOverlay position='bottom-right'><DTChip variant='warning'>LAB</DTChip>" />
|
||||
</DemoSection>
|
||||
|
||||
{/* Stagger + Progress */}
|
||||
<DemoSection
|
||||
title="Staggered Cards with Progress"
|
||||
variant="success"
|
||||
description="Stagger container with progress bars across all modes."
|
||||
>
|
||||
<DTStaggerContainer>
|
||||
{modes.map((mode) => (
|
||||
<DTCard
|
||||
key={mode}
|
||||
mode={mode}
|
||||
title={mode.toUpperCase()}
|
||||
progress={Math.random()}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Text variant="bodySmall" style={{ color: theme.colors.onSurface }}>
|
||||
Staggered + progress
|
||||
</Text>
|
||||
</DTCard>
|
||||
))}
|
||||
</DTStaggerContainer>
|
||||
<CodeLabel text="DTStaggerContainer > DTCard progress" />
|
||||
</DemoSection>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,20 @@ export function CardsScreen() {
|
||||
</View>
|
||||
</DemoSection>
|
||||
|
||||
{/* DTCard - Selected & Progress (quick preview) */}
|
||||
<DemoSection
|
||||
title="Progress Bar"
|
||||
variant="warning"
|
||||
description="Quick preview — see Advanced Cards screen for full demos."
|
||||
>
|
||||
<DTCard mode="normal" title="WITH PROGRESS" progress={0.6} style={{ marginBottom: 16 }}>
|
||||
<Text variant="bodyMedium" style={{ color: theme.colors.onSurface }}>
|
||||
progress=0.6 — vertical left-edge bar
|
||||
</Text>
|
||||
</DTCard>
|
||||
<CodeLabel text="See Advanced Cards for full progress/badge demos" />
|
||||
</DemoSection>
|
||||
|
||||
{/* DTMediaFrame */}
|
||||
<DemoSection
|
||||
title="DTMediaFrame"
|
||||
|
||||
116
packages/showcase/mobile/src/screens/FiltersScreen.tsx
Normal file
116
packages/showcase/mobile/src/screens/FiltersScreen.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import {
|
||||
DTFeatureLegend,
|
||||
DTMobileFilterOverlay,
|
||||
DTButton,
|
||||
DTAccordion,
|
||||
useDTTheme,
|
||||
} from '@dangerousthings/react-native';
|
||||
import type { DTFeatureItem } from '@dangerousthings/react-native';
|
||||
import { ScreenContainer } from '../components/ScreenContainer';
|
||||
import { DemoSection } from '../components/DemoSection';
|
||||
import { CodeLabel } from '../components/CodeLabel';
|
||||
|
||||
const sampleFeatures: DTFeatureItem[] = [
|
||||
{ key: 'payment', name: 'Payment', icon: null, state: 'supported' },
|
||||
{ key: 'access', name: 'Access', icon: null, state: 'supported' },
|
||||
{ key: 'clone', name: 'Clone', icon: null, state: 'disabled' },
|
||||
{ key: 'crypto', name: 'Crypto', icon: null, state: 'unsupported' },
|
||||
{ key: 'sensing', name: 'Sensing', icon: null, state: 'supported' },
|
||||
{ key: 'temp', name: 'Temp', icon: null, state: 'supported' },
|
||||
{ key: 'fitness', name: 'Fitness', icon: null, state: 'disabled' },
|
||||
{ key: 'explore', name: 'Explore', icon: null, state: 'supported' },
|
||||
{ key: 'vibration', name: 'Vibration', icon: null, state: 'unsupported' },
|
||||
{ key: 'sharing', name: 'Sharing', icon: null, state: 'supported' },
|
||||
];
|
||||
|
||||
export function FiltersScreen() {
|
||||
const theme = useDTTheme();
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
{/* Feature Legend */}
|
||||
<DemoSection
|
||||
title="DTFeatureLegend"
|
||||
variant="normal"
|
||||
description="Product feature grid with icons and rotated labels. Color indicates feature state."
|
||||
>
|
||||
<DTFeatureLegend
|
||||
features={sampleFeatures}
|
||||
variant="normal"
|
||||
title="NFC FEATURES"
|
||||
columns={5}
|
||||
/>
|
||||
<CodeLabel text='<DTFeatureLegend features={...} variant="normal" columns={5}>' />
|
||||
</DemoSection>
|
||||
|
||||
{/* Feature Legend — other variant */}
|
||||
<DemoSection
|
||||
title="Emphasis Variant"
|
||||
variant="emphasis"
|
||||
description="Feature legend with emphasis header color."
|
||||
>
|
||||
<DTFeatureLegend
|
||||
features={sampleFeatures.slice(0, 5)}
|
||||
variant="emphasis"
|
||||
title="KEY FEATURES"
|
||||
columns={5}
|
||||
/>
|
||||
<CodeLabel text='variant="emphasis" — header uses emphasis color' />
|
||||
</DemoSection>
|
||||
|
||||
{/* Mobile Filter Overlay */}
|
||||
<DemoSection
|
||||
title="DTMobileFilterOverlay"
|
||||
variant="warning"
|
||||
description="Full-screen slide-up overlay for mobile filter menus."
|
||||
>
|
||||
<DTButton
|
||||
variant="normal"
|
||||
onPress={() => setOverlayVisible(true)}
|
||||
>
|
||||
OPEN FILTER OVERLAY
|
||||
</DTButton>
|
||||
<CodeLabel text="<DTMobileFilterOverlay visible onDismiss={...}>" />
|
||||
|
||||
<DTMobileFilterOverlay
|
||||
visible={overlayVisible}
|
||||
onDismiss={() => setOverlayVisible(false)}
|
||||
heading="FILTERS"
|
||||
activeFilterCount={3}
|
||||
onClearAll={() => setOverlayVisible(false)}
|
||||
variant="normal"
|
||||
>
|
||||
<DTAccordion
|
||||
sections={[
|
||||
{
|
||||
key: 'chip-type',
|
||||
title: 'CHIP TYPE',
|
||||
children: (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.onSurface }}>NTAG215</Text>
|
||||
<Text style={{ color: theme.colors.onSurface }}>NTAG216</Text>
|
||||
<Text style={{ color: theme.colors.onSurface }}>DESFire EV2</Text>
|
||||
</View>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'frequency',
|
||||
title: 'FREQUENCY',
|
||||
children: (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.onSurface }}>13.56 MHz (HF)</Text>
|
||||
<Text style={{ color: theme.colors.onSurface }}>125 kHz (LF)</Text>
|
||||
</View>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DTMobileFilterOverlay>
|
||||
</DemoSection>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,27 @@ const categories: {
|
||||
route: 'Overlays',
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
title: 'ADVANCED CARDS',
|
||||
subtitle: 'Selected state, progress bar, badge overlays, stagger',
|
||||
mode: 'emphasis',
|
||||
route: 'CardsAdvanced',
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
title: 'ANIMATIONS',
|
||||
subtitle: 'DTStaggerContainer, useScaleIn, usePulse',
|
||||
mode: 'success',
|
||||
route: 'Animations',
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
title: 'FILTERS & FEATURES',
|
||||
subtitle: 'DTFeatureLegend, DTMobileFilterOverlay',
|
||||
mode: 'other',
|
||||
route: 'Filters',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
title: 'THEME REFERENCE',
|
||||
subtitle: 'Colors, Typography, Spacing',
|
||||
|
||||
33
packages/tailwind-preset/package.json
Normal file
33
packages/tailwind-preset/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@dangerousthings/tailwind-preset",
|
||||
"version": "0.1.0",
|
||||
"description": "Tailwind CSS v3 preset mapping DT design tokens to Tailwind theme",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
76
packages/tailwind-preset/src/index.ts
Normal file
76
packages/tailwind-preset/src/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* DT Design System — Tailwind CSS v3 Preset
|
||||
*
|
||||
* Maps design tokens (CSS custom properties) to Tailwind theme values.
|
||||
* Uses var() references so the same utility classes work with both
|
||||
* DT and Classic brands at runtime via [data-brand] switching.
|
||||
*
|
||||
* Usage in consumer's tailwind.config.js:
|
||||
*
|
||||
* import dtPreset from '@dangerousthings/tailwind-preset';
|
||||
*
|
||||
* export default {
|
||||
* presets: [dtPreset],
|
||||
* content: ['./src/**\/*.{tsx,ts,html}'],
|
||||
* };
|
||||
*
|
||||
* Then use: bg-dt-primary, text-dt-text-primary, gap-dt-4, rounded-dt, etc.
|
||||
* Opacity modifiers work for colors with -rgb variants: bg-dt-primary/50
|
||||
*/
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const dtPreset: Partial<Config> = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
dt: {
|
||||
// Colors with -rgb variants support Tailwind opacity modifiers (bg-dt-primary/50)
|
||||
bg: 'rgb(var(--color-bg-rgb) / <alpha-value>)',
|
||||
surface: 'rgb(var(--color-surface-rgb) / <alpha-value>)',
|
||||
primary: 'rgb(var(--color-primary-rgb) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--color-secondary-rgb) / <alpha-value>)',
|
||||
accent: 'rgb(var(--color-accent-rgb) / <alpha-value>)',
|
||||
other: 'rgb(var(--color-other-rgb) / <alpha-value>)',
|
||||
error: 'rgb(var(--color-error-rgb) / <alpha-value>)',
|
||||
warning: 'rgb(var(--color-warning-rgb) / <alpha-value>)',
|
||||
success: 'rgb(var(--color-success-rgb) / <alpha-value>)',
|
||||
info: 'rgb(var(--color-info-rgb) / <alpha-value>)',
|
||||
// Colors without -rgb variants (plain var)
|
||||
border: 'var(--color-border)',
|
||||
'surface-hover': 'var(--color-surface-hover)',
|
||||
'primary-dim': 'var(--color-primary-dim)',
|
||||
'text-primary': 'var(--color-text-primary)',
|
||||
'text-secondary': 'var(--color-text-secondary)',
|
||||
'text-muted': 'var(--color-text-muted)',
|
||||
},
|
||||
mode: {
|
||||
normal: 'var(--mode-normal)',
|
||||
emphasis: 'var(--mode-emphasis)',
|
||||
warning: 'var(--mode-warning)',
|
||||
success: 'var(--mode-success)',
|
||||
other: 'var(--mode-other)',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'dt-1': 'var(--space-1)',
|
||||
'dt-2': 'var(--space-2)',
|
||||
'dt-3': 'var(--space-3)',
|
||||
'dt-4': 'var(--space-4)',
|
||||
'dt-6': 'var(--space-6)',
|
||||
'dt-8': 'var(--space-8)',
|
||||
},
|
||||
borderRadius: {
|
||||
'dt-sm': 'var(--radius-sm)',
|
||||
dt: 'var(--radius)',
|
||||
'dt-lg': 'var(--radius-lg)',
|
||||
},
|
||||
fontFamily: {
|
||||
'dt-heading': 'var(--font-heading)',
|
||||
'dt-body': 'var(--font-body)',
|
||||
'dt-mono': 'var(--font-mono)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default dtPreset;
|
||||
17
packages/tailwind-preset/tsconfig.json
Normal file
17
packages/tailwind-preset/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -6,8 +6,11 @@ export type {
|
||||
TypographyTokens,
|
||||
ShapeTokens,
|
||||
BrandTokens,
|
||||
DTVariant,
|
||||
} from "./types.js";
|
||||
|
||||
export { variantToCSSProperty, variantToClassName } from "./types.js";
|
||||
|
||||
export { dt } from "./brands/dt.js";
|
||||
export { classic } from "./brands/classic.js";
|
||||
|
||||
|
||||
@@ -56,7 +56,24 @@ function colorVars(colors: ColorTokens): string {
|
||||
--color-error: ${colors.error};
|
||||
--color-error-rgb: ${hexToRgb(colors.error)};
|
||||
--color-info: ${colors.info};
|
||||
--color-info-rgb: ${hexToRgb(colors.info)};`;
|
||||
--color-info-rgb: ${hexToRgb(colors.info)};
|
||||
|
||||
/* Mode Aliases — per-component color overrides (cards, badges, buttons) */
|
||||
--mode-normal: var(--color-primary);
|
||||
--mode-normal-rgb: var(--color-primary-rgb);
|
||||
--mode-normal-selected: rgba(var(--color-primary-rgb), 0.7);
|
||||
--mode-emphasis: var(--color-secondary);
|
||||
--mode-emphasis-rgb: var(--color-secondary-rgb);
|
||||
--mode-emphasis-selected: rgba(var(--color-secondary-rgb), 0.7);
|
||||
--mode-warning: var(--color-error);
|
||||
--mode-warning-rgb: var(--color-error-rgb);
|
||||
--mode-warning-selected: rgba(var(--color-error-rgb), 0.7);
|
||||
--mode-success: var(--color-accent);
|
||||
--mode-success-rgb: var(--color-accent-rgb);
|
||||
--mode-success-selected: rgba(var(--color-accent-rgb), 0.7);
|
||||
--mode-other: var(--color-other);
|
||||
--mode-other-rgb: var(--color-other-rgb);
|
||||
--mode-other-selected: rgba(var(--color-other-rgb), 0.7);`;
|
||||
}
|
||||
|
||||
/** Generate a full brand CSS file */
|
||||
|
||||
@@ -53,6 +53,27 @@ export interface ShapeTokens {
|
||||
radiusLg: string;
|
||||
}
|
||||
|
||||
/** Color variant for component accent colors (shared across web + React Native) */
|
||||
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
||||
|
||||
/** Maps DTVariant to CSS custom property names */
|
||||
export const variantToCSSProperty: Record<DTVariant, string> = {
|
||||
normal: '--mode-normal',
|
||||
emphasis: '--mode-emphasis',
|
||||
warning: '--mode-warning',
|
||||
success: '--mode-success',
|
||||
other: '--mode-other',
|
||||
};
|
||||
|
||||
/** Maps DTVariant to CSS class names */
|
||||
export const variantToClassName: Record<DTVariant, string> = {
|
||||
normal: 'mode-normal',
|
||||
emphasis: 'mode-emphasis',
|
||||
warning: 'mode-warning',
|
||||
success: 'mode-success',
|
||||
other: 'mode-other',
|
||||
};
|
||||
|
||||
/** Complete brand definition */
|
||||
export interface BrandTokens {
|
||||
id: ThemeBrand;
|
||||
|
||||
@@ -52,16 +52,29 @@ import { themes, brands } from "@dangerousthings/web/theme-registry";
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `bevels.css` | Angular clip-path bevels for cards, buttons, labels, modals, drawers |
|
||||
| `glows.css` | Neon drop-shadow and text-shadow effects for clipped and standard elements |
|
||||
| `forms-dt.css` | Text inputs, checkboxes, switches, radio buttons, progress bars, accordions, quantity steppers |
|
||||
| `bevels.css` | Angular clip-path bevels for cards, buttons, labels, modals, drawers. Card color modes, selected states, progress bars, badge overlays, interactive bevel buttons |
|
||||
| `glows.css` | Neon drop-shadow and text-shadow effects — mode-aware via `--dt-glow-color` |
|
||||
| `forms-dt.css` | Text inputs, checkboxes, switches, radio buttons, progress bars, accordions, steppers, menu items, filter headers, filter overlays |
|
||||
| `animations.css` | Entrance animations (scale-in, fade-in, slide-up), interactive animations (pulse, ping, spin), stagger container, transition utilities |
|
||||
| `scrollbar.css` | Thin neon scrollbar styling scoped under `[data-brand="dt"]` |
|
||||
| `feature-legend.css` | Product feature grid with icons and rotated labels, state-based coloring |
|
||||
|
||||
### Key Classes
|
||||
|
||||
**Bevels** — `.dt-bevel-card`, `.dt-bevel-btn`, `.dt-bevel-label`, `.dt-bevel-modal`, `.dt-bevel-drawer-left`, `.dt-bevel-drawer-right`, `.dt-bevel-sm`
|
||||
|
||||
**Card Modes** — `.mode-normal`, `.mode-emphasis`, `.mode-warning`, `.mode-success`, `.mode-other`, `.card.selected`, `.dt-card-progress`, `.dt-badge-overlay`
|
||||
|
||||
**Interactive Buttons** — `.dt-btn` (outlined rectangle, bevels on hover/select)
|
||||
|
||||
**Glows** — `.dt-glow`, `.dt-glow-strong`, `.dt-glow-inset`, `.dt-text-glow`
|
||||
|
||||
**Animations** — `.dt-animate-scale-in`, `.dt-animate-fade-in`, `.dt-animate-slide-up`, `.dt-animate-pulse`, `.dt-animate-ping`, `.dt-animate-spin`, `.dt-stagger-container`
|
||||
|
||||
**Scrollbar** — `.dt-scrollbar`, `.dt-scrollbar-mode`
|
||||
|
||||
**Filters** — `.dt-menu-item`, `.dt-filter-header`, `.dt-filter-overlay`
|
||||
|
||||
## Exports
|
||||
|
||||
| Path | Description |
|
||||
|
||||
@@ -28,4 +28,7 @@
|
||||
--bevel-sm: 0px;
|
||||
--bevel-md: 0px;
|
||||
--bevel-lg: 0px;
|
||||
|
||||
/* Interactive bevel corner size (buttons/menu items on hover) */
|
||||
--menu-item-corner: 1em;
|
||||
}
|
||||
|
||||
137
packages/web/src/components/animations.css
Normal file
137
packages/web/src/components/animations.css
Normal file
@@ -0,0 +1,137 @@
|
||||
/* dt-web-theme: Animation Library */
|
||||
/* Source: dt-shopify-storefront animation patterns */
|
||||
/* Brand-independent — no color references in keyframes */
|
||||
|
||||
/* ============================================================================
|
||||
Entrance Animations
|
||||
============================================================================ */
|
||||
|
||||
@keyframes dt-scale-in {
|
||||
from { transform: scale(0); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes dt-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dt-slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Interactive Animations
|
||||
============================================================================ */
|
||||
|
||||
@keyframes dt-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes dt-ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dt-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Utility Classes
|
||||
============================================================================ */
|
||||
|
||||
.dt-animate-scale-in {
|
||||
animation: dt-scale-in 0.33s ease-in-out both;
|
||||
}
|
||||
|
||||
.dt-animate-fade-in {
|
||||
animation: dt-fade-in 0.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.dt-animate-slide-up {
|
||||
animation: dt-slide-up 0.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.dt-animate-pulse {
|
||||
animation: dt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.dt-animate-ping {
|
||||
animation: dt-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.dt-animate-spin {
|
||||
animation: dt-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Stagger Container
|
||||
Source: storefront framer-motion staggered card entrance
|
||||
Usage: <div class="dt-stagger-container">...</div>
|
||||
Customize: --dt-stagger-duration, --dt-stagger-interval
|
||||
============================================================================ */
|
||||
|
||||
.dt-stagger-container {
|
||||
--dt-stagger-duration: 0.33s;
|
||||
--dt-stagger-interval: 75ms;
|
||||
}
|
||||
|
||||
.dt-stagger-container > * {
|
||||
animation: dt-scale-in var(--dt-stagger-duration) ease-in-out both;
|
||||
}
|
||||
|
||||
.dt-stagger-container > :nth-child(1) { animation-delay: calc(0 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(2) { animation-delay: calc(1 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(3) { animation-delay: calc(2 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(4) { animation-delay: calc(3 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(5) { animation-delay: calc(4 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(6) { animation-delay: calc(5 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(7) { animation-delay: calc(6 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(8) { animation-delay: calc(7 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(9) { animation-delay: calc(8 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(10) { animation-delay: calc(9 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(11) { animation-delay: calc(10 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(12) { animation-delay: calc(11 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(13) { animation-delay: calc(12 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(14) { animation-delay: calc(13 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(15) { animation-delay: calc(14 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(16) { animation-delay: calc(15 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(17) { animation-delay: calc(16 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(18) { animation-delay: calc(17 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(19) { animation-delay: calc(18 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(20) { animation-delay: calc(19 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(21) { animation-delay: calc(20 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(22) { animation-delay: calc(21 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(23) { animation-delay: calc(22 * var(--dt-stagger-interval)); }
|
||||
.dt-stagger-container > :nth-child(24) { animation-delay: calc(23 * var(--dt-stagger-interval)); }
|
||||
|
||||
.dt-stagger-container > :nth-child(n+25) {
|
||||
animation-delay: calc(24 * var(--dt-stagger-interval));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Transition Utilities
|
||||
============================================================================ */
|
||||
|
||||
.dt-transition-accordion {
|
||||
transition: max-height 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.dt-transition-chevron {
|
||||
transition: transform 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.dt-transition-chevron[aria-expanded="true"],
|
||||
.dt-transition-chevron.is-expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dt-transition-progress {
|
||||
transition: height 300ms ease-out, width 300ms ease-out;
|
||||
}
|
||||
@@ -17,19 +17,22 @@
|
||||
============================================================================ */
|
||||
[data-brand="dt"] .dt-bevel-card,
|
||||
[data-brand="dt"] .card {
|
||||
--dt-card-progress: 0;
|
||||
--_card-pad: var(--space-6);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: var(--color-primary);
|
||||
background: var(--dt-card-color, 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))
|
||||
);
|
||||
padding: var(--_card-pad);
|
||||
padding-left: calc(var(--bevel-sm) + var(--_card-pad));
|
||||
overflow: clip;
|
||||
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,
|
||||
@@ -39,41 +42,109 @@
|
||||
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))
|
||||
);
|
||||
/* Inner clip-path: left edge at bevel-sm to create progress bar zone.
|
||||
The progress bar occupies x=0 to x=bevel-sm with its own borders.
|
||||
Inner surface starts at bevel-sm, so the left frame is wider than 3px. */
|
||||
clip-path: polygon(var(--bevel-sm) 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));
|
||||
}
|
||||
|
||||
/* Card Progress Bar — structural fill area on the left edge
|
||||
Sits between the 3px frame borders, follows bottom-left bevel diagonal.
|
||||
Gradient fills from bottom (accent) to top (surface) based on --dt-card-progress.
|
||||
Filled portion: fully opaque accent color. Unfilled portion: semi-transparent surface.
|
||||
At 0%: all semi-transparent surface. At 100%: all opaque accent.
|
||||
Override --dt-progress-empty-opacity to control unfilled portion transparency. */
|
||||
[data-brand="dt"] .dt-bevel-card::after,
|
||||
[data-brand="dt"] .card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
|
||||
clip-path: polygon(3px 3px,
|
||||
calc(var(--bevel-sm) - 3px) 3px,
|
||||
calc(var(--bevel-sm) - 3px) calc(100% - 6px),
|
||||
3px calc(100% - var(--bevel-sm)));
|
||||
|
||||
background: linear-gradient(to top,
|
||||
rgba(var(--dt-card-color-rgb, var(--color-primary-rgb)), 1) calc(var(--dt-card-progress, 0) * 1%),
|
||||
rgba(var(--color-surface-rgb),
|
||||
var(--dt-progress-empty-opacity, 0.6)) calc(var(--dt-card-progress, 0) * 1%));
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card children — ensure body content renders above the progress bar (::after z-index:1) */
|
||||
[data-brand="dt"] .dt-bevel-card > *:not(.dt-card-badge),
|
||||
[data-brand="dt"] .card > *:not(.dt-card-badge) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Card Header — storefront .card-header pattern
|
||||
Title extends edge-to-edge on the card's primary bg (cyan).
|
||||
Title starts at bevel-sm (past the progress bar zone) and extends to the right edge.
|
||||
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);
|
||||
[data-brand="dt"] .card>.card-title,
|
||||
[data-brand="dt"] .dt-bevel-card>.card-title {
|
||||
/* Pull title toward card edges to sit flush inside the 3px frame border.
|
||||
Uses 2px inset (1px overlap into the frame border) on top/right/left
|
||||
to prevent sub-pixel anti-aliasing gaps. The overlap is invisible
|
||||
because the header and frame border share the same accent color.
|
||||
Left edge lands at bevel-sm - 1px, keeping the progress bar column
|
||||
visible alongside the header (the progress bar spans full card height). */
|
||||
margin: calc(2px - var(--_card-pad)) calc(2px - var(--_card-pad)) var(--space-4) calc(-1px - var(--_card-pad));
|
||||
padding: 1rem calc(var(--_card-pad) + 1px) 1rem calc(var(--_card-pad) + 1px);
|
||||
/* Sits above ::before so its own bg covers the dark surface */
|
||||
background: var(--color-primary);
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 1rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Force icon spans to inherit header color (overrides inline styles) */
|
||||
[data-brand="dt"] .card > .card-title > * {
|
||||
[data-brand="dt"] .card>.card-title>* {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* Full-bleed card body — pulls content flush against the inner frame border.
|
||||
Use for images/media that should fill the card body edge-to-edge.
|
||||
Margins align with the card's inner surface edges so the card's structural
|
||||
3px border is the only visible border (no double-border). */
|
||||
[data-brand="dt"] .card>.card-body-flush,
|
||||
[data-brand="dt"] .dt-bevel-card>.card-body-flush {
|
||||
margin: 0 calc(3px - var(--_card-pad)) calc(3px - var(--_card-pad)) calc(-1 * var(--_card-pad));
|
||||
overflow: hidden;
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
}
|
||||
|
||||
/* Bottom-right bevel on card body content (img/video). Clip-path matches
|
||||
the card's inner surface bevel so the 3px structural border is visible
|
||||
at the diagonal corner. Badge is not clipped (it's a sibling, not img). */
|
||||
[data-brand="dt"] .card-body-flush > img,
|
||||
[data-brand="dt"] .card-body-flush > video {
|
||||
display: block;
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - var(--bevel-md) + 3px),
|
||||
calc(100% - var(--bevel-md) + 3px) 100%,
|
||||
0% 100%);
|
||||
}
|
||||
|
||||
/* Remove gap between card title and full-bleed body */
|
||||
[data-brand="dt"] .card>.card-title + .card-body-flush,
|
||||
[data-brand="dt"] .dt-bevel-card>.card-title + .card-body-flush {
|
||||
margin-top: calc(-1 * var(--space-4));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Button Bevels — top-right corner cut
|
||||
Source: storefront .label-clipped pattern (solid fill + clip-path)
|
||||
@@ -83,13 +154,11 @@
|
||||
[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%
|
||||
);
|
||||
clip-path: polygon(0% 0%,
|
||||
calc(100% - var(--bevel-sm)) 0%,
|
||||
100% var(--bevel-sm),
|
||||
100% 100%,
|
||||
0% 100%);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .btn-secondary {
|
||||
@@ -97,13 +166,11 @@
|
||||
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%
|
||||
);
|
||||
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 {
|
||||
@@ -112,13 +179,11 @@
|
||||
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)
|
||||
);
|
||||
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 {
|
||||
@@ -141,13 +206,11 @@
|
||||
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%
|
||||
);
|
||||
clip-path: polygon(0% 0%,
|
||||
calc(100% - var(--bevel-sm)) 0%,
|
||||
100% var(--bevel-sm),
|
||||
100% 100%,
|
||||
0% 100%);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -158,13 +221,11 @@
|
||||
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)
|
||||
);
|
||||
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 {
|
||||
@@ -204,23 +265,19 @@
|
||||
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%
|
||||
);
|
||||
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%
|
||||
);
|
||||
clip-path: polygon(0% 0%,
|
||||
calc(100% - var(--bevel-sm)) 0%,
|
||||
100% var(--bevel-sm),
|
||||
100% 100%,
|
||||
0% 100%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -229,28 +286,91 @@
|
||||
Source: storefront .media-frame-clipped pattern
|
||||
============================================================================ */
|
||||
.dt-bevel-media {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
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));
|
||||
}
|
||||
|
||||
/* Inner surface fill — shows as background when no image is loaded */
|
||||
.dt-bevel-media::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface);
|
||||
z-index: -1;
|
||||
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)
|
||||
);
|
||||
calc(var(--bevel-md) + 1px) 3px,
|
||||
calc(100% - 3px) 3px,
|
||||
calc(100% - 3px) calc(100% - var(--bevel-md) - 1px),
|
||||
calc(100% - var(--bevel-md) - 1px) calc(100% - 3px),
|
||||
3px calc(100% - 3px),
|
||||
3px calc(var(--bevel-md) + 1px));
|
||||
}
|
||||
|
||||
.dt-bevel-media > img,
|
||||
.dt-bevel-media > video {
|
||||
display: block;
|
||||
clip-path: polygon(
|
||||
calc(var(--bevel-md) + 1px) 3px,
|
||||
calc(100% - 3px) 3px,
|
||||
calc(100% - 3px) calc(100% - var(--bevel-md) - 1px),
|
||||
calc(100% - var(--bevel-md) - 1px) calc(100% - 3px),
|
||||
3px calc(100% - 3px),
|
||||
3px calc(var(--bevel-md) + 1px));
|
||||
}
|
||||
|
||||
/* Media frame placeholder — shows when no image/video is present.
|
||||
Surface-colored background is already provided by ::before.
|
||||
This adds centered "MISSING MEDIA" text via ::after. */
|
||||
.dt-bevel-media::after {
|
||||
content: 'MISSING MEDIA';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: -1;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card body placeholder — shows when image container is empty or image fails.
|
||||
Uses surface background and centered placeholder text. */
|
||||
.dt-badge-parent:empty::after,
|
||||
.card-body-flush:empty::after {
|
||||
content: 'MISSING MEDIA';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
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))
|
||||
);
|
||||
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)));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -258,39 +378,33 @@
|
||||
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))
|
||||
);
|
||||
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%
|
||||
);
|
||||
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)
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -300,3 +414,156 @@
|
||||
[data-brand="dt"] .terminal {
|
||||
border-top: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Card Color Modes — per-instance color overrides
|
||||
Sets --dt-card-color, --dt-card-color-selected, --dt-card-color-rgb,
|
||||
--dt-glow-color, and --accent-mode for use by all card sub-components.
|
||||
Source: dt-shopify-storefront .mode-* classes
|
||||
============================================================================ */
|
||||
[data-brand="dt"] .mode-normal {
|
||||
--dt-card-color: var(--mode-normal);
|
||||
--dt-card-color-rgb: var(--mode-normal-rgb);
|
||||
--dt-card-color-selected: var(--mode-normal-selected);
|
||||
--dt-glow-color: var(--mode-normal);
|
||||
--accent-mode: var(--mode-emphasis);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .mode-emphasis {
|
||||
--dt-card-color: var(--mode-emphasis);
|
||||
--dt-card-color-rgb: var(--mode-emphasis-rgb);
|
||||
--dt-card-color-selected: var(--mode-emphasis-selected);
|
||||
--dt-glow-color: var(--mode-emphasis);
|
||||
--accent-mode: var(--mode-normal);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .mode-warning {
|
||||
--dt-card-color: var(--mode-warning);
|
||||
--dt-card-color-rgb: var(--mode-warning-rgb);
|
||||
--dt-card-color-selected: var(--mode-warning-selected);
|
||||
--dt-glow-color: var(--mode-warning);
|
||||
--accent-mode: var(--mode-emphasis);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .mode-success {
|
||||
--dt-card-color: var(--mode-success);
|
||||
--dt-card-color-rgb: var(--mode-success-rgb);
|
||||
--dt-card-color-selected: var(--mode-success-selected);
|
||||
--dt-glow-color: var(--mode-success);
|
||||
--accent-mode: var(--mode-emphasis);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .mode-other {
|
||||
--dt-card-color: var(--mode-other);
|
||||
--dt-card-color-rgb: var(--mode-other-rgb);
|
||||
--dt-card-color-selected: var(--mode-other-selected);
|
||||
--dt-glow-color: var(--mode-other);
|
||||
--accent-mode: var(--mode-normal);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Card Badge — bottom-right product type chip (LAB, BUNDLE, etc.)
|
||||
Source: dt-shopify-storefront CyberProductCard LabChip / BundleChip
|
||||
Plain flat rectangle. Inherits card color via --dt-card-color.
|
||||
============================================================================ */
|
||||
/* Badge parent — provides position context for absolutely-positioned badges.
|
||||
Use on the image container inside a card, or on any element that wraps content + badge. */
|
||||
.dt-badge-parent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dt-card-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
z-index: 4;
|
||||
padding: 2px calc(8px + 0.2rem) 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
background-color: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
/* Inside card body-flush, offset badge position to compensate for the
|
||||
3px margin from the card edge. This makes the badge position relative
|
||||
to the card's bevel corner match the media frame's placement exactly
|
||||
(8px from card edge in both cases). */
|
||||
.card-body-flush > .dt-card-badge {
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Interactive Bevel Button — storefront .menu-item-clipped pattern
|
||||
Default: outlined rectangle, no clip-path, 5px top border
|
||||
Hover: bottom-right bevel appears, fills with mode color, pulse animation
|
||||
Selected: bevel persists, fills with 70% opacity mode color
|
||||
============================================================================ */
|
||||
[data-brand="dt"] .dt-btn {
|
||||
color: var(--accent-mode, var(--color-primary));
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--dt-card-color, var(--color-primary));
|
||||
border-top: 5px solid var(--dt-card-color, var(--color-primary));
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: visible;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
clip-path var(--transition-fast);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-btn:hover {
|
||||
clip-path: polygon(0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - var(--menu-item-corner)),
|
||||
calc(100% - var(--menu-item-corner)) 100%,
|
||||
0% 100%);
|
||||
color: var(--color-bg);
|
||||
background-color: var(--dt-card-color, var(--color-primary));
|
||||
animation: dt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-btn.selected {
|
||||
clip-path: polygon(0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - var(--menu-item-corner)),
|
||||
calc(100% - var(--menu-item-corner)) 100%,
|
||||
0% 100%);
|
||||
background-color: var(--dt-card-color-selected, rgba(var(--color-primary-rgb), 0.7));
|
||||
color: var(--color-bg);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-btn.selected:hover {
|
||||
animation: dt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Classic brand: no clip-path, uses border-radius instead */
|
||||
[data-brand="classic"] .dt-btn {
|
||||
border-radius: var(--radius);
|
||||
border-top-width: 2px;
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-btn:hover {
|
||||
clip-path: none;
|
||||
background-color: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-btn.selected {
|
||||
clip-path: none;
|
||||
background-color: var(--dt-card-color-selected, rgba(var(--color-primary-rgb), 0.7));
|
||||
color: var(--color-bg);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
56
packages/web/src/components/feature-legend.css
Normal file
56
packages/web/src/components/feature-legend.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* dt-web-theme: Feature Legend Layout */
|
||||
/* Source: dt-shopify-storefront UseCaseLegend component */
|
||||
|
||||
/* ============================================================================
|
||||
Feature Legend — icon grid with rotated labels
|
||||
============================================================================ */
|
||||
.dt-feature-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dt-feature-legend-header {
|
||||
padding: 8px 16px;
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dt-feature-legend-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dt-feature-legend-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
flex: 0 0 20%; /* 5 columns */
|
||||
}
|
||||
|
||||
.dt-feature-legend-icon {
|
||||
font-size: 42px;
|
||||
line-height: 1;
|
||||
color: var(--dt-feature-color, var(--color-primary));
|
||||
}
|
||||
|
||||
.dt-feature-legend-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--dt-feature-color, var(--color-text-primary));
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Feature state color classes */
|
||||
.dt-feature-supported { --dt-feature-color: var(--mode-normal, var(--color-primary)); }
|
||||
.dt-feature-disabled { --dt-feature-color: var(--mode-emphasis, var(--color-secondary)); }
|
||||
.dt-feature-unsupported { --dt-feature-color: var(--mode-warning, var(--color-error)); }
|
||||
@@ -327,3 +327,135 @@
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Menu Item — beveled clipped style with thick top border
|
||||
Source: dt-shopify-storefront .menu-item-clipped pattern
|
||||
============================================================================ */
|
||||
[data-brand="dt"] .dt-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--dt-card-color, var(--color-primary));
|
||||
border-top: 5px solid var(--dt-card-color, var(--color-primary));
|
||||
background: var(--color-bg);
|
||||
color: var(--accent-mode, var(--color-primary));
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-menu-item:hover {
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - var(--menu-item-corner)),
|
||||
calc(100% - var(--menu-item-corner)) 100%,
|
||||
0% 100%
|
||||
);
|
||||
background-color: var(--dt-card-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
animation: dt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-menu-item.active,
|
||||
[data-brand="dt"] .dt-menu-item[aria-expanded="true"] {
|
||||
color: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-menu-item.selected {
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - var(--menu-item-corner)),
|
||||
calc(100% - var(--menu-item-corner)) 100%,
|
||||
0% 100%
|
||||
);
|
||||
background-color: var(--dt-card-color-selected, rgba(var(--color-primary-rgb), 0.7));
|
||||
color: var(--color-bg);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-menu-item.selected:hover {
|
||||
animation: dt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Nested level indentation */
|
||||
.dt-menu-item[style*="--dt-menu-level"] {
|
||||
padding-left: calc(0.75rem + var(--dt-menu-level, 0) * 1rem);
|
||||
}
|
||||
|
||||
/* Classic brand: rounded, thinner borders */
|
||||
[data-brand="classic"] .dt-menu-item {
|
||||
border-top-width: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-menu-item:hover {
|
||||
clip-path: none;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-menu-item.selected {
|
||||
clip-path: none;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Filter Header — thick border accent bar
|
||||
Source: dt-shopify-storefront FilterAccordion header
|
||||
============================================================================ */
|
||||
[data-brand="dt"] .dt-filter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 5px solid var(--color-primary);
|
||||
border-bottom: 1px solid rgba(var(--color-primary-rgb), 0.2);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-filter-header.active {
|
||||
color: var(--color-secondary);
|
||||
border-top-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
[data-brand="classic"] .dt-filter-header {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Mobile Filter Overlay
|
||||
Source: dt-shopify-storefront MobileFilterMenu
|
||||
============================================================================ */
|
||||
.dt-filter-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dt-filter-overlay-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dt-filter-overlay-content {
|
||||
position: relative;
|
||||
margin-top: auto;
|
||||
max-height: 85vh;
|
||||
background: var(--color-bg);
|
||||
overflow-y: auto;
|
||||
animation: dt-slide-up 0.3s ease-in-out both;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
Link Glow
|
||||
============================================================================ */
|
||||
[data-brand="dt"] a:hover {
|
||||
text-shadow: 0 0 8px var(--color-primary);
|
||||
text-shadow: 0 0 8px var(--dt-glow-color, var(--color-primary));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -49,8 +49,9 @@
|
||||
/* ============================================================================
|
||||
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));
|
||||
[data-brand="dt"] .card:hover,
|
||||
[data-brand="dt"] .dt-bevel-card:hover {
|
||||
filter: drop-shadow(0 0 8px rgba(var(--dt-card-color-rgb, var(--color-primary-rgb)), 0.3));
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
39
packages/web/src/components/scrollbar.css
Normal file
39
packages/web/src/components/scrollbar.css
Normal file
@@ -0,0 +1,39 @@
|
||||
/* dt-web-theme: Scrollbar Styling */
|
||||
/* Source: dt-shopify-storefront cyberpunk scrollbar pattern */
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-primary) transparent;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Mode-aware scrollbar: uses local --dt-card-color if set */
|
||||
[data-brand="dt"] .dt-scrollbar-mode {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dt-card-color, var(--color-primary)) transparent;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar-mode::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar-mode::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-brand="dt"] .dt-scrollbar-mode::-webkit-scrollbar-thumb {
|
||||
background: var(--dt-card-color, var(--color-primary));
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -15,3 +15,6 @@
|
||||
@import './components/bevels.css';
|
||||
@import './components/glows.css';
|
||||
@import './components/forms-dt.css';
|
||||
@import './components/animations.css';
|
||||
@import './components/scrollbar.css';
|
||||
@import './components/feature-legend.css';
|
||||
|
||||
Reference in New Issue
Block a user