Compare commits

..

43 Commits

Author SHA1 Message Date
michael
0cfc945960 Fixed type in label 2026-05-05 14:16:32 -07:00
michael
77c6a5a3bf feat(DTGallery): add optional activeIndex prop for external control
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:42:10 -07:00
michael
27824ae189 chore(hex-background): bump 0.2.1, changelog for animation fix 2026-04-13 16:28:59 -07:00
michael
1f67ebf48a feat(hex-background): pass animationDuration through HexGridBackground 2026-04-13 16:26:17 -07:00
michael
5112e5c057 fix(hex-background): replace lerp with duration-based easing
Hexagons now interpolate via smoothstep over a fixed duration instead of
frame-rate-dependent lerp. Prevents height convergence to mean over time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:23:28 -07:00
michael
1be0070abf chore: version packages (web 0.4.1, react 2.0.1, react-native 0.4.1)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:26:57 -07:00
michael
c8c92d187d chore: version packages (web 0.4.0, react 2.0.0, react-native 0.4.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:14 -07:00
michael
195c47602d feat: video letterboxing, card badge styling, component updates
- Add object-fit: contain + black background for video in DTMediaFrame
- Style .dt-card-badge with mode-colored background, padding, and
  bevel-aware positioning (negative right offset for clean diagonal clip)
- Restore DTStaggerContainer in react-native (was incorrectly deleted)
- Update DTGallery, DTModal, DTMobileFilterOverlay, DTMediaFrame components
- Refresh showcase pages for desktop and mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:18:49 -07:00
michael
d1e2bf1964 fix: tune card badge position to 25% of bevel-md for responsive corner clipping 2026-03-13 05:03:46 -07:00
michael
926a282535 refactor: update showcase to use DTCard badge prop 2026-03-13 04:44:24 -07:00
michael
2dcf86c346 feat: add badge prop to DTCard for bevel-clipped badge slot 2026-03-12 19:21:11 -07:00
michael
8a91b21a11 feat: add .dt-card-badge positioning rule for card-level badge slot 2026-03-12 17:22:38 -07:00
michael
766266b799 Apply inner bevel clip-path to model-viewer and iframe elements
Extend the .dt-bevel-media inner clip-path selector to cover
model-viewer and iframe children, matching the existing treatment
for img and video. Without this, 3D viewers and embedded video
iframes filled edge-to-edge and covered the neon bevel border.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:26:48 -07:00
michael
36420868f5 Fix card-body-flush child bevel clip-path alignment
The child clip-path used bevel-md - 3px from child corner, but should
use bevel-md to align with the ::before inner surface diagonal. This
caused the bottom-right bevel border to appear too thin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:51:56 -07:00
michael
c8afd90527 Refactor DTStaggerContainer: wrapper-div approach with delay/interval props
Replace CSS nth-child stagger rules with JS-driven wrapper divs that set
animationDelay inline. Adds delay (base offset) and interval (ms between
children) props. Removes --dt-stagger-interval CSS variable and 24
nth-child rules in favor of unlimited children support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:18:12 -07:00
michael
34be1d0b9e DTCard: make progress optional, fill top-to-bottom
Progress bar now fills top-down (was bottom-up). When progress prop is
omitted the bar stays empty and the card renders at full size. Only cards
that explicitly pass progress={0–100} show the animated bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:04:15 -07:00
michael
6816ef4825 Add documentLevel mode to DTWebThemeProvider
When documentLevel is true, sets data-brand and data-theme on
document.documentElement instead of rendering a wrapper div.
This is needed for apps where CSS ancestor selectors must match
from the document root (e.g. [data-brand="dt"] .card).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:39:25 -07:00
michael
b319f15092 Fix feature legend data accuracy from storefront models
- NExT v2: fix access control (Legacy, not DESFire/iCLASS), digital
  security (unsupported), magic (supported via T5577), illumination
  (supported HF LED), data sharing (1 kB), payment (unsupported)
- Replace Titan with m0422a magnet: RP 22,860 G/g sensing, ~20 g
  lifting, Push/Pull haptics, Monopole polarity detection
- xLED HF: correct illumination colors and Low prominence
- All data sourced from dt-shopify-storefront model definitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:12:03 -07:00
michael
68234e66f4 Add repository field to showcase desktop package.json
electron-builder requires a repository field to detect the
GitHub repo for publishing artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:41:03 -07:00
michael
2c9f7b8495 Fix showcase release: build all design system packages
Replace selective turbo filters with full build to ensure all
dependencies (tokens, web, tailwind-preset, hex-background, react)
are available for both desktop and mobile showcase builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:04:53 -07:00
michael
b137a99c78 Fix showcase release: add tailwind-preset to build filter
Desktop showcase depends on @dangerousthings/tailwind-preset
via its Tailwind config. Add it to the turbo build filter
alongside tokens and web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:40:42 -07:00
michael
4f477a50d8 Add Windows and macOS builds to showcase release workflow
- Add desktop-windows job (NSIS installer + portable exe)
- Add desktop-macos job (dmg + zip, unsigned, x64 + arm64)
- Update create-release to collect artifacts from all platforms
- Update release notes with all artifact types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:59:20 -07:00
michael
741ee69079 Fix package-lock.json sync for CI
Regenerate lock file without --legacy-peer-deps to include
all resolved peer dependencies (react, react-native, etc.)
that npm ci requires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:22:05 -07:00
michael
89aaac9548 Merge branch 'main' of https://github.com/dangerous-tac0s/dt-design-system 2026-03-08 10:07:23 -07:00
michael
09143edab8 Add hex background to showcase with interactive controls
- Render HexGridBackground globally on every page
- Add dedicated Hex Background page with sliders for all 8 props
- Lift hex props state to App for real-time global updates
- Add three, @react-three/fiber, hex-background deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:05:47 -07:00
michael
5d2b245f5a Update docs to reflect current 6-package architecture
- README: add all 6 packages, React/Tailwind usage sections, remove glows refs
- CLAUDE.md: rewrite for 6 packages, 4-layer parity, build.cjs, storefront migration as implemented reference
- Package READMEs: fix monorepo links, remove glows references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:04:41 -07:00
michael
0cfa83259e Remove glows, refine feature legend, forms, and showcase pages
- Delete glows.css and GlowsPage (unused)
- Enhance DTFeatureLegend and feature-legend.css with improved layout
- Update DTRadioGroup styling
- Refine bevels.css, forms-dt.css
- Update AnimationsPage, CardsAdvancedPage, FormsPage, HomePage
- Add type:module to web package.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:03:48 -07:00
dangerous-tac0s
3d9ec57523 Merge pull request #4 from dangerous-tac0s/changeset-release/main
chore: version packages
2026-03-07 11:25:43 -08:00
github-actions[bot]
b9eb83fa23 chore: version packages 2026-03-07 19:11:50 +00:00
michael
54b58a6ac6 Merge branch 'main' of https://github.com/dangerous-tac0s/dt-design-system 2026-03-07 11:10:36 -08:00
michael
568a1490e3 Add changeset for tailwind-preset npm readiness
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:10:11 -08:00
dangerous-tac0s
a7fa6fba84 Merge pull request #3 from dangerous-tac0s/changeset-release/main
chore: version packages
2026-03-07 11:04:14 -08:00
github-actions[bot]
2d3a693797 chore: version packages 2026-03-07 18:44:38 +00:00
michael
358ecb05cb Prepare all packages for npm publishing
- Add author, repository, homepage, keywords, and engines fields to
  all 6 publishable package.json files
- Add README.md to react, tailwind-preset, and hex-background packages
- Add "type": "module" to web and react-native for consistency
- Rename web build script to .cjs for ESM package compatibility
- Remove overly broad "./dist/*" export from web package

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:43:42 -08:00
michael
30a0510a5e Fix CI build failure and add missing LICENSE files
Add "require" export condition to tailwind-preset for Node 18 CJS
compatibility (jiti resolution). Add LICENSE files to react,
tailwind-preset, and hex-background packages. Add license field to
showcase packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:31:55 -08:00
michael
21acd98260 More fine tuning 2026-03-06 10:18:39 -08:00
michael
8a8d4d4adc Improve showcase pages: drawers, modals, badges, and Vite caching fix
- DTDrawer: storefront-matching dual-element bevel border, slide-in/out
  animations from correct edge, 99vh height, bold 2em header, box shadow
- DTModal: interactive demos on Bevels page with all 5 mode variants
- Badges: solid color fill with contrasting text (black on dark, white on light)
  instead of transparent fill with colored text
- Animations: add dt-slide-right and dt-slide-left keyframes for drawers
- Bevels page: remove trash "Button Bevels", add interactive modal/drawer
  demos, fix media frame to use real images with proper bevel styling
- Advanced Cards: remove redundant media frame sections, rename "Menu Items"
  to "Buttons", remove DTButton/dt-btn references
- Forms page: rename "Menu Items" to "Buttons"
- Vite config: exclude design system packages from optimizeDeps pre-bundling
  and watch dist directories to prevent stale CSS during development

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:39 -08:00
michael
e74c285b70 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>
2026-03-06 10:18:39 -08:00
michael
47e8085581 Make RN components fully theme-driven for multi-brand support
Refactor all 18 React Native components from static DTColors imports to
dynamic useDTTheme() context, enabling proper visual differentiation
when switching between DT and Classic brands.

Key changes:
- Replace static DTColors with theme context in all components
- Add shape tokens (bevel/radius) to DTExtendedTheme interface
- Conditionally render SVG bevels (DT) vs borderRadius (Classic)
- Configure fonts per brand (Tektur for DT, system sans-serif for Classic)
- Update buildThemeFromBrand to parse shape and typography tokens
- Fix Metro config for monorepo singleton resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:39 -08:00
michael
1b0d09313c Add showcase release workflow and gitignore build artifacts
- Remove 541MB of committed Electron build artifacts from git
- Add .gitignore patterns for release/, *.apk, *.aab, *.ipa
- Add GitHub Actions workflow for building showcase releases
  (manual dispatch with version input, builds Linux desktop +
  Android APK, creates GitHub Release with attached artifacts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:39 -08:00
michael
baf8cd3b2a Remove supra brand from design system
Supra (VivoKey MD3 theme) has been extracted into a standalone
Dangerous-Pi theme plugin. Remove all supra references from tokens,
web CSS, showcase apps, and documentation.

- Delete supra brand tokens and elevation.css
- Update ThemeBrand type to "dt" | "classic"
- Remove supra from CSS generation, imports, and bevels
- Update CLAUDE.md and package READMEs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:39 -08:00
michael
da84736b4b Add desktop (Electron) and mobile (Expo) showcase apps
New workspace packages for demonstrating design system components:
- Desktop: Electron + Vite app with brand/theme switching
- Mobile: Expo React Native app with brand switching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:38 -08:00
michael
852a3af854 Fix hardcoded colors in classic tokens and form components
Replace hardcoded hex/opacity values with CSS custom properties for
proper theming support across brands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:18:37 -08:00
213 changed files with 24216 additions and 1096 deletions

267
.github/workflows/showcase-release.yml vendored Normal file
View File

@@ -0,0 +1,267 @@
name: Showcase Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., 0.1.0)'
required: true
type: string
build_desktop:
description: 'Build desktop (Electron) artifacts'
required: false
type: boolean
default: true
build_mobile:
description: 'Build mobile (Android APK) artifact'
required: false
type: boolean
default: true
concurrency:
group: showcase-release
cancel-in-progress: false
jobs:
# ─── Desktop Linux Build ───────────────────────────────────────
desktop-linux:
if: inputs.build_desktop
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build design system packages
run: npx turbo run build
- name: Build desktop showcase
working-directory: packages/showcase/desktop
run: npm run build
- name: Package desktop (Linux)
working-directory: packages/showcase/desktop
run: npx electron-builder --linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload desktop artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: |
packages/showcase/desktop/release/*.AppImage
packages/showcase/desktop/release/*.deb
retention-days: 30
# ─── Desktop Windows Build ──────────────────────────────────────
desktop-windows:
if: inputs.build_desktop
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build design system packages
run: npx turbo run build
- name: Build desktop showcase
working-directory: packages/showcase/desktop
run: npm run build
- name: Package desktop (Windows)
working-directory: packages/showcase/desktop
run: npx electron-builder --win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload desktop artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: |
packages/showcase/desktop/release/*.exe
retention-days: 30
# ─── Desktop macOS Build ────────────────────────────────────────
desktop-macos:
if: inputs.build_desktop
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build design system packages
run: npx turbo run build
- name: Build desktop showcase
working-directory: packages/showcase/desktop
run: npm run build
- name: Package desktop (macOS)
working-directory: packages/showcase/desktop
run: npx electron-builder --mac --x64 --arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No CSC_LINK / CSC_KEY_PASSWORD = unsigned build
# Users will need to right-click > Open to bypass Gatekeeper
- name: Upload desktop artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-macos
path: |
packages/showcase/desktop/release/*.dmg
packages/showcase/desktop/release/*.zip
retention-days: 30
# ─── Mobile Android Build ──────────────────────────────────────
mobile-android:
if: inputs.build_mobile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install dependencies
run: npm ci
- name: Build design system packages
run: npx turbo run build
- name: Expo prebuild
working-directory: packages/showcase/mobile
run: npx expo prebuild --clean --platform android
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('packages/showcase/mobile/android/**/*.gradle*', 'packages/showcase/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build release APK
working-directory: packages/showcase/mobile/android
run: ./gradlew assembleRelease
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: mobile-android
path: packages/showcase/mobile/android/app/build/outputs/apk/release/app-release.apk
retention-days: 30
# ─── Create GitHub Release ─────────────────────────────────────
create-release:
needs: [desktop-linux, desktop-windows, desktop-macos, mobile-android]
if: always() && (needs.desktop-linux.result == 'success' || needs.desktop-windows.result == 'success' || needs.desktop-macos.result == 'success' || needs.mobile-android.result == 'success')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts
- name: Rename artifacts with version
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
mkdir -p final-artifacts
# Desktop Linux artifacts
if [ -d release-artifacts/desktop-linux ]; then
for f in release-artifacts/desktop-linux/*; do
filename=$(basename "$f")
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
cp "$f" "final-artifacts/$newname"
done
fi
# Desktop Windows artifacts
if [ -d release-artifacts/desktop-windows ]; then
for f in release-artifacts/desktop-windows/*; do
filename=$(basename "$f")
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
cp "$f" "final-artifacts/$newname"
done
fi
# Desktop macOS artifacts
if [ -d release-artifacts/desktop-macos ]; then
for f in release-artifacts/desktop-macos/*; do
filename=$(basename "$f")
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
cp "$f" "final-artifacts/$newname"
done
fi
# Mobile APK
if [ -f release-artifacts/mobile-android/app-release.apk ]; then
cp release-artifacts/mobile-android/app-release.apk \
"final-artifacts/dt-showcase-mobile-${RELEASE_VERSION}.apk"
fi
echo "Release artifacts:"
ls -lh final-artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: showcase-v${{ inputs.version }}
name: Showcase v${{ inputs.version }}
body: |
## DT Design System Showcase v${{ inputs.version }}
### Desktop (Electron)
- **AppImage** (Linux, portable) — download and `chmod +x` to run
- **deb** (Debian/Ubuntu) — install with `sudo dpkg -i <file>.deb`
- **exe** (Windows) — NSIS installer + portable exe
- **dmg** (macOS) — unsigned, right-click > Open to bypass Gatekeeper
- **zip** (macOS) — portable archive
### Mobile (Android)
- **APK** — sideload on any Android device (Settings > Install unknown apps)
---
Built from commit ${{ github.sha }}
files: final-artifacts/*
draft: false
prerelease: false

7
.gitignore vendored
View File

@@ -25,3 +25,10 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
# Showcase build artifacts
release/
*.apk
*.aab
*.ipa
*.xcarchive

127
CLAUDE.md
View File

@@ -2,13 +2,16 @@
## Project Overview
Monorepo for the Dangerous Things design system. Three npm packages publish under `@dangerousthings/*`:
Monorepo for the Dangerous Things design system. Six npm packages publish under `@dangerousthings/*`:
- **tokens** — canonical design tokens in TypeScript, compiled to JS and auto-generated as CSS custom properties
- **web** — CSS themes (bevels, glows, elevation, forms) consumed by web apps
- **web** — CSS themes (bevels, forms, animations) consumed by web apps
- **react** — React web components wrapping web CSS
- **react-native** — React Native components built on React Native Paper
- **tailwind-preset** — Tailwind CSS v3 preset mapping DT tokens
- **hex-background** — 3D hexagon grid background (Three.js)
Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy / magenta), **supra** (MD3 / blue).
Two brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy / magenta).
## Build & Tooling
@@ -16,7 +19,7 @@ Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy /
- Build everything: `npm run build`
- Typecheck everything: `npm run typecheck`
- Tokens build: `tsc` then `node dist/scripts/generate-css.js`
- Web build: `node scripts/build.js` (copies CSS, assembles brand token CSS, copies fonts)
- Web build: `node scripts/build.cjs` (copies CSS, assembles brand token CSS, copies fonts)
- React Native build: `tsc`
- Versioning: Changesets (`npx changeset`, `npm run version-packages`, `npm run release`)
@@ -28,20 +31,22 @@ Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy /
## Feature Parity
When adding or modifying a design token, component, or visual behavior, keep all three layers in sync:
When adding or modifying a design token, component, or visual behavior, keep all four layers in sync:
1. **Tokens** — add/update the value in every brand file (`packages/tokens/src/brands/{dt,classic,supra}.ts`) and in the shared type (`packages/tokens/src/types.ts`).
1. **Tokens** — add/update the value in every brand file (`packages/tokens/src/brands/{dt,classic}.ts`) and in the shared type (`packages/tokens/src/types.ts`).
2. **Web** — if the token maps to a CSS component, update the relevant CSS file under `packages/web/src/components/`. Ensure the CSS custom property name matches the token path (e.g., `--dt-color-brand-primary`).
3. **React Native** — update the corresponding component(s) under `packages/react-native/src/components/` and ensure the theme provider maps the new token.
3. **React** — if there is a React web component wrapper, update or create the corresponding component in `packages/react/src/components/`.
4. **React Native** — update the corresponding component(s) under `packages/react-native/src/components/` and ensure the theme provider maps the new token.
### Parity Checklist (use before closing any PR)
- [ ] New tokens exist in all three brand files with correct values
- [ ] New tokens exist in all brand files with correct values
- [ ] Token types are updated if the shape of the token object changed
- [ ] CSS generation script produces the expected custom properties (`npm run build:tokens` then inspect `dist/css/`)
- [ ] Web CSS files reference the new/changed custom properties
- [ ] React web components are updated if applicable
- [ ] React Native components consume the new tokens via the theme
- [ ] All three packages build cleanly (`npm run build`)
- [ ] All packages build cleanly (`npm run build`)
- [ ] TypeScript typechecks pass (`npm run typecheck`)
## Testing
@@ -65,10 +70,10 @@ When adding or modifying a design token, component, or visual behavior, keep all
### Adding a New Component
1. Create the React Native component in `packages/react-native/src/components/DTFoo.tsx`.
2. Re-export it from `packages/react-native/src/index.ts`.
3. If it needs new tokens, add them to all brand files and types first.
4. If there is a web equivalent (CSS-only), add a CSS file in `packages/web/src/components/`.
1. If it needs new tokens, add them to all brand files and types first.
2. If there is a web equivalent, add a CSS file in `packages/web/src/components/`.
3. Create a React web component wrapper in `packages/react/src/components/DTFoo.tsx` and re-export from `packages/react/src/index.ts`.
4. Create the React Native component in `packages/react-native/src/components/DTFoo.tsx` and re-export from `packages/react-native/src/index.ts`.
5. Create a changeset: `npx changeset` — select affected packages and describe the change.
### Adding a New Brand
@@ -98,3 +103,99 @@ GitHub Actions runs on push to `main` and on PRs:
- CSS uses BEM-ish class names prefixed with `dt-` (e.g., `.dt-bevel-card`).
- React Native components are prefixed `DT` (e.g., `DTButton`).
- Keep files focused — one component per file, one brand per file.
## Storefront Component Migration — Implementation Reference
The DT Shopify Storefront (`dt-shopify-storefront`) is the genesis project for this design system.
The storefront features documented below have been migrated into the design system. This section
serves as a reference for the implemented components and their locations.
### Implemented Features
| Feature | CSS (web) | React Component | Notes |
|---|---|---|---|
| Card Color Modes | `.mode-normal`, `.mode-emphasis`, `.mode-warning`, `.mode-success`, `.mode-other` in `bevels.css` | `DTCard` | Five modes with `*-selected` variants at 70% opacity |
| Card Progress Bar | `.dt-card-progress` in `bevels.css` | `DTCard` (`progress` prop) | Vertical left-edge bar, 0-100%, fills on hover |
| Card Chip Badges | `.dt-badge-overlay` in `bevels.css` | `DTBadgeOverlay` | Positioned bottom-right, mode-color-aware |
| Card Hover/Selected | `.card.selected` and hover states in `bevels.css` | `DTCard` | Fills with `var(--dt-card-color-selected)` |
| Staggered Container | `.dt-stagger-container` in `animations.css` | `DTStaggerContainer` | CSS `nth-child` staggered `animation-delay` |
| Animation Library | `animations.css` | -- | `dt-scale-in`, `dt-fade-in`, `dt-slide-up`, `dt-pulse`, `dt-ping`, `dt-spin`, transition utilities |
| Filter System UI | `.dt-menu-item`, `.dt-filter-header`, `.dt-filter-overlay` in `forms-dt.css` | `DTMenu`, `DTMobileFilterOverlay` | Nested menus, filter accordions, mobile overlay |
| Feature Legend | `feature-legend.css` | `DTFeatureLegend` | Icon grid with rotated labels, mode-colored header, feature states |
| Theme Branching | `[data-brand="dt"]` / `[data-brand="classic"]` CSS scoping | -- | No JS conditionals needed; CSS handles visual differences |
| Scrollbar Styling | `scrollbar.css` (`.dt-scrollbar`) | -- | Thin scrollbar with mode-normal color |
**Card color modes reference:**
| Mode | Token | Default (DT dark) | Use Case |
|---|---|---|---|
| `mode-normal` | `--color-primary` | `#00FFFF` cyan | Default product cards |
| `mode-emphasis` | `--color-secondary` | `#FFFF00` yellow | Highlighted/selected states |
| `mode-warning` | `--color-error` | `#FF0000` red | Lab products, warnings |
| `mode-success` | `--color-accent` | `#00FF00` green | Positive/success states |
| `mode-other` | `--color-other` | `#FF00FF` magenta | Bundles, alternative states |
**Theme branching differences (handled by CSS, not JS):**
| Aspect | Cyberpunk (`data-brand="dt"`) | Clean (`data-brand="classic"`) |
|---|---|---|
| Clip-paths | Heavy bevels | `border-radius: 0.75rem` |
| Colors | Neon (#00FFFF, #FF00FF) | B&W or muted tones |
| Hover effects | Pulse glow animations | None |
| Typography | Tektur | system-ui sans-serif |
| Text shadows | `0 0 8px var(--mode)` | None |
| Borders | Thick neon (5px top) | Subtle 1px |
### Icon Set — Product Features & Filtering
Canonical icon set for the DT design system, sourced from `react-icons`.
**Product Feature Icons (react-icons/md — Material Design):**
| Feature | Icon | Package |
|---|---|---|
| Illumination | `MdLightbulbOutline` | react-icons/md |
| Visibility/Glow | `MdOutlineVisibility` | react-icons/md |
| Data Sharing | `MdOutlineMobileScreenShare` | react-icons/md |
| UID Cloning/Magic | `MdOutlineCopyAll` | react-icons/md |
| Payment/NFC | `MdOutlineCreditCard` | react-icons/md |
| Smartphone Compat | `MdOutlinePhonelinkRing` | react-icons/md |
| Access Control | `MdOutlineVpnKey` | react-icons/md |
| Sensors | `MdOutlineThermostat` | react-icons/md |
| NFC Sensing | `MdOutlineSensors` | react-icons/md |
| Fitness/Bio | `MdOutlineFitbit` | react-icons/md |
| Vibration | `MdOutlineVibration` | react-icons/md |
| Exploration | `MdOutlineExplore` | react-icons/md |
| Cryptography | `LuBinary` | react-icons/lu |
| Digital Security | `FaUserShield` | react-icons/fa |
| Temperature (alt) | `BsThermometerHalf` | react-icons/bs |
**UI Control Icons:**
| Control | Icon | Package |
|---|---|---|
| Filter toggle | `MdFilterList` | react-icons/md |
| Expand/collapse | `MdExpandMore` | react-icons/md |
| Close/dismiss | `MdClose` | react-icons/md |
| Dropdown arrow | `MdOutlineKeyboardArrowDown` | react-icons/md |
| No image placeholder | `MdOutlineHideImage` | react-icons/md |
| More details | `CgMoreR` | react-icons/cg |
| Loading spinner | `FaSpinner` | react-icons/fa6 |
| Cart | `FaCartShopping` | react-icons/fa6 |
| Add to cart | `FaCartPlus` | react-icons/fa6 |
| Search | `FaMagnifyingGlass` | react-icons/fa6 |
| Back | `FaArrowLeft` | react-icons/fa6 |
| User (logged in) | `FaUser` | react-icons/fa6 |
| User (logged out) | `FaUserSlash` | react-icons/fa6 |
| Delete | `FaTrash` | react-icons/fa |
**Custom SVG Icons (ThemeToggle.tsx):**
- Sun icon (24x24) — switch to clean theme
- Terminal/matrix icon (24x24) — switch to cyberpunk theme
**Icon display standard:** Feature icons render at 42px in the UseCaseLegend grid. UI icons at 1.5em.
Icons inherit color from their parent's `--mode` variable.
## Known Issues
(none currently)

View File

@@ -4,19 +4,21 @@ Shared design tokens, web CSS, and React Native components for the Dangerous Thi
## Packages
| Package | Description |
|---------|-------------|
| [`@dangerousthings/tokens`](packages/tokens) | Canonical design tokens (colors, typography, shape) defined in TypeScript and exported as CSS custom properties |
| [`@dangerousthings/web`](packages/web) | Web CSS themes — bevels, glows, elevation, and form styles |
| [`@dangerousthings/react-native`](packages/react-native) | React Native components built on React Native Paper |
| Package | Description |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| [`@dangerousthings/tokens`](packages/tokens) | Canonical design tokens (colors, typography, shape) defined in TypeScript and exported as CSS custom properties |
| [`@dangerousthings/web`](packages/web) | Web CSS themes — bevels, animations, forms, and component styles |
| [`@dangerousthings/react`](packages/react) | React web components wrapping @dangerousthings/web CSS |
| [`@dangerousthings/react-native`](packages/react-native) | React Native components built on React Native Paper |
| [`@dangerousthings/tailwind-preset`](packages/tailwind-preset) | Tailwind CSS v3 preset mapping DT tokens to Tailwind theme |
| [`@dangerousthings/hex-background`](packages/hex-background) | 3D hexagon grid background using Three.js + React Three Fiber (web and React Native) |
## Brand Themes
Three brand themes ship out of the box:
Two brand themes ship out of the box:
- **DT** — neon cyberpunk aesthetic with beveled corners and glow effects (Tektur font)
- **DT** — neon cyberpunk aesthetic with beveled corners and baked in animations (Tektur font)
- **Classic** — dark navy palette with magenta accents and standard border radius
- **Supra** — Material Design 3inspired with blue accent and rounded corners
## Getting Started
@@ -50,6 +52,25 @@ npm run build:react-native
@import "@dangerousthings/web/components/bevels.css";
```
### React (Web)
```tsx
import "@dangerousthings/web";
import { DTWebThemeProvider, DTButton, DTCard } from "@dangerousthings/react";
export default function App() {
return (
<DTWebThemeProvider brand="dt" theme="dark">
<DTCard title="PRODUCT" variant="emphasis" progress={60}>
<DTButton variant="normal" onClick={handleClick}>
Scan NFC
</DTButton>
</DTCard>
</DTWebThemeProvider>
);
}
```
### React Native
```tsx
@@ -76,16 +97,26 @@ import { dt } from "@dangerousthings/tokens/brands/dt";
console.log(dt.color.brand.primary); // "#00ffff"
```
### Tailwind
```js
// tailwind.config.js
import dtPreset from "@dangerousthings/tailwind-preset";
export default {
presets: [dtPreset],
content: ["./src/**/*.{tsx,ts,html}"],
};
```
## Development
### Scripts
| Command | Description |
|---------|-------------|
| `npm run build` | Build all packages (Turbo) |
| `npm run clean` | Remove all `dist/` directories |
| `npm run typecheck` | Run TypeScript type checking |
| `npm run lint` | Lint all packages |
| Command | Description |
| ------------------- | ------------------------------ |
| `npm run build` | Build all packages (Turbo) |
| `npm run clean` | Remove all `dist/` directories |
| `npm run typecheck` | Run TypeScript type checking |
### Release
@@ -97,16 +128,25 @@ npm run version-packages # bump versions from pending changesets
npm run release # build + publish to npm
```
## React Web Components
The `@dangerousthings/react` package provides React web components that wrap the CSS from `@dangerousthings/web`. The component API mirrors the React Native package for consistency:
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTStaggerContainer, DTSwitch, DTTextInput
## React Native Components
DTAccordion, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTSwitch, DTTextInput
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTStaggerContainer, DTSwitch, DTTextInput
**Animation hooks:** `useScaleIn`, `usePulse`
## Web CSS Modules
- `bevels.css` — angular clip-path patterns for the DT brand
- `glows.css`neon drop-shadow effects for clipped elements
- `forms-dt.css` — text inputs, checkboxes, radio buttons, switches
- `elevation.css` — shadow elevation for Supra / MD3 brand
- `bevels.css` — angular clip-path bevels, card modes, selected states, progress bars, badge overlays, interactive bevel buttons
- `forms-dt.css`text inputs, checkboxes, radio buttons, switches, menu items, filter headers
- `animations.css` — entrance animations, stagger containers, transition utilities
- `scrollbar.css` — thin neon scrollbar styling
- `feature-legend.css` — product feature grid with rotated labels
## Project Structure
@@ -115,8 +155,11 @@ dt-design-system/
├── packages/
│ ├── tokens/ # Design token definitions + CSS generation
│ ├── web/ # CSS themes and components
│ ├── react/ # React web components
│ ├── react-native/ # React Native Paper components
── showcase/ # Demo app (placeholder)
── tailwind-preset/ # Tailwind CSS v3 preset
│ ├── hex-background/ # 3D hexagon grid (Three.js)
│ └── showcase/ # Demo apps (desktop + mobile)
├── turbo.json # Turbo pipeline config
└── package.json # Workspace root
```

12448
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,16 @@
},
"license": "MIT",
"workspaces": [
"packages/*"
"packages/*",
"packages/showcase/*"
],
"scripts": {
"build": "turbo run build",
"build:tokens": "npm run build -w packages/tokens",
"build:web": "npm run build -w packages/web",
"build:react-native": "npm run build -w packages/react-native",
"start:mobile": "npm run start -w @dangerousthings/showcase-mobile",
"start:desktop": "npm run start -w @dangerousthings/showcase-desktop",
"clean": "turbo run clean",
"typecheck": "turbo run typecheck",
"lint": "turbo run lint",
@@ -25,6 +28,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"puppeteer": "^24.38.0",
"turbo": "^2.4.0",
"typescript": "^5.7.0"
},

View File

@@ -0,0 +1,15 @@
# @dangerousthings/hex-background
## 0.2.1
### Fixed
- Animation no longer degrades to noise over time — replaced frame-rate-dependent lerp with duration-based smoothstep easing
### Added
- `animationDuration` prop (default 1200ms) controls how long each hexagon takes to reach its target height
## 0.2.0
### Minor Changes
- e74c285: New package: 3D hexagon grid background using Three.js + React Three Fiber. Includes HexGridBackground (full-viewport web component), HexGrid (instanced mesh with animated heights), and HexCamera (orbiting camera). Supports both web and React Native via separate entry points.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dangerous Things
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,84 @@
# @dangerousthings/hex-background
3D hexagon grid background for the Dangerous Things design system. Renders an animated, instanced WebGL hex grid using Three.js and React Three Fiber. Hexagons animate to random heights on a loop, with a slowly orbiting camera.
## Install
```bash
npm install @dangerousthings/hex-background
```
### Peer Dependencies
```bash
npm install react @react-three/fiber three
```
For React Native, also install `expo-gl`.
## Web Usage
Drop-in full-viewport background that renders behind page content:
```tsx
import { HexGridBackground } from "@dangerousthings/hex-background";
function App() {
return (
<>
<HexGridBackground />
<main>{/* your content */}</main>
</>
);
}
```
The component renders a fixed-position Canvas at `z-index: -1` with `pointer-events: none`, so it sits behind all other content. It automatically sizes the grid to the viewport and skips rendering in automated browsers (Selenium, Puppeteer).
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `opacity` | `number` | `0.5` | Canvas opacity |
| `hexRadius` | `number` | `0.5` | Hexagon radius |
| `margin` | `number` | `0.05` | Spacing between hexagons |
| `maxHeight` | `number` | `3` | Maximum animation height |
| `animationInterval` | `number` | `1500` | Height target refresh interval in ms |
| `cameraSpeed` | `number` | `0.02` | Camera orbital speed |
| `cameraRadius` | `number` | `8` | Camera orbital radius |
| `fov` | `number` | `40` | Camera field of view |
## React Native Usage
The full-viewport `HexGridBackground` wrapper uses web-specific APIs. For React Native, use the lower-level `HexGrid` and `HexCamera` components directly within a native Canvas:
```tsx
import { Canvas } from "@react-three/fiber/native";
import { HexGrid, HexCamera } from "@dangerousthings/hex-background/native";
function Background() {
return (
<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>
);
}
```
## Exports
| Path | Description |
|------|-------------|
| `@dangerousthings/hex-background` | `HexGridBackground`, `HexGrid`, `HexCamera` (web) |
| `@dangerousthings/hex-background/native` | `HexGrid`, `HexCamera` (React Native) |
## Monorepo
Part of the [DT Design System](https://github.com/dangerous-tac0s/dt-design-system) monorepo.
## License
[MIT](LICENSE)

View File

@@ -0,0 +1,73 @@
{
"name": "@dangerousthings/hex-background",
"version": "0.2.1",
"description": "3D hexagon grid background for the Dangerous Things design system",
"license": "MIT",
"author": {
"name": "Dangerous Things",
"email": "info@dangerousthings.com"
},
"repository": {
"type": "git",
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
"directory": "packages/hex-background"
},
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
"keywords": [
"dangerousthings",
"design-system",
"three.js",
"react-three-fiber",
"3d",
"hexagon",
"background"
],
"engines": {
"node": ">=18.0.0"
},
"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"
}
}

View 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;
}

View File

@@ -0,0 +1,171 @@
/**
* 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,
} 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;
/** Duration (ms) for each hex to reach its target @default 1200 */
animationDuration?: number;
}
/** Hermite smoothstep: 3t² - 2t³ */
function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}
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,
animationDuration = 1200,
}: HexGridProps) {
const meshRef = useRef<InstancedMesh>(null);
const positions = useMemo(
() => computeHexPositions(rows, cols, hexRadius * 2, extrudeHeight, margin),
[rows, cols, hexRadius, extrudeHeight, margin],
);
const animStateRef = useRef(
positions.map(() => {
const h = 1 + Math.random() * maxHeight;
return {startHeight: h, targetHeight: h, startTime: 0};
}),
);
// 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(() => {
const now = performance.now();
animStateRef.current = animStateRef.current.map((state) => {
// Compute where the hex currently is (same easing as useFrame)
const elapsed = Math.min(
(now - state.startTime) / animationDuration,
1,
);
const t = smoothstep(elapsed);
const currentHeight =
state.startHeight + (state.targetHeight - state.startHeight) * t;
return {
startHeight: currentHeight,
targetHeight: 0.5 + Math.random() * maxHeight,
startTime: now,
};
});
}, animationInterval);
return () => clearInterval(interval);
}, [positions.length, maxHeight, animationInterval, animationDuration]);
// Animate per frame: time-based smoothstep easing
useFrame(() => {
if (!meshRef.current) return;
const dummy = dummyRef.current;
const now = performance.now();
positions.forEach(({x, z}, i) => {
const state = animStateRef.current[i];
const elapsed = Math.min(
(now - state.startTime) / animationDuration,
1,
);
const t = smoothstep(elapsed);
const height =
state.startHeight + (state.targetHeight - state.startHeight) * t;
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,
]}
/>
);
}

View File

@@ -0,0 +1,145 @@
/**
* 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;
/** Duration (ms) for each hex to ease to target @default 1200 */
animationDuration?: 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,
animationDuration = 1200,
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}
animationDuration={animationDuration}
/>
</Suspense>
</Canvas>
);
}

View 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';

View 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';

View File

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

View File

@@ -1,5 +1,29 @@
# @dangerousthings/react-native
## 0.4.1
### Patch Changes
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer.
## 0.4.0
### Minor Changes
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer in react-native, updated DTGallery/DTModal/DTMobileFilterOverlay components.
## 0.3.0
### Minor Changes
- e74c285: Add storefront component features: DTCard selected state and progress bar, DTBadgeOverlay positioning wrapper, DTStaggerContainer with staggered scale-in animation, DTFeatureLegend product feature grid, DTMobileFilterOverlay slide-up filter panel, useScaleIn and usePulse animation hooks. Rewrite DTButton with interactive bevel pattern (outlined rectangle, bevels on hover/select).
### Patch Changes
- Updated dependencies [e74c285]
- Updated dependencies [e74c285]
- @dangerousthings/tokens@0.3.0
## 0.2.1
### Patch Changes

View File

@@ -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"

View File

@@ -1,8 +1,29 @@
{
"name": "@dangerousthings/react-native",
"version": "0.2.1",
"version": "0.4.1",
"description": "React Native themed components for the Dangerous Things design system",
"license": "MIT",
"author": {
"name": "Dangerous Things",
"email": "info@dangerousthings.com"
},
"repository": {
"type": "git",
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
"directory": "packages/react-native"
},
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
"keywords": [
"dangerousthings",
"design-system",
"react-native",
"components",
"theming"
],
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {

View File

@@ -20,7 +20,7 @@ import {
Animated,
} from 'react-native';
import {Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
export interface DTAccordionSection {
@@ -93,6 +93,7 @@ export function DTAccordion({
onSectionToggle,
style,
}: DTAccordionProps) {
const theme = useDTTheme();
const [openKeys, setOpenKeys] = useState<Set<string>>(
new Set(initialOpenKeys),
);
@@ -114,8 +115,8 @@ export function DTAccordion({
[allowMultiple, onSectionToggle],
);
const inactiveColor = getVariantColor(variant);
const activeColor = getVariantColor(activeVariant);
const inactiveColor = getVariantColor(theme, variant);
const activeColor = getVariantColor(theme, activeVariant);
return (
<View style={[styles.container, style]}>
@@ -146,6 +147,7 @@ function AccordionSection({
inactiveColor: string;
activeColor: string;
}) {
const theme = useDTTheme();
const heightAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
const chevronAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
const [contentHeight, setContentHeight] = useState(0);
@@ -187,6 +189,7 @@ function AccordionSection({
{
borderColor: sectionColor,
borderTopColor: sectionColor,
backgroundColor: theme.colors.background,
opacity: pressed ? 0.7 : 1,
},
]}>
@@ -214,7 +217,7 @@ function AccordionSection({
},
]}>
<View
style={styles.content}
style={[styles.content, {backgroundColor: theme.colors.background}]}
onLayout={e => {
const h = e.nativeEvent.layout.height;
if (h > 0 && !measured) {
@@ -240,7 +243,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: DTColors.dark,
borderWidth: 1,
borderTopWidth: 5,
paddingVertical: 12,
@@ -249,14 +251,12 @@ const styles = StyleSheet.create({
title: {
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
fontSize: 14,
},
contentWrapper: {},
content: {
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: DTColors.dark,
},
chevron: {
justifyContent: 'center',

View 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>;
}

View File

@@ -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 {DTColors} from '../theme/colors';
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,79 +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(variant, color);
const accentColor = getVariantColor(theme, variant, color);
const selectedColor = accentColor + 'B3'; // ~70% opacity
const isContained = mode === 'contained';
const bgColor = isContained ? accentColor : 'transparent';
const textColor = isContained ? DTColors.dark : accentColor;
const useBevels = theme.custom.bevelMd > 0;
const {width, height} = dimensions;
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
// 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} onLayout={onLayout}>
{hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path
d={buildButtonBevelPath(width, height, bevelSize, borderWidth)}
fill={bgColor}
stroke={accentColor}
strokeWidth={borderWidth}
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>
);
}
@@ -146,6 +243,5 @@ const styles = StyleSheet.create({
label: {
fontWeight: '600',
letterSpacing: 1,
textTransform: 'uppercase',
},
});

View File

@@ -8,13 +8,17 @@
* - 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';
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildCardBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
@@ -39,6 +43,17 @@ interface DTCardProps {
* @default true when title is provided
*/
showHeader?: boolean;
/**
* Progress value (01) for left-edge vertical progress bar.
* Always present. At 0 the bar shows surface color (empty).
* @default 0
*/
progress?: number;
/**
* Which area expands when the card grows to fill space
* @default 'body'
*/
growArea?: 'body' | 'title';
/**
* Additional styles for the card container
*/
@@ -68,16 +83,127 @@ interface DTCardProps {
*/
bevelSizeSmall?: number;
/**
* Background color
* @default '#000000'
* Background color (defaults to theme background)
*/
backgroundColor?: string;
/**
* Element rendered at the right edge of the header
*/
headerRight?: ReactNode;
/**
* Press handler
*/
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
*
@@ -97,58 +223,139 @@ export function DTCard({
borderColor,
title,
showHeader,
progress = 0,
growArea = 'body',
style,
contentStyle,
padding = 16,
borderWidth = 3,
bevelSize = 32,
bevelSizeSmall = 16,
backgroundColor = '#000000',
backgroundColor,
headerRight,
onPress,
}: DTCardProps) {
const theme = useDTTheme();
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const accentColor = getVariantColor(mode, borderColor);
const accentColor = getVariantColor(theme, mode, borderColor);
const shouldShowHeader = showHeader ?? !!title;
const bgColor = backgroundColor ?? theme.colors.background;
const {width, height} = dimensions;
const useBevels = theme.custom.bevelMd > 0;
// Outer bevel path (full card shape)
const outerPath = hasDimensions
const outerPath = useBevels && hasDimensions
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
: '';
// Inner bevel path at border inset (for background + frame cutout)
const innerPath = hasDimensions
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
// Inner bevel path — left edge at bevelSizeSmall (progress bar zone)
const innerPath = useBevels && hasDimensions
? 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 = (
<View style={[styles.container, style]} onLayout={onLayout}>
{/* Background fill (behind content) */}
{hasDimensions && (
<View
style={[
styles.container,
!useBevels && {
borderWidth,
borderColor: accentColor,
borderRadius: theme.custom.radius,
backgroundColor: bgColor,
overflow: 'hidden',
},
style,
]}
onLayout={onLayout}>
{/* Background fill (behind content) — beveled mode only */}
{useBevels && hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={backgroundColor} />
<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}]}>
<View style={[styles.header, {backgroundColor: accentColor}, growArea === 'title' && {flex: 1}, !!(title && headerRight) && styles.headerRow]}>
{title && (
<Text
variant="titleMedium"
style={[styles.headerText, {color: DTColors.dark}]}>
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
{title}
</Text>
)}
{headerRight}
</View>
)}
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
<View style={[styles.content, {padding}, growArea === 'body' && {flex: 1}, contentStyle]}>{children}</View>
</View>
{/* Frame overlay (above content) — clips content at beveled border */}
{hasDimensions && (
{/* 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]}
width={width}
@@ -156,7 +363,7 @@ export function DTCard({
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
<Path
d={outerPath + ' ' + innerPath}
d={framePath}
fillRule="evenodd"
fill={accentColor}
/>
@@ -205,11 +412,18 @@ const styles = StyleSheet.create({
position: 'relative',
zIndex: 1,
overflow: 'hidden',
flex: 1,
},
header: {
paddingHorizontal: 16,
paddingLeft: 8,
paddingRight: 16,
paddingVertical: 12,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
headerText: {
fontWeight: '700',
letterSpacing: 0.5,
@@ -218,4 +432,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,
},
});

View File

@@ -8,10 +8,10 @@
* Uses diagonal-symmetry bevels (top-left + bottom-right) on the indicator.
*/
import {StyleSheet, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildBeveledRectPath} from '../utils/bevelPaths';
@@ -76,16 +76,20 @@ export function DTCheckbox({
style,
labelStyle,
}: DTCheckboxProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant, color);
const opacity = disabled ? 0.5 : 1;
const borderWidth = 2;
const useBevels = theme.custom.bevelMd > 0;
const bevelSize = Math.round(size * 0.3);
// Outer beveled path (border)
const outerPath = buildBeveledRectPath(size, size, {
corners: {topLeft: bevelSize, bottomRight: bevelSize},
strokeWidth: borderWidth,
});
const outerPath = useBevels
? buildBeveledRectPath(size, size, {
corners: {topLeft: bevelSize, bottomRight: bevelSize},
strokeWidth: borderWidth,
})
: '';
return (
<Pressable
@@ -96,28 +100,44 @@ export function DTCheckbox({
{opacity: pressed ? 0.7 : opacity},
style,
]}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Beveled border */}
<Path
d={outerPath}
fill={checked ? accentColor : 'transparent'}
stroke={accentColor}
strokeWidth={borderWidth}
/>
{/* Checkmark path (only when checked) */}
{checked && (
{useBevels ? (
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Path
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
fill="none"
stroke={DTColors.dark}
strokeWidth={borderWidth + 0.5}
strokeLinecap="round"
strokeLinejoin="round"
d={outerPath}
fill={checked ? accentColor : 'transparent'}
stroke={accentColor}
strokeWidth={borderWidth}
/>
)}
</Svg>
{checked && (
<Path
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
fill="none"
stroke={theme.colors.onPrimary}
strokeWidth={borderWidth + 0.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</Svg>
) : (
<View
style={{
width: size,
height: size,
borderWidth,
borderColor: accentColor,
borderRadius: theme.custom.radiusSm,
backgroundColor: checked ? accentColor : 'transparent',
alignItems: 'center',
justifyContent: 'center',
}}>
{checked && (
<Text style={{color: theme.colors.onPrimary, fontSize: size * 0.6, lineHeight: size * 0.7}}></Text>
)}
</View>
)}
{label && (
<Text style={[styles.label, {color: DTColors.light}, labelStyle]}>
<Text style={[styles.label, {color: theme.colors.onSurface}, labelStyle]}>
{label}
</Text>
)}

View File

@@ -8,16 +8,15 @@
// React import not needed with new JSX transform
import { StyleSheet, ViewStyle, StyleProp } from 'react-native';
import { Chip, ChipProps } from 'react-native-paper';
import { DTColors } from '../theme/colors';
type DTChipVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
import { useDTTheme } from '../theme/DTThemeProvider';
import { type DTVariant, getVariantColor } from '../utils/variantColors';
interface DTChipProps extends Omit<ChipProps, 'mode'> {
/**
* Visual variant of the chip
* @default 'normal'
*/
variant?: DTChipVariant;
variant?: DTVariant;
/**
* Whether the chip is in selected state
* @default false
@@ -29,14 +28,6 @@ interface DTChipProps extends Omit<ChipProps, 'mode'> {
style?: StyleProp<ViewStyle>;
}
const variantColors: Record<DTChipVariant, string> = {
normal: DTColors.modeNormal,
emphasis: DTColors.modeEmphasis,
warning: DTColors.modeWarning,
success: DTColors.modeSuccess,
other: DTColors.modeOther,
};
/**
* DT-styled Chip component
*
@@ -56,7 +47,8 @@ export function DTChip({
children,
...props
}: DTChipProps) {
const color = variantColors[variant];
const theme = useDTTheme();
const color = getVariantColor(theme, variant);
const chipStyle: ViewStyle = {
backgroundColor: selected ? color : 'transparent',
@@ -69,9 +61,10 @@ export function DTChip({
<Chip
{...props}
mode="outlined"
showSelectedCheck={false}
textStyle={[
styles.text,
{ color: selected ? DTColors.dark : color },
{ color: selected ? theme.colors.onPrimary : color },
]}
style={[styles.chip, chipStyle, style]}
selectedColor={color}

View File

@@ -31,7 +31,7 @@ import {
import {Portal, Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildDrawerBevelPath} from '../utils/bevelPaths';
@@ -108,9 +108,11 @@ export function DTDrawer({
children,
style,
}: DTDrawerProps) {
const theme = useDTTheme();
const useBevels = theme.custom.bevelMd > 0;
const slideAnim = useRef(new Animated.Value(0)).current;
const overlayAnim = useRef(new Animated.Value(0)).current;
const headerColor = getVariantColor(headingVariant);
const headerColor = getVariantColor(theme, headingVariant);
const {width: screenWidth, height: screenHeight} = useWindowDimensions();
const insets = useSafeAreaInsets();
const effectiveWidth = Math.min(drawerWidth, screenWidth);
@@ -172,7 +174,7 @@ export function DTDrawer({
{/* Overlay backdrop */}
<Animated.View style={[StyleSheet.absoluteFill, {opacity: overlayAnim}]}>
<Pressable
style={[StyleSheet.absoluteFill, styles.overlay]}
style={[StyleSheet.absoluteFill, {backgroundColor: theme.colors.backdrop}]}
onPress={onDismiss}
/>
</Animated.View>
@@ -183,42 +185,49 @@ export function DTDrawer({
styles.panel,
position === 'right' ? styles.panelRight : styles.panelLeft,
{width: effectiveWidth, transform: [{translateX}]},
!useBevels && {
borderWidth,
borderColor: headerColor,
borderRadius: theme.custom.radius,
backgroundColor: theme.colors.background,
overflow: 'hidden',
},
style,
]}>
{/* Dual-SVG-path technique: outer border + inner dark fill */}
<View style={StyleSheet.absoluteFill}>
<Svg width={effectiveWidth} height={screenHeight}>
{/* Outer path: accent border color */}
<Path
d={buildDrawerBevelPath(
effectiveWidth,
screenHeight,
bevelSize,
position,
)}
fill={headerColor}
/>
{/* Inner path: dark background (inset by borderWidth) */}
<Path
d={buildDrawerBevelPath(
effectiveWidth - borderWidth * 2,
screenHeight - borderWidth * 2,
bevelSize - borderWidth,
position,
)}
fill={DTColors.dark}
transform={`translate(${borderWidth}, ${borderWidth})`}
/>
</Svg>
</View>
{/* Dual-SVG-path technique: outer border + inner dark fill — beveled mode only */}
{useBevels && (
<View style={StyleSheet.absoluteFill}>
<Svg width={effectiveWidth} height={screenHeight}>
<Path
d={buildDrawerBevelPath(
effectiveWidth,
screenHeight,
bevelSize,
position,
)}
fill={headerColor}
/>
<Path
d={buildDrawerBevelPath(
effectiveWidth - borderWidth * 2,
screenHeight - borderWidth * 2,
bevelSize - borderWidth,
position,
)}
fill={theme.colors.background}
transform={`translate(${borderWidth}, ${borderWidth})`}
/>
</Svg>
</View>
)}
{/* Header bar */}
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
<Text style={styles.headerText}>{heading}</Text>
<Text style={[styles.headerText, {color: theme.colors.onPrimary}]}>{heading}</Text>
<Pressable
onPress={onDismiss}
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
<Text style={styles.closeButton}></Text>
<Text style={[styles.closeButton, {color: theme.colors.onPrimary}]}></Text>
</Pressable>
</View>
@@ -235,9 +244,6 @@ export function DTDrawer({
}
const styles = StyleSheet.create({
overlay: {
backgroundColor: DTColors.overlay,
},
panel: {
position: 'absolute',
top: 0,
@@ -258,13 +264,11 @@ const styles = StyleSheet.create({
zIndex: 1,
},
headerText: {
color: DTColors.dark,
fontWeight: '700',
fontSize: 18,
letterSpacing: 0.5,
},
closeButton: {
color: DTColors.dark,
fontSize: 20,
fontWeight: '700',
paddingHorizontal: 8,

View File

@@ -0,0 +1,135 @@
/**
* 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,
},
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',
},
});

View File

@@ -26,7 +26,7 @@ import {
Platform,
} from 'react-native';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
@@ -79,6 +79,11 @@ interface DTGalleryProps {
* @default 3
*/
borderWidth?: number;
/**
* Aspect ratio (width / height) for the display area
* @default 1
*/
aspectRatio?: number;
/**
* Additional styles
*/
@@ -108,11 +113,13 @@ export function DTGallery({
thumbnailSize = 64,
bevelSize = 24,
borderWidth = 3,
aspectRatio = 1,
style,
}: DTGalleryProps) {
const theme = useDTTheme();
const flatListRef = useRef<FlatList>(null);
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const accentColor = getVariantColor(variant);
const accentColor = getVariantColor(theme, variant);
const atStart = activeIndex <= 0;
const atEnd = activeIndex >= items.length - 1;
@@ -171,22 +178,45 @@ export function DTGallery({
style={({pressed}) => ({
opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1,
})}>
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
<Path
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
fill="transparent"
stroke={accentColor}
strokeWidth={ARROW_BORDER}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
{useBevels ? (
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
<Path
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
fill="transparent"
stroke={accentColor}
strokeWidth={ARROW_BORDER}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
) : (
<View style={{
width: ARROW_SIZE,
height: ARROW_SIZE,
borderWidth: ARROW_BORDER,
borderColor: accentColor,
borderRadius: theme.custom.radiusSm,
alignItems: 'center',
justifyContent: 'center',
}}>
<Svg width={ARROW_SIZE * 0.6} height={ARROW_SIZE * 0.6} viewBox={`${ARROW_SIZE * 0.2} ${ARROW_SIZE * 0.2} ${ARROW_SIZE * 0.6} ${ARROW_SIZE * 0.6}`}>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</View>
)}
</Pressable>
);
};
@@ -204,6 +234,7 @@ export function DTGallery({
width: thumbnailSize,
height: thumbnailSize,
borderColor: isActive ? accentColor : 'transparent',
borderRadius: useBevels ? 0 : theme.custom.radiusSm,
},
isActive && styles.thumbnailActive,
]}>
@@ -235,30 +266,42 @@ export function DTGallery({
if (items.length === 0) return null;
const {width, height} = dimensions;
const useBevels = theme.custom.bevelMd > 0;
// Outer and inner bevel paths for frame overlay
const outerPath = hasDimensions
const outerPath = useBevels && hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize)
: '';
const innerPath = hasDimensions
const innerPath = useBevels && hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
: '';
return (
<View style={[styles.container, style]}>
{/* Main image with beveled frame */}
<View style={styles.mainImageContainer} onLayout={onLayout}>
{/* Background fill */}
{hasDimensions && (
<View
style={[
styles.mainImageContainer,
{ aspectRatio },
!useBevels && {
borderWidth,
borderColor: accentColor,
borderRadius: theme.custom.radius,
overflow: 'hidden',
},
]}
onLayout={onLayout}>
{/* Background fill — beveled mode only */}
{useBevels && hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={DTColors.surfaceVariant} />
<Path d={innerPath} fill={theme.colors.surfaceVariant} />
</Svg>
)}
<View style={[styles.mainImageContent, {padding: borderWidth}]}>
<View style={[styles.mainImageContent, {padding: useBevels ? borderWidth : 0}]}>
<Image
source={{uri: items[activeIndex]?.uri}}
style={styles.mainImage}
@@ -266,21 +309,19 @@ export function DTGallery({
resizeMode="contain"
/>
</View>
{/* Frame overlay (above content) — clips image at beveled border */}
{hasDimensions && (
{/* Frame overlay — beveled mode only */}
{useBevels && hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
{/* Corner mask: fill area outside outer bevel with dark to hide overflow */}
<Path
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
fillRule="evenodd"
fill={DTColors.dark}
fill={theme.colors.background}
/>
{/* Colored border between outer and inner bevel paths */}
<Path
d={outerPath + ' ' + innerPath}
fillRule="evenodd"
@@ -321,7 +362,6 @@ const styles = StyleSheet.create({
gap: 12,
},
mainImageContainer: {
aspectRatio: 1,
position: 'relative',
},
mainImageContent: {

View File

@@ -13,6 +13,7 @@
import {useRef, useEffect} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
import Svg, {Polygon} from 'react-native-svg';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTHexagonProps {
@@ -112,7 +113,8 @@ export function DTHexagon({
style,
children,
}: DTHexagonProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant, color);
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;

View File

@@ -14,7 +14,7 @@ import {ReactNode, useEffect, useRef} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildLabelBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
@@ -150,12 +150,14 @@ export function DTLabel({
bevelSize,
style,
}: DTLabelProps) {
const theme = useDTTheme();
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
const pingAnim = useRef(new Animated.Value(1)).current;
const backgroundColor = getVariantColor(mode);
const backgroundColor = getVariantColor(theme, mode);
const sizeConfig = sizeConfigs[size];
const effectiveBevelSize = bevelSize ?? sizeConfig.bevel;
const onPrimaryColor = theme.colors.onPrimary;
// Mount animation
useEffect(() => {
@@ -196,6 +198,7 @@ export function DTLabel({
}, [showIndicator, pingAnim]);
const {width, height} = dimensions;
const useBevels = theme.custom.bevelMd > 0;
return (
<Animated.View
@@ -203,10 +206,14 @@ export function DTLabel({
styles.container,
fullWidth && styles.fullWidth,
{transform: [{scale: scaleAnim}]},
!useBevels && {
backgroundColor,
borderRadius: theme.custom.radiusSm,
},
style,
]}
onLayout={onLayout}>
{hasDimensions && (
{useBevels && hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
@@ -228,7 +235,7 @@ export function DTLabel({
<Text
style={[
styles.primaryText,
{fontSize: sizeConfig.fontSize},
{fontSize: sizeConfig.fontSize, color: onPrimaryColor},
]}>
{primaryText}
</Text>
@@ -236,7 +243,7 @@ export function DTLabel({
<Text
style={[
styles.secondaryText,
{fontSize: sizeConfig.fontSizeSecondary},
{fontSize: sizeConfig.fontSizeSecondary, color: onPrimaryColor},
]}>
{secondaryText}
</Text>
@@ -251,7 +258,7 @@ export function DTLabel({
<View
style={[
styles.indicatorLine,
{borderColor: DTColors.dark, width: sizeConfig.indicatorWidth},
{borderColor: onPrimaryColor, width: sizeConfig.indicatorWidth},
]}
/>
</Animated.View>
@@ -299,11 +306,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
primaryText: {
color: DTColors.dark,
fontWeight: '500',
},
secondaryText: {
color: DTColors.dark,
fontWeight: '800',
},
indicator: {},

View File

@@ -13,7 +13,7 @@
import {ReactNode, useRef, useEffect} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
import Svg, {Path, Defs, ClipPath} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildMediaFrameBevelPath} from '../utils/bevelPaths';
import {useComponentLayout} from '../utils/useComponentLayout';
@@ -91,9 +91,10 @@ export function DTMediaFrame({
style,
contentStyle,
}: DTMediaFrameProps) {
const theme = useDTTheme();
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
const accentColor = getVariantColor(variant, color);
const accentColor = getVariantColor(theme, variant, color);
useEffect(() => {
if (animated) {
@@ -107,13 +108,14 @@ export function DTMediaFrame({
}, [animated, animationDelay, scaleAnim]);
const {width, height} = dimensions;
const useBevels = theme.custom.bevelMd > 0;
// Outer bevel path (full frame shape)
const outerPath = hasDimensions
const outerPath = useBevels && hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize)
: '';
// Inner bevel path at border inset
const innerPath = hasDimensions
const innerPath = useBevels && hasDimensions
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
: '';
@@ -123,21 +125,27 @@ export function DTMediaFrame({
styles.container,
aspectRatio ? {aspectRatio} : undefined,
{transform: [{scale: scaleAnim}]},
!useBevels && {
borderWidth,
borderColor: accentColor,
borderRadius: theme.custom.radius,
overflow: 'hidden',
},
style,
]}
onLayout={onLayout}>
{/* Background fill (behind content) */}
{hasDimensions && (
{/* Background fill (behind content) — beveled mode only */}
{useBevels && hasDimensions && (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}>
<Path d={innerPath} fill={DTColors.dark} />
<Path d={innerPath} fill={theme.colors.background} />
</Svg>
)}
{/* Content clipped to the inner bevel shape */}
{hasDimensions ? (
{/* Content */}
{useBevels && hasDimensions ? (
<View style={[styles.content, contentStyle]}>
<Svg
width={width}
@@ -155,7 +163,6 @@ export function DTMediaFrame({
StyleSheet.absoluteFill,
{
margin: borderWidth,
// Use borderRadius 0 to keep angular, overflow hidden clips rectangular part
overflow: 'hidden',
},
]}>
@@ -163,30 +170,27 @@ export function DTMediaFrame({
</View>
</View>
) : (
<View style={[styles.content, {padding: borderWidth}, contentStyle]}>
<View style={[styles.content, !useBevels ? undefined : {padding: borderWidth}, contentStyle]}>
{children}
</View>
)}
{/* Frame overlay (above content) — masks content at beveled corners */}
{hasDimensions && (
{/* Frame overlay (above content) — beveled mode only */}
{useBevels && hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
{/* Outer frame border */}
<Path
d={outerPath + ' ' + innerPath}
fillRule="evenodd"
fill={accentColor}
/>
{/* Corner masks: fill the rectangular area outside the bevel shape with black
to hide content that overflows past the beveled corners */}
<Path
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
fillRule="evenodd"
fill={DTColors.dark}
fill={theme.colors.background}
/>
</Svg>
)}

View File

@@ -20,7 +20,7 @@ import {
Animated,
} from 'react-native';
import {Menu, Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
export interface DTMenuItem {
@@ -163,7 +163,8 @@ export function DTMenuDropdown({
onDismiss,
style,
}: DTMenuDropdownProps) {
const accentColor = getVariantColor(variant);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant);
return (
<Menu
@@ -173,12 +174,12 @@ export function DTMenuDropdown({
anchorPosition="bottom"
contentStyle={[
styles.dropdownContent,
{borderColor: accentColor},
{borderColor: accentColor, backgroundColor: theme.colors.background},
style,
]}
theme={{
colors: {
elevation: {level2: DTColors.dark},
elevation: {level2: theme.colors.background},
},
}}>
{items.map(item => (
@@ -189,7 +190,7 @@ export function DTMenuDropdown({
item.onPress?.();
onDismiss();
}}
titleStyle={[styles.dropdownItemTitle, {color: DTColors.light}]}
titleStyle={[styles.dropdownItemTitle, {color: theme.colors.onSurface}]}
style={styles.dropdownItem}
/>
))}
@@ -210,10 +211,11 @@ function DTMenuItemRow({
activeVariant: DTVariant;
level: number;
}) {
const theme = useDTTheme();
const [expanded, setExpanded] = useState(false);
const hasChildren = item.items && item.items.length > 0;
const isActive = item.isActive || expanded;
const itemColor = getVariantColor(isActive ? activeVariant : variant);
const itemColor = getVariantColor(theme, isActive ? activeVariant : variant);
const heightAnim = useRef(new Animated.Value(0)).current;
const chevronAnim = useRef(new Animated.Value(0)).current;
@@ -265,7 +267,7 @@ function DTMenuItemRow({
borderTopColor: itemColor,
backgroundColor: isActive
? `${itemColor}20`
: DTColors.dark,
: theme.colors.background,
paddingLeft: 16 + level * 16,
opacity: pressed ? 0.7 : 1,
},
@@ -339,7 +341,6 @@ const styles = StyleSheet.create({
menuItemTitle: {
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
fontSize: 14,
},
chevron: {
@@ -360,7 +361,6 @@ const styles = StyleSheet.create({
dropdownContent: {
borderWidth: 2,
borderRadius: 0,
backgroundColor: DTColors.dark,
},
dropdownItem: {
borderRadius: 0,

View 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,
},
});

View File

@@ -9,9 +9,9 @@
* - Content in beveled card container
*/
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform} from 'react-native';
import {Portal, Modal} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform, Pressable} from 'react-native';
import {Portal, Modal, Text} from 'react-native-paper';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant} from '../utils/variantColors';
import {DTCard} from './DTCard';
@@ -75,6 +75,8 @@ export function DTModal({
contentStyle,
style,
}: DTModalProps) {
const theme = useDTTheme();
return (
<Portal>
<Modal
@@ -85,7 +87,7 @@ export function DTModal({
style={styles.modal}
theme={{
colors: {
backdrop: DTColors.overlay,
backdrop: theme.colors.backdrop,
},
}}>
<KeyboardAvoidingView
@@ -95,6 +97,17 @@ export function DTModal({
mode={variant}
title={title}
showHeader={!!title}
headerRight={dismissable ? (
<Pressable
onPress={onDismiss}
hitSlop={8}
accessibilityLabel="Close"
accessibilityRole="button">
<Text style={{ color: theme.colors.onPrimary, fontSize: 18, lineHeight: 18 }}>
{'\u2715'}
</Text>
</Pressable>
) : undefined}
contentStyle={contentStyle}>
{children}
</DTCard>

View File

@@ -11,7 +11,7 @@
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
import {ProgressBar, Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {useComponentLayout} from '../utils/useComponentLayout';
@@ -77,7 +77,8 @@ export function DTProgressBar({
color,
style,
}: DTProgressBarProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant, color);
const clampedValue = Math.max(0, Math.min(1, value));
if (orientation === 'vertical') {
@@ -85,6 +86,7 @@ export function DTProgressBar({
<VerticalProgressBar
value={clampedValue}
color={accentColor}
trackColor={theme.colors.surfaceDisabled}
width={height}
showLabel={showLabel}
style={style}
@@ -100,7 +102,7 @@ export function DTProgressBar({
style={[styles.bar, {height}]}
theme={{
colors: {
surfaceVariant: DTColors.disabledBackground,
surfaceVariant: theme.colors.surfaceDisabled,
},
}}
/>
@@ -120,12 +122,14 @@ export function DTProgressBar({
function VerticalProgressBar({
value,
color,
trackColor,
width,
showLabel,
style,
}: {
value: number;
color: string;
trackColor: string;
width: number;
showLabel: boolean;
style?: StyleProp<ViewStyle>;
@@ -135,7 +139,7 @@ function VerticalProgressBar({
return (
<View style={[styles.verticalContainer, {width}, style]}>
<View style={styles.verticalTrack} onLayout={onLayout}>
<View style={[styles.verticalTrack, {backgroundColor: trackColor}]} onLayout={onLayout}>
<View
style={[
styles.verticalFill,
@@ -169,7 +173,6 @@ const styles = StyleSheet.create({
verticalTrack: {
flex: 1,
width: '100%',
backgroundColor: DTColors.disabledBackground,
justifyContent: 'flex-end',
},
verticalFill: {

View File

@@ -9,10 +9,10 @@
* - Quantity display between buttons
*/
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Text as RNText} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Path} from 'react-native-svg';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
import {buildButtonBevelPath} from '../utils/bevelPaths';
@@ -124,7 +124,9 @@ export function DTQuantityStepper({
color,
style,
}: DTQuantityStepperProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const useBevels = theme.custom.bevelMd > 0;
const accentColor = getVariantColor(theme, variant, color);
const config = sizeConfigs[size];
const atMin = value <= min;
const atMax = value >= max;
@@ -168,21 +170,37 @@ export function DTQuantityStepper({
style={({pressed}) => ({
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
})}>
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<Path
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
fill="transparent"
stroke={accentColor}
strokeWidth={borderWidth}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={sw}
strokeLinecap="round"
/>
</Svg>
{useBevels ? (
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<Path
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
fill="transparent"
stroke={accentColor}
strokeWidth={borderWidth}
/>
<Path
d={iconPath}
fill="none"
stroke={accentColor}
strokeWidth={sw}
strokeLinecap="round"
/>
</Svg>
) : (
<View style={{
width: s,
height: s,
borderWidth,
borderColor: accentColor,
borderRadius: theme.custom.radiusSm,
alignItems: 'center',
justifyContent: 'center',
}}>
<RNText style={{color: accentColor, fontSize: s * 0.5, lineHeight: s * 0.6}}>
{type === 'minus' ? '' : '+'}
</RNText>
</View>
)}
</Pressable>
);
};
@@ -190,7 +208,7 @@ export function DTQuantityStepper({
return (
<View style={[styles.wrapper, style]}>
{label && (
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
<Text style={[styles.label, {color: theme.colors.onSurface}]}>{label}</Text>
)}
<View style={styles.container}>
{renderStepButton('minus', handleDecrement, atMin)}

View File

@@ -14,7 +14,7 @@ import {createContext, useContext} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
import {Text} from 'react-native-paper';
import Svg, {Polygon} from 'react-native-svg';
import {DTColors, getColor} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
// --- Context ---
@@ -115,6 +115,15 @@ function hexPoints(cx: number, cy: number, r: number): string {
return pts.join(' ');
}
/** Convert hex color to rgba with given alpha */
function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace('#', '');
const r = parseInt(clean.substring(0, 2), 16);
const g = parseInt(clean.substring(2, 4), 16);
const b = parseInt(clean.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* DT-styled RadioOption with hexagonal indicator
*
@@ -130,6 +139,7 @@ export function DTRadioOption({
disabled = false,
style,
}: DTRadioOptionProps) {
const theme = useDTTheme();
const context = useContext(RadioContext);
if (!context) {
throw new Error('DTRadioOption must be used inside DTRadioGroup');
@@ -137,14 +147,9 @@ export function DTRadioOption({
const {value: selectedValue, onValueChange, variant} = context;
const isSelected = selectedValue === value;
const accentColor = getVariantColor(variant);
const selectedBgColor = getColor(
variant === 'normal'
? 'modeNormalSelected'
: variant === 'emphasis'
? 'modeEmphasisSelected'
: 'modeNormal',
);
const accentColor = getVariantColor(theme, variant);
const selectedBgColor = hexToRgba(accentColor, 0.7);
const unselectedBorderColor = hexToRgba(theme.custom.modeNormal, 0.3);
const opacity = disabled ? 0.5 : 1;
return (
@@ -153,7 +158,7 @@ export function DTRadioOption({
style={({pressed}) => [
styles.option,
{
borderColor: isSelected ? accentColor : getColor('modeNormal', 0.3),
borderColor: isSelected ? accentColor : unselectedBorderColor,
backgroundColor: isSelected ? selectedBgColor : 'transparent',
opacity: pressed ? 0.7 : opacity,
},
@@ -183,7 +188,7 @@ export function DTRadioOption({
<Text
style={[
styles.optionLabel,
{color: isSelected ? DTColors.dark : DTColors.light},
{color: isSelected ? theme.colors.onPrimary : theme.colors.onSurface},
]}>
{label}
</Text>
@@ -192,7 +197,7 @@ export function DTRadioOption({
variant="bodySmall"
style={[
styles.optionDescription,
{color: isSelected ? DTColors.dark : DTColors.disabled},
{color: isSelected ? theme.colors.onPrimary : theme.colors.onSurfaceDisabled},
]}>
{description}
</Text>

View File

@@ -11,7 +11,7 @@
import {useState, useRef, useEffect} from 'react';
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Animated} from 'react-native';
import {Searchbar, ActivityIndicator} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTSearchInputProps {
@@ -86,7 +86,8 @@ export function DTSearchInput({
style,
inputStyle,
}: DTSearchInputProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant, color);
const [focused, setFocused] = useState(false);
const focusAnim = useRef(new Animated.Value(0)).current;
@@ -110,7 +111,7 @@ export function DTSearchInput({
onBlur={() => setFocused(false)}
returnKeyType="search"
placeholder={placeholder}
placeholderTextColor={DTColors.disabled}
placeholderTextColor={theme.colors.onSurfaceDisabled}
editable={!disabled}
autoFocus={autoFocus}
icon={
@@ -119,13 +120,13 @@ export function DTSearchInput({
: 'magnify'
}
iconColor={accentColor}
inputStyle={[styles.input, {color: DTColors.light}, inputStyle]}
style={[styles.searchbar, {borderColor: accentColor}]}
inputStyle={[styles.input, {color: theme.colors.onSurface}, inputStyle]}
style={[styles.searchbar, {borderColor: accentColor, backgroundColor: theme.colors.background}]}
rippleColor={accentColor}
theme={{
colors: {
elevation: {level3: DTColors.dark},
onSurface: DTColors.light,
elevation: {level3: theme.colors.background},
onSurface: theme.colors.onSurface,
onSurfaceVariant: accentColor,
},
roundness: 0,
@@ -149,7 +150,6 @@ const styles = StyleSheet.create({
container: {},
searchbar: {
borderWidth: 2,
backgroundColor: DTColors.dark,
elevation: 0,
},
input: {

View 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>
);
}

View File

@@ -15,7 +15,7 @@ import {
Animated,
} from 'react-native';
import {Text} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
const TRACK_WIDTH = 48;
@@ -73,7 +73,8 @@ export function DTSwitch({
color,
style,
}: DTSwitchProps) {
const accentColor = getVariantColor(variant, color);
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant, color);
const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => {
@@ -91,10 +92,9 @@ export function DTSwitch({
const trackColor = thumbAnim.interpolate({
inputRange: [0, 1],
outputRange: [DTColors.disabledBackground, accentColor],
outputRange: [theme.colors.surfaceDisabled, accentColor],
});
return (
<Pressable
onPress={() => !disabled && onValueChange(!value)}
@@ -104,7 +104,7 @@ export function DTSwitch({
style,
]}>
{label && (
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
<Text style={[styles.label, {color: theme.colors.onSurface}]}>{label}</Text>
)}
<Animated.View
style={[
@@ -118,7 +118,7 @@ export function DTSwitch({
style={[
styles.thumb,
{
backgroundColor: value ? DTColors.dark : DTColors.light,
backgroundColor: value ? theme.colors.onPrimary : theme.colors.onSurface,
transform: [{translateX: thumbTranslateX}],
},
]}

View File

@@ -20,7 +20,7 @@ import {
Animated,
} from 'react-native';
import {TextInput, TextInputProps} from 'react-native-paper';
import {DTColors} from '../theme/colors';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTTextInputProps
@@ -79,12 +79,13 @@ export function DTTextInput({
style,
...props
}: DTTextInputProps) {
const theme = useDTTheme();
const [focused, setFocused] = useState(false);
const focusAnim = useRef(new Animated.Value(0)).current;
const accentColor = error
? DTColors.modeWarning
: getVariantColor(variant, color);
? theme.custom.modeWarning
: getVariantColor(theme, variant, color);
useEffect(() => {
const anim = Animated.timing(focusAnim, {
@@ -101,7 +102,7 @@ export function DTTextInput({
<View
style={[
styles.inputWrapper,
{borderColor: accentColor, borderWidth},
{borderColor: accentColor, borderWidth, backgroundColor: theme.colors.background},
]}>
<TextInput
{...props}
@@ -116,8 +117,8 @@ export function DTTextInput({
onBlur?.(e);
}}
style={[styles.input, inputStyle, style]}
textColor={DTColors.light}
placeholderTextColor={DTColors.disabled}
textColor={theme.colors.onSurface}
placeholderTextColor={theme.colors.onSurfaceDisabled}
activeUnderlineColor="transparent"
underlineColor="transparent"
theme={{
@@ -141,7 +142,7 @@ export function DTTextInput({
/>
{error && errorMessage && (
<View style={styles.errorContainer}>
<Animated.Text style={[styles.errorText, {color: DTColors.modeWarning}]}>
<Animated.Text style={[styles.errorText, {color: theme.custom.modeWarning}]}>
{errorMessage}
</Animated.Text>
</View>
@@ -151,9 +152,7 @@ export function DTTextInput({
}
const styles = StyleSheet.create({
inputWrapper: {
backgroundColor: DTColors.dark,
},
inputWrapper: {},
input: {
backgroundColor: 'transparent',
},

View File

@@ -27,9 +27,10 @@ export { DTThemeProvider, useDTTheme } from './theme/DTThemeProvider';
// Shared utilities
export type { DTVariant } from './utils/variantColors';
export { variantColorMap, getVariantColor } from './utils/variantColors';
export { getVariantColor } from './utils/variantColors';
export { buildBeveledRectPath } from './utils/bevelPaths';
export { useComponentLayout } from './utils/useComponentLayout';
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';

View File

@@ -124,6 +124,14 @@ export interface DTExtendedTheme extends MD3Theme {
modeOther: string;
border: string;
borderEmphasis: string;
/** Bevel sizes in pixels (0 = no bevels, use borderRadius instead) */
bevelSm: number;
bevelMd: number;
bevelLg: number;
/** Border radius in pixels (0 = angular/beveled) */
radiusSm: number;
radius: number;
radiusLg: number;
};
}
@@ -139,6 +147,12 @@ export const DTExtendedDarkTheme: DTExtendedTheme = {
modeOther: DTColors.modeOther,
border: DTColors.border,
borderEmphasis: DTColors.borderEmphasis,
bevelSm: 16,
bevelMd: 32,
bevelLg: 40,
radiusSm: 0,
radius: 0,
radiusLg: 0,
},
};

View 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;
}

View File

@@ -2,26 +2,33 @@
* Centralized variant type and color mapping
*
* Used by all DT components for consistent mode/variant color resolution.
* Reads colors from the active DTExtendedTheme rather than static defaults.
*/
import {DTColors} from '../theme/colors';
import type {DTExtendedTheme} from '../theme/paperTheme';
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';
export const variantColorMap: Record<DTVariant, string> = {
normal: DTColors.modeNormal,
emphasis: DTColors.modeEmphasis,
warning: DTColors.modeWarning,
success: DTColors.modeSuccess,
other: DTColors.modeOther,
/** String-typed keys in DTExtendedTheme['custom'] that map to mode colors */
type ModeColorKey = 'modeNormal' | 'modeEmphasis' | 'modeWarning' | 'modeSuccess' | 'modeOther';
const variantToThemeKey: Record<DTVariant, ModeColorKey> = {
normal: 'modeNormal',
emphasis: 'modeEmphasis',
warning: 'modeWarning',
success: 'modeSuccess',
other: 'modeOther',
};
/**
* Resolve variant to color, with optional custom color override.
* Resolve variant to color from the active theme, with optional custom color override.
*/
export function getVariantColor(
theme: DTExtendedTheme,
variant: DTVariant,
customColor?: string,
): string {
return customColor ?? variantColorMap[variant];
return customColor ?? theme.custom[variantToThemeKey[variant]];
}

View File

@@ -0,0 +1,34 @@
# @dangerousthings/react
## 2.0.1
### Patch Changes
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer.
- Updated dependencies
- @dangerousthings/web@0.4.1
## 2.0.0
### Minor Changes
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer in react-native, updated DTGallery/DTModal/DTMobileFilterOverlay components.
### Patch Changes
- Updated dependencies
- @dangerousthings/web@0.4.0
## 1.0.0
### Minor Changes
- e74c285: New package: @dangerousthings/react — React web components wrapping @dangerousthings/web CSS. 22 components matching the React Native package API (DTCard, DTButton, DTLabel, DTChip, DTModal, DTDrawer, DTAccordion, DTMenu, DTStaggerContainer, DTFeatureLegend, DTMobileFilterOverlay, DTGallery, DTHexagon, form primitives). Includes DTWebThemeProvider, animation hooks (useScaleIn, usePulse), and variant utility functions. DTVariant type moved to @dangerousthings/tokens as shared canonical source.
### Patch Changes
- Updated dependencies [e74c285]
- Updated dependencies [e74c285]
- Updated dependencies [e74c285]
- @dangerousthings/tokens@0.3.0
- @dangerousthings/web@0.3.0

21
packages/react/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dangerous Things
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

131
packages/react/README.md Normal file
View File

@@ -0,0 +1,131 @@
# @dangerousthings/react
React web components for the Dangerous Things design system — 20 themed components wrapping `@dangerousthings/web` CSS with React APIs matching `@dangerousthings/react-native` for cross-platform parity.
## Install
```bash
npm install @dangerousthings/react
```
### Peer Dependencies
```bash
npm install react react-dom @dangerousthings/tokens @dangerousthings/web
```
## Setup
Wrap your app with `DTWebThemeProvider` and import the web CSS bundle:
```tsx
import "@dangerousthings/web";
import { DTWebThemeProvider } from "@dangerousthings/react";
export default function App() {
return (
<DTWebThemeProvider brand="dt" theme="dark">
{/* your app */}
</DTWebThemeProvider>
);
}
```
The provider sets `data-brand` and `data-theme` attributes on a wrapper div and exposes brand/theme via React context.
## Components
### Bevel & Layout
| Component | Description |
|-----------|-------------|
| `DTCard` | Beveled card with header, progress bar, and mode colors |
| `DTButton` | Beveled button with variant colors |
| `DTLabel` | Top-right beveled label with status colors |
| `DTChip` | Compact labeled element |
| `DTMediaFrame` | Diagonal beveled frame for images |
| `DTModal` | Modal dialog with beveled corners |
| `DTDrawer` | Side drawer with edge bevels |
| `DTHexagon` | Decorative hexagon SVG shape |
### Forms
| Component | Description |
|-----------|-------------|
| `DTTextInput` | Sharp-cornered input with focus glow |
| `DTCheckbox` | Beveled checkbox with opposing corner cuts |
| `DTSwitch` | Angular toggle switch |
| `DTRadioGroup` | Hexagonal radio buttons |
| `DTQuantityStepper` | Beveled +/- increment buttons |
| `DTSearchInput` | Search-styled text input |
### Layout & Animation
| Component | Description |
|-----------|-------------|
| `DTProgressBar` | Angular progress bar with optional label |
| `DTAccordion` | Collapsible sections with accent border |
| `DTStaggerContainer` | Staggered scale-in entrance animation for children |
| `DTBadgeOverlay` | Absolute-positioned badge wrapper |
### Filter & Feature
| Component | Description |
|-----------|-------------|
| `DTMenu` | Dropdown menu with item variants |
| `DTFeatureLegend` | Product feature grid with icons and rotated labels |
| `DTMobileFilterOverlay` | Full-screen slide-up filter overlay with backdrop |
| `DTGallery` | Image gallery |
### Animation Hooks
| Hook | Description |
|------|-------------|
| `useScaleIn` | Scale 0 to 1 entrance animation |
| `usePulse` | Looping opacity pulse animation |
## Usage Examples
```tsx
import { DTButton, DTCard, DTTextInput } from "@dangerousthings/react";
// Button with variant
<DTButton variant="normal" onClick={handleClick}>
Scan NFC
</DTButton>
// Card with title and progress bar
<DTCard title="PRODUCT" variant="emphasis" progress={60}>
<p>60% progress</p>
</DTCard>
// Text input
<DTTextInput variant="normal" label="Username" />
```
## Theme Utilities
```tsx
import { useDTWebTheme } from "@dangerousthings/react";
const { brand, theme } = useDTWebTheme();
// brand: "dt" | "classic"
// theme: "dark" | "light"
```
## Variant Utilities
```tsx
import { getVariantClass, variantToClassName } from "@dangerousthings/react";
getVariantClass("emphasis"); // mode class name for CSS
variantToClassName("warning"); // token-level variant mapping
```
## Monorepo
Part of the [DT Design System](https://github.com/dangerous-tac0s/dt-design-system) monorepo.
## License
[MIT](LICENSE)

View File

@@ -0,0 +1,62 @@
{
"name": "@dangerousthings/react",
"version": "2.1.0",
"description": "React web components for the Dangerous Things design system",
"license": "MIT",
"author": {
"name": "Dangerous Things",
"email": "info@dangerousthings.com"
},
"repository": {
"type": "git",
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
"directory": "packages/react"
},
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
"keywords": [
"dangerousthings",
"design-system",
"react",
"components",
"theming"
],
"engines": {
"node": ">=18.0.0"
},
"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"
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,99 @@
/**
* 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 top to bottom.
* When progress is omitted, the bar stays empty (full card, no fill).
*/
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;
/** Dismiss callback — renders a close button in the header bar */
onDismiss?: () => void;
/** Progress value (0100) for left-edge vertical progress bar. Omit for empty bar. */
progress?: number;
/** Grow to fill available space in flex/grid containers @default false */
grow?: boolean;
/** Which area expands when grow is true @default 'body' */
growArea?: 'body' | 'title';
className?: string;
style?: CSSProperties;
/** Badge content rendered at bottom-right, clipped by card bevel */
badge?: ReactNode;
onClick?: () => void;
}
export function DTCard({
children,
variant = 'normal',
title,
showHeader,
onDismiss,
progress,
grow = false,
growArea = 'body',
className,
style,
onClick,
badge,
}: DTCardProps) {
const shouldShowHeader = showHeader ?? !!(title || onDismiss);
const Tag = onClick ? 'button' : 'div';
return (
<Tag
className={cx(
'card',
getVariantClass(variant),
grow && 'dt-card-grow',
grow && growArea === 'title' && 'dt-card-grow-title',
className,
)}
style={{
...style,
...(progress != null && { '--dt-card-progress': progress }),
} as CSSProperties}
onClick={onClick}
type={onClick ? 'button' : undefined}>
{shouldShowHeader && (
<div className="card-title" style={onDismiss ? { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } : undefined}>
<span>{title}</span>
{onDismiss && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
aria-label="Close"
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
fontSize: '1.25rem',
lineHeight: 1,
}}>
&#x2715;
</button>
)}
</div>
)}
{children}
{badge && <div className="dt-card-badge">{badge}</div>}
</Tag>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,202 @@
/**
* DTDrawer — Sliding side panel with beveled edges.
*
* Matches the dt-shopify-storefront Aside pattern:
* - Outer colored shell (mode color) with beveled clip-path
* - Inner dark surface with inset bevel clip-path (creates colored border)
* - Colored header bar with bold heading + close button
* - Scrollable content area
* - Slides in and out from the correct edge
*
* CSS reference: bevels.css .dt-bevel-drawer-right, .dt-bevel-drawer-left
*/
import { useEffect, useState, useCallback, useRef, 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';
const DURATION = 200;
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 [mounted, setMounted] = useState(false);
const [closing, setClosing] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
// Open: mount immediately
useEffect(() => {
if (visible) {
setClosing(false);
setMounted(true);
}
}, [visible]);
// Close: play exit animation, then unmount
useEffect(() => {
if (!visible && mounted && !closing) {
setClosing(true);
timerRef.current = setTimeout(() => {
setMounted(false);
setClosing(false);
}, DURATION);
}
return () => clearTimeout(timerRef.current);
}, [visible, mounted, closing]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss],
);
useEffect(() => {
if (mounted && !closing) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [mounted, closing, handleKeyDown]);
if (!mounted) return null;
const bevelClass = position === 'right' ? 'dt-bevel-drawer-right' : 'dt-bevel-drawer-left';
const innerBevelClass = position === 'right' ? 'dt-bevel-drawer-right-inner' : 'dt-bevel-drawer-left-inner';
const widthValue = typeof width === 'number' ? `${width}px` : width;
const varCSSVar = getVariantCSSVar(headingVariant);
// Slide direction for exit: reverse of entry
const slideOut = position === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
return createPortal(
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
pointerEvents: closing ? 'none' : undefined,
}}>
{/* Backdrop */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.2)',
backdropFilter: 'blur(4px)',
animation: closing ? undefined : `dt-fade-in 0.4s ease-in-out both`,
opacity: closing ? 0 : undefined,
transition: closing ? `opacity ${DURATION}ms ease-in-out` : undefined,
}}
onClick={closing ? undefined : onDismiss}
/>
{/* Outer shell */}
<div
className={cx(
bevelClass,
getVariantClass(headingVariant),
className,
)}
style={{
position: 'absolute',
top: 0,
[position]: 0,
height: '99vh',
width: widthValue,
maxWidth: '100vw',
background: `var(${varCSSVar}, var(--color-primary))`,
padding: '3px',
paddingRight: position === 'right' ? 0 : '3px',
paddingLeft: position === 'left' ? 0 : '3px',
boxShadow: '0 0 50px rgba(0, 0, 0, 0.3)',
animation: closing ? undefined : `dt-slide-${position} ${DURATION}ms ease-in-out both`,
transform: closing ? slideOut : undefined,
transition: closing ? `transform ${DURATION}ms ease-in-out` : undefined,
...style,
}}>
{/* Inner surface */}
<div
className={innerBevelClass}
style={{
height: '100%',
background: 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
}}>
{/* Header */}
<div
style={{
height: 64,
padding: '0 1em',
background: `var(${varCSSVar}, var(--color-primary))`,
color: 'var(--color-bg)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontWeight: 900,
fontSize: '2em',
letterSpacing: '0.05em',
borderBottom: '1px solid var(--color-bg)',
}}>
<span>{heading}</span>
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '0.6em',
fontWeight: 700,
cursor: 'pointer',
padding: '0 8px',
opacity: 0.8,
}}
type="button">
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
{children}
</div>
</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];
}

View File

@@ -0,0 +1,142 @@
/**
* DTFeatureLegend — Interactive grid of product feature icons with rotated labels.
*
* Matches dt-shopify-storefront UseCaseLegend: header bar with hover details,
* rotated vertical labels (above for row 0, below for row 1), ? toggle,
* bordered grid with dark cell backgrounds, and pulse-on-hover icons.
*
* CSS reference: feature-legend.css
*/
import { useState, type ReactNode, type CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
export interface DTFeatureItem {
key: string;
name: string;
icon: ReactNode;
state: 'supported' | 'disabled' | 'unsupported';
/** Optional detail text shown in the header on hover */
detail?: string;
}
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;
/** Show rotated labels initially @default true */
showLabels?: boolean;
className?: string;
style?: CSSProperties;
}
export function DTFeatureLegend({
features,
title,
variant = 'normal',
columns = 5,
iconSize = 42,
showLabels: initialShowLabels = true,
className,
style,
}: DTFeatureLegendProps) {
const [showLabels, setShowLabels] = useState(initialShowLabels);
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
const hoveredItem = hoveredFeature
? features.find(f => f.key === hoveredFeature)
: null;
const rowCount = Math.ceil(features.length / columns);
const hasBottomRow = rowCount >= 2;
return (
<div
className={cx('dt-feature-legend', getVariantClass(variant), className)}
style={style}>
{/* Header bar — title + hovered feature detail + ? toggle */}
{title && (
<div className="dt-feature-legend-header">
<div className="dt-feature-legend-header-content">
<div className="dt-feature-legend-title">{title}</div>
<div className="dt-feature-legend-detail">
{hoveredItem ? (
<span className={stateClassMap[hoveredItem.state]}>
{hoveredItem.name}{hoveredItem.detail ? ': ' : ''}
{hoveredItem.detail && <strong>{hoveredItem.detail}</strong>}
</span>
) : (
<span className="dt-feature-legend-hint">
Hover a feature for details
</span>
)}
</div>
</div>
<button
className="dt-feature-legend-toggle"
onClick={() => setShowLabels(v => !v)}
aria-label={showLabels ? 'Hide feature labels' : 'Show feature labels'}
title={showLabels ? 'Hide labels' : 'Show labels'}
type="button">
?
</button>
</div>
)}
{/* Icon grid with border */}
<div className="dt-feature-legend-body">
<div
className={cx(
'dt-feature-legend-grid',
showLabels && 'with-labels',
showLabels && hasBottomRow && 'with-labels-bottom',
)}
style={{ '--dt-feature-columns': columns } as CSSProperties}>
{features.map((feature, index) => {
const row = Math.floor(index / columns);
const labelPosition = rowCount >= 2 ? (row === 0 ? 'above' : 'below') : 'above';
return (
<div
key={feature.key}
className={cx('dt-feature-legend-item', stateClassMap[feature.state])}
style={{ width: `${100 / columns}%` }}
onMouseEnter={() => setHoveredFeature(feature.key)}
onMouseLeave={() => setHoveredFeature(null)}>
{/* Rotated vertical label — always in DOM, visibility via CSS */}
<div className={cx(
'dt-feature-legend-rotated-label',
labelPosition === 'below' ? 'label-below' : 'label-above',
showLabels && 'label-visible',
)}>
<span>{feature.name}</span>
</div>
<div
className="dt-feature-legend-icon"
style={{ width: `${iconSize}px`, height: `${iconSize}px` }}>
{feature.icon}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
/**
* DTGallery — Media gallery with beveled frame and thumbnails.
* Supports images, 3D models (via <model-viewer>), videos, and external videos.
*
* CSS reference: bevels.css .dt-bevel-media
*/
import { useState, useEffect, type CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
/* ── TypeScript declaration for <model-viewer> web component ── */
declare global {
namespace JSX {
interface IntrinsicElements {
'model-viewer': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
src?: string;
alt?: string;
poster?: string;
'camera-controls'?: boolean | string;
'auto-rotate'?: boolean | string;
'interaction-prompt'?: string;
ar?: boolean | string;
loading?: 'auto' | 'lazy' | 'eager';
};
}
}
}
/* ── Gallery item types ── */
export type DTGalleryItem =
| { type?: 'image'; key: string; src: string; alt?: string; thumbnail?: string }
| { type: 'model3d'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
| { type: 'video'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
| { type: 'external-video'; key: string; src: string; alt?: string; thumbnail?: string };
interface DTGalleryProps {
items: DTGalleryItem[];
/** Externally-controlled active index. When this value changes, the gallery
* jumps to the corresponding item. Thumbnail clicks still work normally. */
activeIndex?: number;
/** Color variant @default 'normal' */
variant?: DTVariant;
/** Aspect ratio (width / height) for the display area @default 1 */
aspectRatio?: number;
className?: string;
style?: CSSProperties;
}
/* ── Model-viewer script loader ── */
const MODEL_VIEWER_SRC =
'https://unpkg.com/@google/model-viewer@v1.12.1/dist/model-viewer.min.js';
let modelViewerLoaded = false;
function useModelViewerScript(needed: boolean) {
useEffect(() => {
if (!needed || modelViewerLoaded || typeof document === 'undefined') return;
if (customElements.get('model-viewer')) {
modelViewerLoaded = true;
return;
}
const script = document.createElement('script');
script.type = 'module';
script.src = MODEL_VIEWER_SRC;
document.head.appendChild(script);
modelViewerLoaded = true;
}, [needed]);
}
/* ── Shared styles ── */
const fillStyle: CSSProperties = {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
};
/* ── Component ── */
export function DTGallery({
items,
activeIndex: activeIndexProp,
variant = 'normal',
aspectRatio = 1,
className,
style,
}: DTGalleryProps) {
const [activeIndex, setActiveIndex] = useState(activeIndexProp ?? 0);
// Sync external activeIndex prop to internal state when it changes
useEffect(() => {
if (activeIndexProp != null && activeIndexProp >= 0 && activeIndexProp < items.length) {
setActiveIndex(activeIndexProp);
}
}, [activeIndexProp, items.length]);
const hasModel = items.some((i) => i.type === 'model3d');
useModelViewerScript(hasModel);
if (items.length === 0) return null;
const current = items[activeIndex];
const currentType = current.type || 'image';
return (
<div className={cx(getVariantClass(variant), className)} style={style}>
{/* Main display */}
<div
className="dt-bevel-media"
style={{ overflow: 'hidden', aspectRatio, position: 'relative' }}
>
{currentType === 'image' && (
<img
src={current.src}
alt={current.alt || ''}
style={{
...fillStyle,
objectFit: 'contain',
transition: 'opacity 300ms ease-in-out',
}}
/>
)}
{currentType === 'model3d' && (
<model-viewer
src={current.src}
alt={current.alt || ''}
poster={'poster' in current ? current.poster : undefined}
camera-controls=""
auto-rotate=""
interaction-prompt="auto"
loading="lazy"
style={{
...fillStyle,
backgroundColor: 'var(--color-bg, #000)',
}}
/>
)}
{currentType === 'video' && (
<video
src={current.src}
poster={'poster' in current ? current.poster : undefined}
controls
playsInline
preload="metadata"
style={{
...fillStyle,
objectFit: 'contain',
}}
/>
)}
{currentType === 'external-video' && (
<iframe
src={current.src}
title={current.alt || 'Video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
...fillStyle,
border: 'none',
}}
/>
)}
</div>
{/* Thumbnails */}
{items.length > 1 && (
<div
style={{
display: 'flex',
gap: '8px',
marginTop: '8px',
overflowX: 'auto',
}}
>
{items.map((item, i) => {
const itemType = item.type || 'image';
const thumbSrc = item.thumbnail || ('poster' in item ? item.poster : undefined) || item.src;
const isMedia = itemType === 'video' || itemType === 'external-video';
return (
<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',
position: 'relative',
}}
>
<img
src={thumbSrc}
alt={item.alt || ''}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Play icon overlay for video thumbnails */}
{isMedia && (
<svg
viewBox="0 0 24 24"
fill="white"
style={{
position: 'absolute',
inset: 0,
margin: 'auto',
width: '24px',
height: '24px',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.6))',
pointerEvents: 'none',
}}
>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
);
})}
</div>
)}
</div>
);
}

View 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>
);
}

View 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', 'p-3', 'badge-mode', getVariantClass(variant), className)}
style={style}>
{children}
</span>
);
}

View File

@@ -0,0 +1,44 @@
/**
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
*
* CSS reference: bevels.css .dt-bevel-media
*
* The outer div provides the colored bevel border via clip-path.
* A ::before pseudo-element fills the inner surface.
* Direct img/video children are auto-clipped to match.
* A ::after pseudo-element shows "MISSING MEDIA" when empty.
*/
import type { ReactNode, CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
interface DTMediaFrameProps {
children: ReactNode;
/** Color variant for the bevel border @default 'normal' */
variant?: DTVariant;
/** Custom placeholder shown when children is nullish (overrides CSS ::after) */
placeholder?: ReactNode;
className?: string;
style?: CSSProperties;
}
export function DTMediaFrame({
children,
variant = 'normal',
placeholder,
className,
style,
}: DTMediaFrameProps) {
const isEmpty = children == null || children === false;
return (
<div
className={cx('dt-bevel-media', getVariantClass(variant), className)}
style={style}
>
{isEmpty && placeholder ? placeholder : children}
</div>
);
}

View 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}
/>
))}
</>
);
}

View File

@@ -0,0 +1,168 @@
/**
* 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, useRef, 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 panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss],
);
// Escape key
useEffect(() => {
if (visible) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [visible, handleKeyDown]);
// Body scroll lock
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [visible]);
// Focus trap
useEffect(() => {
if (!visible) return;
triggerRef.current = document.activeElement as HTMLElement;
const timer = setTimeout(() => {
const first = panelRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
first?.focus();
}, 100);
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !panelRef.current) return;
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => {
clearTimeout(timer);
document.removeEventListener('keydown', handleTab);
triggerRef.current?.focus();
};
}, [visible]);
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 ref={panelRef} 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', 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',
}}>
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,
);
}

View File

@@ -0,0 +1,118 @@
/**
* 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',
display: 'flex',
flexDirection: 'column',
...style,
}}
role="dialog"
aria-modal="true">
{title && (
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span>{title}</span>
{dismissable && (
<button
type="button"
onClick={onDismiss}
aria-label="Close"
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
fontSize: '1.25rem',
lineHeight: 1,
}}>
&#x2715;
</button>
)}
</div>
)}
<div style={{ padding: 'var(--space-6, 24px)', overflow: 'auto', flex: '1 1 auto' }}>{children}</div>
</div>
</div>,
document.body,
);
}

View 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 (01) */
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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,68 @@
/**
* DTRadioGroup — Hexagonal radio buttons.
*
* Uses a custom span for the hexagon indicator since ::before pseudo-elements
* on <input> are unreliable in Chromium/Electron.
*
* CSS reference: forms-dt.css .dt-radio-hex, .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 => {
const checked = value === option.value;
return (
<label
key={option.value}
className={cx('dt-radio-option', checked && 'selected')}>
<input
type="radio"
name={groupName}
value={option.value}
checked={checked}
onChange={() => onChange(option.value)}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }}
/>
<span className={cx('dt-radio-hex', checked && 'checked')}>
<span className="dt-radio-hex-inner" />
</span>
<span>{option.label}</span>
</label>
);
})}
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,47 @@
/**
* DTStaggerContainer — Staggered scale-in animation for children.
*
* CSS reference: animations.css .dt-stagger-container
* Each child is wrapped in a div that carries the animationDelay.
* Wrapper divs inherit display:contents would break animation, so
* the CSS rule targets .dt-stagger-container > * directly.
*/
import { Children, type ReactNode, type 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 in ms @default 150 */
interval?: number;
/** Base delay before the first child animates in ms @default 0 */
delay?: number;
className?: string;
style?: CSSProperties;
}
export function DTStaggerContainer({
children,
duration,
interval = 150,
delay = 0,
className,
style,
}: DTStaggerContainerProps) {
const cssVars: Record<string, string> = {};
if (duration) cssVars['--dt-stagger-duration'] = duration;
return (
<div
className={cx('dt-stagger-container', className)}
style={{ ...cssVars, ...style } as CSSProperties}>
{Children.map(children, (child, i) => (
<div style={{ animationDelay: `${delay + i * interval}ms` }}>
{child}
</div>
))}
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,52 @@
/**
* 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,
letterSpacing: '0.05em',
}}>
{label}
</label>
)}
<input
{...inputProps}
id={inputId}
className={cx('input', error && 'error')}
style={{ width: '100%', padding: '8px 12px' }}
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* DTWebThemeProvider
*
* Sets data-brand and data-theme attributes on a wrapper div (default)
* or on document.documentElement (documentLevel mode).
* Provides React context for child components to read the active brand/theme.
*/
import { createContext, useEffect, 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;
/** When true, sets data-brand/data-theme on document.documentElement instead of a wrapper div */
documentLevel?: boolean;
/** Additional className for the wrapper div (ignored when documentLevel is true) */
className?: string;
children: ReactNode;
}
export function DTWebThemeProvider({
brand = 'dt',
theme = 'dark',
documentLevel,
className,
children,
}: DTWebThemeProviderProps) {
useEffect(() => {
if (documentLevel) {
document.documentElement.dataset.brand = brand;
document.documentElement.dataset.theme = theme;
}
}, [documentLevel, brand, theme]);
return (
<DTWebThemeContext.Provider value={{ brand, theme }}>
{documentLevel ? (
children
) : (
<div data-brand={brand} data-theme={theme} className={cx('dt-theme-root', className)}>
{children}
</div>
)}
</DTWebThemeContext.Provider>
);
}

View 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;
}

View 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]);
}

View 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]);
}

View File

@@ -0,0 +1,62 @@
/**
* @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 { 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';
// Components — animation
export { DTStaggerContainer } from './components/DTStaggerContainer';

View 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(' ');
}

View 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];
}

View File

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

View File

@@ -0,0 +1,18 @@
appId: com.dangerousthings.showcase-desktop
productName: DT Design System Showcase
directories:
output: release
buildResources: resources
npmRebuild: false
artifactName: "dt-showcase-desktop-${version}-${arch}.${ext}"
files:
- dist/**/*
- package.json
- "!node_modules"
mac:
target: [dmg, zip]
win:
target: [nsis, portable]
linux:
target: [AppImage, deb]
category: Development

View File

@@ -0,0 +1,58 @@
{
"name": "@dangerousthings/showcase-desktop",
"version": "0.0.0",
"private": true,
"license": "MIT",
"description": "Desktop showcase for the Dangerous Things design system",
"author": {
"name": "Dangerous Things",
"email": "info@dangerousthings.com"
},
"repository": {
"type": "git",
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
"directory": "packages/showcase/desktop"
},
"homepage": "https://github.com/dangerous-tac0s/dt-design-system",
"main": "dist/main/main.js",
"scripts": {
"dev": "vite --config vite.config.ts",
"dev:electron": "npm run build:main && concurrently \"vite --config vite.config.ts\" \"wait-on http://localhost:5173 && electron .\"",
"build": "tsc -p tsconfig.main.json && vite build --config vite.config.ts",
"build:main": "tsc -p tsconfig.main.json",
"start": "electron .",
"package": "npm run build && electron-builder",
"package:dir": "npm run build && electron-builder --dir",
"package:win": "npm run build && electron-builder --win",
"package:mac": "npm run build && electron-builder --mac",
"package:linux": "npm run build && electron-builder --linux",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.main.json",
"clean": "rm -rf dist release"
},
"dependencies": {
"@dangerousthings/hex-background": "^0.2.0",
"@dangerousthings/react": "*",
"@dangerousthings/tokens": "*",
"@dangerousthings/web": "*",
"@react-three/fiber": "^9.5.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-icons": "^5.6.0",
"three": "^0.183.2"
},
"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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,30 @@
import { app, BrowserWindow } from 'electron';
import path from 'path';
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
backgroundColor: '#000000',
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL);
} else {
win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

View File

@@ -0,0 +1 @@
// Minimal preload — no IPC needed for the showcase

View File

@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react';
import { themes } from '@dangerousthings/tokens';
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
import { HexGridBackground } from '@dangerousthings/hex-background';
import type { HexGridBackgroundProps } from '@dangerousthings/hex-background';
import { HomePage } from './pages/HomePage';
import { BevelsPage } from './pages/BevelsPage';
import { FormsPage } from './pages/FormsPage';
import { AnimationsPage } from './pages/AnimationsPage';
import { CardsAdvancedPage } from './pages/CardsAdvancedPage';
import { TokensPage } from './pages/TokensPage';
import { HexBackgroundPage } from './pages/HexBackgroundPage';
const navPages = [
{ hash: '', label: 'Home' },
{ hash: 'bevels', label: 'Bevels' },
{ hash: 'forms', label: 'Forms' },
{ hash: 'animations', label: 'Animations' },
{ hash: 'cards-advanced', label: 'Advanced Cards' },
{ hash: 'hex-background', label: 'Hex Background' },
{ hash: 'tokens', label: 'Tokens' },
];
export function App() {
const [brand, setBrand] = useState<ThemeBrand>('dt');
const [theme, setTheme] = useState<ThemeMode>('dark');
const [currentHash, setCurrentHash] = useState('');
const [hexProps, setHexProps] = useState<HexGridBackgroundProps>({});
// 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 'forms': return <FormsPage />;
case 'animations': return <AnimationsPage />;
case 'cards-advanced': return <CardsAdvancedPage />;
case 'hex-background': return <HexBackgroundPage hexProps={hexProps} onHexPropsChange={setHexProps} />;
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>
<HexGridBackground {...hexProps} />
<main id="content">
{renderPage()}
</main>
</>
);
}

View File

@@ -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>;
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" data-brand="dt" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DT Design System — Desktop Showcase</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View 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 />);

View File

@@ -0,0 +1,194 @@
import { useState, useEffect, useRef } from 'react';
import { DTCard } 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>
);
}
const VARIANTS = ['normal', 'emphasis', 'warning', 'success', 'other'] as const;
function ProgressBarDemo() {
const [progress, setProgress] = useState<number[]>([0, 0, 0, 0, 0]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [running, setRunning] = useState(false);
const animate = () => {
// Reset then animate up
setProgress([0, 0, 0, 0, 0]);
setRunning(true);
let tick = 0;
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
tick++;
setProgress(prev =>
prev.map((_, i) => Math.min(100, Math.round(tick * (1.5 + i * 0.4))))
);
if (tick > 80) {
if (intervalRef.current) clearInterval(intervalRef.current);
setRunning(false);
}
}, 40);
};
useEffect(() => {
// Auto-run on mount
const t = setTimeout(animate, 500);
return () => {
clearTimeout(t);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return (
<div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 16 }}>
{VARIANTS.map((v, i) => (
<DTCard
key={v}
variant={v}
title={`${v} ${progress[i]}%`}
progress={progress[i]}
style={{ width: 160, transition: 'all 0.1s ease-out' }}
>
<div className="card-body" style={{ fontSize: '0.75rem', minHeight: 60, display: 'flex', alignItems: 'center' }}>
Progress fills the left edge from bottom to top
</div>
</DTCard>
))}
</div>
<button
className="btn-secondary"
onClick={animate}
disabled={running}
type="button"
>
{running ? 'ANIMATING...' : 'REPLAY PROGRESS'}
</button>
</div>
);
}
export function AnimationsPage() {
const [entranceKey, setEntranceKey] = useState(0);
const [accordionExpanded, setAccordionExpanded] = useState(false);
return (
<>
<h1 className="page-title">Animations</h1>
<p className="page-subtitle">Entrance animations, interactive effects, 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="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,
}}
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,
}}>
&#x25BC;
</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="Card Progress Bar" description="Cards have a vertical progress bar on the left edge. Animate it by transitioning --dt-card-progress from 0 to 100.">
<ProgressBarDemo />
<CodeLabel text="<DTCard progress={value} /> — CSS ::after gradient driven by --dt-card-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>
</>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import { DTCard, DTModal, DTDrawer } 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() {
const [modalVariant, setModalVariant] = useState<DTVariant | null>(null);
const [drawerSide, setDrawerSide] = useState<'right' | 'left' | null>(null);
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="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">IN STOCK</span>
<span className="badge badge-error">SOLD OUT</span>
<span className="badge badge-warning">LAB</span>
<span className="badge badge-info">NFC</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). Dual-element border with inner surface fill.">
<div className="grid grid-cols-3 gap-dt-4">
<div className="dt-bevel-media mode-normal" style={{ aspectRatio: '16/9' }}>
<img src="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=600&h=338&fit=crop" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
<div className="dt-bevel-media mode-emphasis" style={{ aspectRatio: '16/9' }}>
<img src="https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&h=338&fit=crop" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
<div className="dt-bevel-media mode-warning" style={{ aspectRatio: '16/9' }} />
</div>
<CodeLabel text=".dt-bevel-media | .dt-bevel-media (no img = 'MISSING MEDIA' placeholder)" />
</Section>
<Section title="Modals" description="Beveled card dialog with backdrop blur. Click to open, click backdrop or press Escape to dismiss.">
<Row>
{modes.map(mode => (
<button key={mode} className={`dt-menu-item mode-${mode}`} type="button" onClick={() => setModalVariant(mode)}>
{mode} Modal
</button>
))}
</Row>
<CodeLabel text="<DTModal variant='normal' visible onDismiss title='...'>content</DTModal>" />
<DTModal visible={modalVariant !== null} onDismiss={() => setModalVariant(null)} variant={modalVariant ?? 'normal'} title={`${modalVariant ?? 'normal'} Modal`}>
<p style={{ marginBottom: 'var(--space-4)' }}>This is a <strong>{modalVariant}</strong> modal with beveled card shape, backdrop blur, and scale-in animation.</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>Click the backdrop or press Escape to dismiss.</p>
</DTModal>
</Section>
<Section title="Drawers" description="Sliding side panel with beveled edges. Click to open from either side.">
<Row>
<button className="dt-menu-item mode-emphasis" type="button" onClick={() => setDrawerSide('right')}>
OPEN RIGHT DRAWER
</button>
<button className="dt-menu-item mode-other" type="button" onClick={() => setDrawerSide('left')}>
OPEN LEFT DRAWER
</button>
</Row>
<CodeLabel text="<DTDrawer position='right' heading='...' visible onDismiss>content</DTDrawer>" />
<DTDrawer visible={drawerSide !== null} onDismiss={() => setDrawerSide(null)} position={drawerSide ?? 'right'} heading={`${drawerSide ?? 'right'} Drawer`} headingVariant={drawerSide === 'left' ? 'other' : 'emphasis'}>
<p style={{ marginBottom: 'var(--space-4)' }}>Sliding panel from the <strong>{drawerSide}</strong> edge with beveled corners and backdrop blur.</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>Click the backdrop, press Escape, or click to dismiss.</p>
</DTDrawer>
</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 }}>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}>
<div className="card-body" style={{ fontSize: '0.75rem' }}>mode-{mode}</div>
</DTCard>
))}
</div>
<CodeLabel text="<DTCard variant='normal'> | 'emphasis' | 'warning' | 'success' | 'other'" />
</Section>
</>
);
}

View File

@@ -0,0 +1,168 @@
import { createElement, type CSSProperties } from 'react';
import {
DTCard,
DTFeatureLegend,
} from '@dangerousthings/react';
import type { DTFeatureItem } from '@dangerousthings/react';
import type { DTVariant } from '@dangerousthings/tokens';
import { Section, 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)
// NExT v2: NTAGI2C (HF) + T5577 (LF) with power-harvesting LED
const chipFeatures: DTFeatureItem[] = [
{ key: 'smartphone', name: 'Smartphone', icon: ico(MdOutlinePhonelinkRing), state: 'supported', detail: 'Full NFC smartphone support' },
{ key: 'access_control', name: 'Access Control', icon: ico(MdOutlineVpnKey), state: 'supported', detail: 'Legacy' },
{ key: 'digital_security', name: 'Digital Security', icon: ico(FaUserShield), state: 'unsupported', detail: 'Not Supported' },
{ key: 'cryptography', name: 'Cryptography', icon: ico(LuBinary), state: 'unsupported', detail: 'Not Supported' },
{ key: 'data_sharing', name: 'Data Sharing', icon: ico(MdOutlineMobileScreenShare), state: 'supported', detail: '1 kB' },
{ key: 'payment', name: 'Payment', icon: ico(MdOutlineCreditCard), state: 'unsupported', detail: 'Not Supported' },
{ key: 'magic', name: 'Magic', icon: ico(MdOutlineCopyAll), state: 'supported', detail: 'Yes' },
{ key: 'Illumination', name: 'Illumination', icon: ico(MdOutlineLightbulb), state: 'supported', detail: 'HF: green, blue, white' },
{ key: 'temperature', name: 'Sensors', icon: ico(MdOutlineThermostat), state: 'unsupported', detail: 'None' },
];
// Full biomagnet feature legend (4 features — from storefront MagnetUseCaseLegend)
// m0422a: axial neodymium disc, bioresin coated
const magnetFeatures: DTFeatureItem[] = [
{ key: 'sensing', name: 'Sensing', icon: ico(MdOutlineSensors), state: 'supported', detail: 'RP 22,860 G/g' },
{ key: 'lifting', name: 'Lifting', icon: ico(MdOutlineFitbit), state: 'supported', detail: '~20 g' },
{ key: 'haptics', name: 'Haptics', icon: ico(MdOutlineVibration), state: 'supported', detail: 'Push/Pull' },
{ key: 'polarity', name: 'Polarity Detection', icon: ico(MdOutlineExplore), state: 'supported', detail: 'Monopole' },
];
// Full aesthetic feature legend (2 features — from storefront AestheticUseCaseLegend)
const aestheticFeatures: DTFeatureItem[] = [
{ key: 'illumination', name: 'Illumination', icon: ico(MdLightbulbOutline), state: 'supported', detail: 'HF: red, green, blue, white' },
{ key: 'prominence', name: 'Prominence', icon: ico(MdOutlineVisibility), state: 'supported', detail: 'Low' },
];
export function CardsAdvancedPage() {
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}>
<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 Grow" description="Cards stretch to equal height in a row via grow prop. Compare the varying content heights.">
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
<DTCard variant="normal" title="SHORT" grow>
<div className="card-body" style={{ fontSize: '0.8rem' }}>One line</div>
</DTCard>
<DTCard variant="emphasis" title="TALL" grow>
<div className="card-body" style={{ fontSize: '0.8rem' }}>
Multiple lines of content to demonstrate that the shorter card stretches to match this card's height.
</div>
</DTCard>
<DTCard variant="other" title="MEDIUM" grow>
<div className="card-body" style={{ fontSize: '0.8rem' }}>Two lines of content here.</div>
</DTCard>
</div>
<CodeLabel text="<DTCard grow /> — flex-grow: 1 + height: 100% to fill container" />
</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 card 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'}
badge={<span>{badge.label}</span>}>
<div className="card-body-flush" style={{ aspectRatio: '1' }}>
<img src={badge.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
</DTCard>
))}
</div>
<CodeLabel text="<DTCard badge={<span>LAB</span>}> — badge rendered and positioned by card" />
</Section>
<Section title="Buttons" description="Beveled buttons with active/selected states and mode colors. Nested levels use inline paddingLeft.">
<div style={{ maxWidth: 300 }}>
{[
{ name: 'All Products', cls: ' active', pad: 0 },
{ name: 'NFC Implants', cls: '', pad: 0 },
{ name: 'RFID Tags', cls: '', pad: 32 },
{ name: 'Accessories', cls: '', pad: 32 },
{ name: 'Lab Products', cls: ' mode-warning', pad: 0 },
].map(item => (
<button
key={item.name}
className={`dt-menu-item${item.cls}`}
style={item.pad ? { paddingLeft: item.pad } : undefined}
type="button"
>
{item.name}
</button>
))}
</div>
<CodeLabel text=".dt-menu-item | .active | paddingLeft for nesting" />
</Section>
<Section title="Feature Legends" description="Interactive product feature grids from the storefront. Hover icons for details. Toggle labels with the ? button.">
<DTFeatureLegend features={chipFeatures} title="NExT v2 Features" variant="normal" columns={5} />
<div style={{ height: 'var(--space-6)' }} />
<DTFeatureLegend features={magnetFeatures} title="m0422a Features" variant="emphasis" columns={4} />
<div style={{ height: 'var(--space-6)' }} />
<DTFeatureLegend features={aestheticFeatures} title="xLED HF Features" variant="other" columns={2} />
<div style={{ height: 'var(--space-4)' }} />
<CodeLabel text="<DTFeatureLegend features={chipFeatures} title='NExT 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>
</>
);
}

View File

@@ -0,0 +1,128 @@
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="Buttons" description="Beveled buttons with active state and mode colors. Nested levels use inline paddingLeft.">
<div style={{ maxWidth: 300 }}>
{[
{ name: 'All Products', cls: ' active', pad: 0 },
{ name: 'NFC Implants', cls: '', pad: 0 },
{ name: 'RFID Tags', cls: '', pad: 32 },
{ name: 'Accessories', cls: '', pad: 32 },
{ name: 'Lab Products', cls: ' mode-warning', pad: 0 },
].map(item => (
<button
key={item.name}
className={`dt-menu-item${item.cls}`}
style={item.pad ? { paddingLeft: item.pad } : undefined}
type="button"
>
{item.name}
</button>
))}
</div>
<CodeLabel text=".dt-menu-item | .active | paddingLeft for nesting" />
</Section>
</>
);
}

View File

@@ -0,0 +1,112 @@
import type { HexGridBackgroundProps } from '@dangerousthings/hex-background';
import { Section } from '../components/Section';
const DEFAULTS: Required<HexGridBackgroundProps> = {
opacity: 0.5,
hexRadius: 0.5,
margin: 0.05,
maxHeight: 3,
animationInterval: 1500,
cameraSpeed: 0.02,
cameraRadius: 8,
fov: 40,
};
interface SliderConfig {
key: keyof HexGridBackgroundProps;
label: string;
min: number;
max: number;
step: number;
}
const sliders: SliderConfig[] = [
{ key: 'opacity', label: 'Opacity', min: 0, max: 1, step: 0.05 },
{ key: 'hexRadius', label: 'Hex Radius', min: 0.1, max: 2, step: 0.1 },
{ key: 'margin', label: 'Margin', min: 0, max: 0.5, step: 0.01 },
{ key: 'maxHeight', label: 'Max Height', min: 0.5, max: 10, step: 0.5 },
{ key: 'animationInterval', label: 'Animation Interval (ms)', min: 200, max: 5000, step: 100 },
{ key: 'cameraSpeed', label: 'Camera Speed', min: 0, max: 0.2, step: 0.005 },
{ key: 'cameraRadius', label: 'Camera Radius', min: 3, max: 20, step: 0.5 },
{ key: 'fov', label: 'Field of View', min: 20, max: 90, step: 1 },
];
interface HexBackgroundPageProps {
hexProps: HexGridBackgroundProps;
onHexPropsChange: (props: HexGridBackgroundProps) => void;
}
export function HexBackgroundPage({ hexProps, onHexPropsChange }: HexBackgroundPageProps) {
const handleChange = (key: keyof HexGridBackgroundProps, value: number) => {
onHexPropsChange({ ...hexProps, [key]: value });
};
const handleReset = () => {
onHexPropsChange({ ...DEFAULTS });
};
return (
<>
<h1 className="page-title">Hex Background</h1>
<p className="page-subtitle">
3D hexagon grid background powered by Three.js + React Three Fiber.
Adjust parameters below changes apply to the global background in real time.
</p>
<Section title="Parameters" description="All HexGridBackground props exposed as controls.">
<div style={{ display: 'grid', gap: 'var(--space-4)' }}>
{sliders.map(({ key, label, min, max, step }) => (
<div key={key} style={{ display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 'var(--space-4)' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em' }}>
{label}
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={hexProps[key] ?? DEFAULTS[key]}
onChange={e => handleChange(key, parseFloat(e.target.value))}
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
/>
</label>
<span style={{
fontFamily: 'monospace',
fontSize: '0.85rem',
color: 'var(--color-primary)',
minWidth: '4.5em',
textAlign: 'right',
}}>
{hexProps[key] ?? DEFAULTS[key]}
</span>
</div>
))}
</div>
<button
className="dt-btn mode-normal"
onClick={handleReset}
type="button"
style={{ marginTop: 'var(--space-6)' }}
>
Reset to Defaults
</button>
</Section>
<Section title="Usage" description="Drop-in component for any React app with Three.js.">
<div className="terminal dt-accent-top">
<code>{`import { HexGridBackground } from '@dangerousthings/hex-background';
// Full-viewport fixed background (renders behind content)
<HexGridBackground
opacity={${hexProps.opacity ?? DEFAULTS.opacity}}
hexRadius={${hexProps.hexRadius ?? DEFAULTS.hexRadius}}
maxHeight={${hexProps.maxHeight ?? DEFAULTS.maxHeight}}
cameraSpeed={${hexProps.cameraSpeed ?? DEFAULTS.cameraSpeed}}
/>`}</code>
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { DTCard } 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: 'forms', title: 'Forms', desc: 'Text inputs, checkboxes, switches, radios, progress bars, accordions, steppers', mode: 'success', count: 7 },
{ hash: 'animations', title: 'Animations', desc: 'Entrance animations, 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',
}}>
DANGEROUS THINGS
</h1>
<p style={{
color: 'var(--mode-emphasis, var(--color-secondary))',
fontSize: '1.25rem',
fontWeight: 700,
letterSpacing: '0.2em',
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',
marginBottom: 'var(--space-4)',
}}>
COMPONENT CATALOG
</p>
<div 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}>
<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>
))}
</div>
<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>
</>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,302 @@
@tailwind components;
@tailwind utilities;
/* Showcase App Layout */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--color-bg);
color: var(--color-text-primary);
font-family: var(--font-body);
min-height: 100vh;
}
#app {
display: flex;
min-height: 100vh;
}
/* ---- Sidebar ---- */
#sidebar {
width: 220px;
min-height: 100vh;
background: var(--color-surface);
border-right: 2px solid var(--color-border);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
position: fixed;
top: 0;
left: 0;
overflow-y: auto;
}
.nav-title {
font-family: var(--font-heading);
font-weight: 900;
font-size: 0.875rem;
letter-spacing: 0.1em;
color: var(--color-primary);
padding-bottom: var(--space-4);
border-bottom: 2px solid var(--color-primary);
}
.brand-switcher-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.brand-btn {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-primary);
background: transparent;
color: var(--color-primary);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.05em;
cursor: pointer;
transition: all var(--transition-fast);
}
.brand-btn:hover {
background: rgba(var(--color-primary-rgb), 0.1);
}
.brand-btn.active {
background: var(--color-primary);
color: var(--color-bg);
}
.mode-switcher {
display: flex;
gap: var(--space-1);
margin-top: var(--space-2);
}
.mode-btn {
flex: 1;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
font-size: 0.625rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.mode-btn.active {
background: var(--color-primary);
color: var(--color-bg);
border-color: var(--color-primary);
}
.nav-links {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-link {
display: block;
padding: var(--space-2) var(--space-3);
color: var(--color-text-secondary);
text-decoration: none;
font-weight: 600;
font-size: 0.875rem;
letter-spacing: 0.02em;
border-left: 3px solid transparent;
transition: all var(--transition-fast);
}
.nav-link:hover {
color: var(--color-primary);
border-left-color: var(--color-primary);
background: rgba(var(--color-primary-rgb), 0.05);
}
.nav-link.active {
color: var(--color-primary);
border-left-color: var(--color-primary);
}
/* ---- Main Content ---- */
#content {
flex: 1;
margin-left: 220px;
padding: var(--space-8);
max-width: 900px;
}
/* ---- Demo Sections ---- */
.demo-section {
margin-bottom: var(--space-8);
}
.demo-section h2 {
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.125rem;
color: var(--color-primary);
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 2px solid var(--color-primary);
}
.demo-description {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-bottom: var(--space-4);
}
.demo-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
}
.demo-label {
color: var(--color-text-muted);
font-size: 0.6875rem;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
margin-top: var(--space-4);
}
.code-label {
color: rgba(var(--color-text-primary-rgb, 255, 255, 255), 0.35);
font-size: 0.625rem;
font-family: var(--font-mono);
margin-top: var(--space-1);
}
/* ---- Shared Component Base Styles ---- */
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-6);
font-weight: 700;
font-size: 0.875rem;
letter-spacing: 0.05em;
cursor: pointer;
transition: all var(--transition-fast);
border: none;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-bg);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: transparent;
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
.btn-danger {
background: var(--color-error);
color: var(--color-bg);
}
.btn-danger:hover {
opacity: 0.9;
}
/* Cards — DT brand padding is set in bevels.css via --_card-pad */
.card {
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;
letter-spacing: 0.05em;
}
.card-body {
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
/* Badges */
.badge {
display: inline-block;
padding: var(--space-2) var(--space-3);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
/* Inputs */
.input,
input[type="text"],
input[type="email"],
input[type="password"] {
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
width: 100%;
max-width: 400px;
border: 2px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text-primary);
}
.input:focus,
input:focus {
outline: none;
}
/* Terminal */
.terminal {
background: var(--color-surface);
border: 1px solid var(--color-border);
padding: var(--space-6);
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-accent, var(--color-primary));
line-height: 1.6;
}
/* Page title */
.page-title {
font-family: var(--font-heading);
font-weight: 900;
font-size: 1.75rem;
color: var(--color-primary);
letter-spacing: 0.1em;
margin-bottom: var(--space-2);
}
.page-subtitle {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-bottom: var(--space-8);
}

View 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,
},
};

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist/renderer",
"rootDir": "src/renderer",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src/renderer"]
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist/main",
"rootDir": "src/main",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/main"]
}

View File

@@ -0,0 +1,30 @@
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,
},
resolve: {
alias: {
'@dangerousthings/web': path.resolve(__dirname, '../../web/dist'),
},
},
optimizeDeps: {
// Don't pre-bundle design system packages — they're local workspace deps
// that change frequently during development. Pre-bundling caches them in
// node_modules/.vite which causes stale CSS/JS after rebuilds.
exclude: ['@dangerousthings/web', '@dangerousthings/react', '@dangerousthings/tokens'],
},
server: {
// Watch design system dist directories for changes during dev
watch: {
ignored: ['!**/packages/web/dist/**', '!**/packages/react/dist/**', '!**/packages/tokens/dist/**'],
},
},
});

Some files were not shown because too many files have changed in this diff Show More