From 6371c4fa023f31cff3014a806ffccb4321e685f3 Mon Sep 17 00:00:00 2001 From: Gabriel-Pereira1788 Date: Thu, 3 Sep 2026 17:59:34 -0300 Subject: [PATCH 1/3] feat(example): add dark design-system UI kit Design tokens ported 1:1 from packages/salve-db-studio's Tailwind theme (canvas #0d0d0d, accent #22d16c green, danger #fb7175, brand yellow/navy reserved for the stripe) plus a shared component kit: Button (5 variants), IconButton, Input, Card, Divider, ScreenHeader, StatusBadge, EmptyState, ProgressBar, Toast/useToast. Icons switch to phosphor-react-native (+ react-native-svg peer dep), replacing emoji throughout the app. Co-Authored-By: Claude Haiku 4.5 --- example/global.d.ts | 19 ++ example/ios/Podfile.lock | 49 +++++ .../SalveDbExample.xcodeproj/project.pbxproj | 58 +++--- example/package.json | 4 +- example/src/components/ui/Button.tsx | 84 +++++++++ example/src/components/ui/Card.tsx | 23 +++ example/src/components/ui/Divider.tsx | 11 ++ example/src/components/ui/EmptyState.tsx | 35 ++++ example/src/components/ui/IconButton.tsx | 46 +++++ example/src/components/ui/Input.tsx | 52 ++++++ example/src/components/ui/ProgressBar.tsx | 30 ++++ example/src/components/ui/ScreenHeader.tsx | 27 +++ example/src/components/ui/StatusBadge.tsx | 63 +++++++ example/src/components/ui/Toast.tsx | 79 ++++++++ example/src/components/ui/index.ts | 10 ++ example/src/theme/icons.ts | 27 +++ example/src/theme/tokens.ts | 72 ++++++++ package-lock.json | 170 +++++++++++++++++- 18 files changed, 824 insertions(+), 35 deletions(-) create mode 100644 example/global.d.ts create mode 100644 example/src/components/ui/Button.tsx create mode 100644 example/src/components/ui/Card.tsx create mode 100644 example/src/components/ui/Divider.tsx create mode 100644 example/src/components/ui/EmptyState.tsx create mode 100644 example/src/components/ui/IconButton.tsx create mode 100644 example/src/components/ui/Input.tsx create mode 100644 example/src/components/ui/ProgressBar.tsx create mode 100644 example/src/components/ui/ScreenHeader.tsx create mode 100644 example/src/components/ui/StatusBadge.tsx create mode 100644 example/src/components/ui/Toast.tsx create mode 100644 example/src/components/ui/index.ts create mode 100644 example/src/theme/icons.ts create mode 100644 example/src/theme/tokens.ts diff --git a/example/global.d.ts b/example/global.d.ts new file mode 100644 index 0000000..61df4bb --- /dev/null +++ b/example/global.d.ts @@ -0,0 +1,19 @@ +// Module augmentation required by phosphor-react-native's TypeScript setup — +// see the "Typescript support" section of its README. Declares only the +// extra `className` member each interface is missing; merges into the +// existing declaration rather than re-declaring it (an `extends` here would +// make the interface reference itself). +import 'react-native-svg'; +import 'phosphor-react-native'; + +declare module 'react-native-svg' { + interface SvgProps { + className?: string; + } +} + +declare module 'phosphor-react-native' { + interface IconProps { + className?: string; + } +} diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9f59529..354d1fa 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1939,6 +1939,51 @@ PODS: - React-utils (= 0.86.0) - ReactNativeDependencies - ReactNativeDependencies (0.86.0) + - RNSVG (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNSVG/common (= 15.15.5) + - Yoga + - RNSVG/common (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - SalveDb (0.1.1): - hermes-engine - NitroModules @@ -2044,6 +2089,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) - ReactNativeDependencies (from `../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - RNSVG (from `../../node_modules/react-native-svg`) - SalveDb (from `../..`) - Yoga (from `../../node_modules/react-native/ReactCommon/yoga`) @@ -2203,6 +2249,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/react-native/ReactCommon" ReactNativeDependencies: :podspec: "../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNSVG: + :path: "../../node_modules/react-native-svg" SalveDb: :path: "../.." Yoga: @@ -2286,6 +2334,7 @@ SPEC CHECKSUMS: ReactCodegen: 1973e629fe2614a7b2cf72919cb9935b6c34675f ReactCommon: d5c1bb4427bf51c443de5926aac332c89ddd9363 ReactNativeDependencies: fa0a54b3f5319ae0e3b9aff32bfee7a424b88e66 + RNSVG: 6cae8d4082913be2eb43eaec2a456222c42195ed SalveDb: c9d225fdca0b6ee1bf26697372566eac2aa0640f Yoga: fe50ab299e578f397fef753cf309c6703a4db29b diff --git a/example/ios/SalveDbExample.xcodeproj/project.pbxproj b/example/ios/SalveDbExample.xcodeproj/project.pbxproj index 9ce05e7..ef5f9c7 100644 --- a/example/ios/SalveDbExample.xcodeproj/project.pbxproj +++ b/example/ios/SalveDbExample.xcodeproj/project.pbxproj @@ -11,17 +11,17 @@ 6D150186035001D7357AA846 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; - C097E6C4B0ADD3EC7F80A6E8 /* libPods-SalveDbExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F8F2B860BF69F7821E07C3D /* libPods-SalveDbExample.a */; }; + C6E915F3DF6C60DE686559A7 /* libPods-SalveDbExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 49E666747AF051099A41A290 /* libPods-SalveDbExample.a */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 10655584FE167515188A1060 /* Pods-SalveDbExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SalveDbExample.debug.xcconfig"; path = "Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample.debug.xcconfig"; sourceTree = ""; }; + 0A7B746496E6D15E07A7307F /* Pods-SalveDbExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SalveDbExample.release.xcconfig"; path = "Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample.release.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* SalveDbExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SalveDbExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = SalveDbExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = SalveDbExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = SalveDbExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 1F8F2B860BF69F7821E07C3D /* libPods-SalveDbExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SalveDbExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 3A5618B54CF630B74C215667 /* Pods-SalveDbExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SalveDbExample.release.xcconfig"; path = "Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample.release.xcconfig"; sourceTree = ""; }; + 1C6E7DDF126616369256D989 /* Pods-SalveDbExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SalveDbExample.debug.xcconfig"; path = "Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample.debug.xcconfig"; sourceTree = ""; }; + 49E666747AF051099A41A290 /* libPods-SalveDbExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SalveDbExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SalveDbExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SalveDbExample/LaunchScreen.storyboard; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -32,7 +32,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C097E6C4B0ADD3EC7F80A6E8 /* libPods-SalveDbExample.a in Frameworks */, + C6E915F3DF6C60DE686559A7 /* libPods-SalveDbExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -55,7 +55,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 1F8F2B860BF69F7821E07C3D /* libPods-SalveDbExample.a */, + 49E666747AF051099A41A290 /* libPods-SalveDbExample.a */, ); name = Frameworks; sourceTree = ""; @@ -92,8 +92,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 10655584FE167515188A1060 /* Pods-SalveDbExample.debug.xcconfig */, - 3A5618B54CF630B74C215667 /* Pods-SalveDbExample.release.xcconfig */, + 1C6E7DDF126616369256D989 /* Pods-SalveDbExample.debug.xcconfig */, + 0A7B746496E6D15E07A7307F /* Pods-SalveDbExample.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -105,13 +105,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SalveDbExample" */; buildPhases = ( - C62BB49E30CCF2A01D9B41FC /* [CP] Check Pods Manifest.lock */, + 81270F069B86D64C1C9F9C4F /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - FFE2D58645D2744BF96615B9 /* [CP] Embed Pods Frameworks */, - 4B7D259B78BA68639FEB64C1 /* [CP] Copy Pods Resources */, + 8076584F57D7BAF8DB7AEDAA /* [CP] Embed Pods Frameworks */, + 67DE7510A084AA7FAAEDBF1E /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -183,7 +183,7 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; }; - 4B7D259B78BA68639FEB64C1 /* [CP] Copy Pods Resources */ = { + 67DE7510A084AA7FAAEDBF1E /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -191,60 +191,52 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; - C62BB49E30CCF2A01D9B41FC /* [CP] Check Pods Manifest.lock */ = { + 8076584F57D7BAF8DB7AEDAA /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SalveDbExample-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - FFE2D58645D2744BF96615B9 /* [CP] Embed Pods Frameworks */ = { + 81270F069B86D64C1C9F9C4F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SalveDbExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SalveDbExample/Pods-SalveDbExample-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -263,7 +255,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 10655584FE167515188A1060 /* Pods-SalveDbExample.debug.xcconfig */; + baseConfigurationReference = 1C6E7DDF126616369256D989 /* Pods-SalveDbExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -294,7 +286,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3A5618B54CF630B74C215667 /* Pods-SalveDbExample.release.xcconfig */; + baseConfigurationReference = 0A7B746496E6D15E07A7307F /* Pods-SalveDbExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; diff --git a/example/package.json b/example/package.json index 0b335c7..601199d 100644 --- a/example/package.json +++ b/example/package.json @@ -16,10 +16,12 @@ "dependencies": { "@react-native/new-app-screen": "0.86.0", "@salve-software/salvetron-react-native": "^1.1.5", + "phosphor-react-native": "^3.0.6", "react": "19.2.8", "react-native": "0.86.0", "react-native-nitro-modules": "0.36.1", - "react-native-safe-area-context": "^5.5.2" + "react-native-safe-area-context": "^5.5.2", + "react-native-svg": "^15.15.5" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/src/components/ui/Button.tsx b/example/src/components/ui/Button.tsx new file mode 100644 index 0000000..9c225ff --- /dev/null +++ b/example/src/components/ui/Button.tsx @@ -0,0 +1,84 @@ +import React, { useRef } from 'react'; +import { Animated, Pressable, StyleSheet, Text, ActivityIndicator, type StyleProp, type ViewStyle } from 'react-native'; +import { colors, radius, spacing, motion } from '../../theme/tokens'; + +export type ButtonVariant = 'primary' | 'ghost' | 'dangerSolid' | 'dangerOutline' | 'text'; +export type ButtonSize = 'sm' | 'md'; + +interface ButtonProps { + label: string; + onPress: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + disabled?: boolean; + loading?: boolean; + icon?: React.ReactNode; + style?: StyleProp; + testID?: string; +} + +/** + * Shared button — 5 variants matching the Studio's exact button vocabulary + * (primary green CTA / ghost outline / solid danger / outline danger / text-only), + * replacing the 5 divergent button heights and 6 border-radii found across + * the example app's screens before this redesign. + */ +export function Button({ label, onPress, variant = 'primary', size = 'md', disabled, loading, icon, style, testID }: ButtonProps): React.JSX.Element { + const scale = useRef(new Animated.Value(1)).current; + const isDisabled = disabled || loading; + + function pressIn() { + Animated.timing(scale, { toValue: 0.97, duration: motion.fast, useNativeDriver: true }).start(); + } + function pressOut() { + Animated.timing(scale, { toValue: 1, duration: motion.fast, useNativeDriver: true }).start(); + } + + const variantStyle = variantStyles[variant]; + const sizeStyle = size === 'sm' ? styles.sizeSm : styles.sizeMd; + + return ( + + + {loading ? ( + + ) : ( + <> + {icon} + {label} + + )} + + + ); +} + +const variantStyles: Record = { + primary: { container: { backgroundColor: colors.accent, borderWidth: 0 }, text: { color: colors.accentInk } }, + ghost: { container: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.line }, text: { color: colors.muted } }, + dangerSolid: { container: { backgroundColor: colors.danger, borderWidth: 0 }, text: { color: colors.canvas } }, + dangerOutline: { container: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.danger }, text: { color: colors.danger } }, + text: { container: { backgroundColor: 'transparent', borderWidth: 0, paddingHorizontal: 0 }, text: { color: colors.muted } }, +}; + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radius.sm, + }, + sizeMd: { height: 44, paddingHorizontal: spacing.lg }, + sizeSm: { height: 34, paddingHorizontal: spacing.md }, + label: { fontSize: 14, fontWeight: '600' }, + labelSm: { fontSize: 12 }, + disabled: { opacity: 0.4 }, +}); diff --git a/example/src/components/ui/Card.tsx b/example/src/components/ui/Card.tsx new file mode 100644 index 0000000..f675fcb --- /dev/null +++ b/example/src/components/ui/Card.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; +import { colors, radius, spacing } from '../../theme/tokens'; + +interface CardProps { + children: React.ReactNode; + style?: StyleProp; +} + +/** Studio's flat panel recipe — `rounded-lg border border-line bg-surface`, no shadow. */ +export function Card({ children, style }: CardProps): React.JSX.Element { + return {children}; +} + +const styles = StyleSheet.create({ + card: { + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.line, + backgroundColor: colors.surface, + padding: spacing.lg, + }, +}); diff --git a/example/src/components/ui/Divider.tsx b/example/src/components/ui/Divider.tsx new file mode 100644 index 0000000..f22dc16 --- /dev/null +++ b/example/src/components/ui/Divider.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { colors } from '../../theme/tokens'; + +export function Divider(): React.JSX.Element { + return ; +} + +const styles = StyleSheet.create({ + line: { height: StyleSheet.hairlineWidth, backgroundColor: colors.line }, +}); diff --git a/example/src/components/ui/EmptyState.tsx b/example/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..f5fc24f --- /dev/null +++ b/example/src/components/ui/EmptyState.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { colors, radius, spacing } from '../../theme/tokens'; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + message?: string; +} + +/** Studio's dashed-border empty-table recipe. */ +export function EmptyState({ icon, title, message }: EmptyStateProps): React.JSX.Element { + return ( + + {icon} + {title} + {message ? {message} : null} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + height: 160, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radius.md, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: colors.line, + }, + title: { fontSize: 14, fontWeight: '600', color: colors.ink }, + message: { fontSize: 12, color: colors.muted, textAlign: 'center', maxWidth: 240 }, +}); diff --git a/example/src/components/ui/IconButton.tsx b/example/src/components/ui/IconButton.tsx new file mode 100644 index 0000000..185ac70 --- /dev/null +++ b/example/src/components/ui/IconButton.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { colors, radius, spacing } from '../../theme/tokens'; + +interface IconButtonProps { + icon: React.ReactNode; + onPress: () => void; + disabled?: boolean; + variant?: 'ghost' | 'danger'; + testID?: string; +} + +/** Icon-only control — Studio's `rounded-md border border-line p-1.5` pattern. */ +export function IconButton({ icon, onPress, disabled, variant = 'ghost', testID }: IconButtonProps): React.JSX.Element { + return ( + [ + styles.base, + variant === 'danger' && styles.danger, + pressed && styles.pressed, + disabled && styles.disabled, + ]} + > + {icon} + + ); +} + +const styles = StyleSheet.create({ + base: { + width: 36, + height: 36, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: colors.line, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xs, + }, + danger: { borderColor: colors.danger }, + pressed: { borderColor: colors.lineStrong, backgroundColor: 'rgba(255,255,255,0.05)' }, + disabled: { opacity: 0.4 }, +}); diff --git a/example/src/components/ui/Input.tsx b/example/src/components/ui/Input.tsx new file mode 100644 index 0000000..472891a --- /dev/null +++ b/example/src/components/ui/Input.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { TextInput, View, Text, StyleSheet, type TextInputProps } from 'react-native'; +import { colors, radius, spacing } from '../../theme/tokens'; + +interface InputProps extends TextInputProps { + label?: string; +} + +/** + * One text-input pattern for the whole app — Studio's + * `bg-surface-2 border border-line focus:border-accent` recipe — replacing + * the 4 divergent input styles (pill, outlined, two different filled tones) + * found across screens before this redesign. + */ +export function Input({ label, style, onFocus, onBlur, ...rest }: InputProps): React.JSX.Element { + const [focused, setFocused] = useState(false); + + return ( + + {label ? {label} : null} + { + setFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + onBlur?.(e); + }} + placeholderTextColor={colors.muted} + style={[styles.input, focused && styles.inputFocused, style]} + /> + + ); +} + +const styles = StyleSheet.create({ + wrapper: { gap: spacing.xs }, + label: { fontSize: 12, fontWeight: '600', color: colors.muted }, + input: { + height: 44, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: colors.line, + backgroundColor: colors.surface2, + paddingHorizontal: spacing.md, + fontSize: 14, + color: colors.ink, + }, + inputFocused: { borderColor: colors.accent }, +}); diff --git a/example/src/components/ui/ProgressBar.tsx b/example/src/components/ui/ProgressBar.tsx new file mode 100644 index 0000000..f1f340c --- /dev/null +++ b/example/src/components/ui/ProgressBar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { colors, radius } from '../../theme/tokens'; + +interface ProgressBarProps { + /** 0–1. Values are clamped. */ + progress: number; + tone?: 'accent' | 'danger' | 'ok'; + height?: number; +} + +const toneColor = { accent: colors.accent, danger: colors.danger, ok: colors.ok }; + +/** + * One track/fill bar, reused by both the Query budget indicator and the + * Benchmark comparison bars — previously two separate implementations. + */ +export function ProgressBar({ progress, tone = 'accent', height = 8 }: ProgressBarProps): React.JSX.Element { + const pct = Math.max(0, Math.min(1, progress)) * 100; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + track: { backgroundColor: colors.surface2, overflow: 'hidden', borderRadius: radius.sm }, + fill: { height: '100%' }, +}); diff --git a/example/src/components/ui/ScreenHeader.tsx b/example/src/components/ui/ScreenHeader.tsx new file mode 100644 index 0000000..7c0782d --- /dev/null +++ b/example/src/components/ui/ScreenHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { colors, spacing } from '../../theme/tokens'; + +interface ScreenHeaderProps { + title: string; + subtitle?: string; +} + +/** + * One title/subtitle recipe for every screen — replaces the 3 divergent + * title sizes (32/24/22) found across the app before this redesign. + */ +export function ScreenHeader({ title, subtitle }: ScreenHeaderProps): React.JSX.Element { + return ( + + {title} + {subtitle ? {subtitle} : null} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, paddingBottom: spacing.sm, gap: spacing.xxs }, + title: { fontSize: 24, fontWeight: '800', color: colors.ink, letterSpacing: -0.3 }, + subtitle: { fontSize: 13, fontWeight: '500', color: colors.muted }, +}); diff --git a/example/src/components/ui/StatusBadge.tsx b/example/src/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..e9b418f --- /dev/null +++ b/example/src/components/ui/StatusBadge.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, View, Text, StyleSheet } from 'react-native'; +import { colors, alpha, radius, motion } from '../../theme/tokens'; + +export type StatusKind = 'ok' | 'pending' | 'danger' | 'muted'; + +interface StatusBadgeProps { + label: string; + status: StatusKind; + /** Animated pulse on the dot — Studio uses this for "live"/connecting states. */ + pulse?: boolean; +} + +const kindStyle: Record = { + ok: { bg: alpha.ok10, border: alpha.ok25, text: colors.ok, dot: colors.ok }, + pending: { bg: alpha.pending10, border: alpha.pending30, text: colors.brandYellow, dot: colors.brandYellow }, + danger: { bg: alpha.danger10, border: alpha.danger30, text: colors.danger, dot: colors.danger }, + muted: { bg: 'transparent', border: colors.line, text: colors.muted, dot: colors.muted }, +}; + +/** + * Pill + dot — the Studio's StatusBadge recipe (connected/disconnected), + * reused here as the canonical way to surface sync_queue/metadata states + * instead of raw "PENDING 3 · FAILED 1" strings. + */ +export function StatusBadge({ label, status, pulse }: StatusBadgeProps): React.JSX.Element { + const opacity = useRef(new Animated.Value(1)).current; + const tone = kindStyle[status]; + + useEffect(() => { + if (!pulse) return; + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { toValue: 0.35, duration: motion.pulse / 2, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 1, duration: motion.pulse / 2, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [pulse, opacity]); + + return ( + + + {label} + + ); +} + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: radius.pill, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 5, + alignSelf: 'flex-start', + }, + dot: { width: 6, height: 6, borderRadius: 3 }, + label: { fontSize: 11, fontWeight: '600' }, +}); diff --git a/example/src/components/ui/Toast.tsx b/example/src/components/ui/Toast.tsx new file mode 100644 index 0000000..3aa6c1e --- /dev/null +++ b/example/src/components/ui/Toast.tsx @@ -0,0 +1,79 @@ +import React, { createContext, useCallback, useContext, useRef, useState } from 'react'; +import { Animated, StyleSheet, Text, View } from 'react-native'; +import { colors, radius, spacing } from '../../theme/tokens'; + +export interface ToastContextValue { + showError: (message: string) => void; +} + +const ToastContext = createContext(null); + +const AUTO_DISMISS_MS = 4000; + +/** + * Global error banner — Studio's bottom toast recipe (danger dot + card, + * auto-dismiss). Mounted once at the app root via ; screens + * call useToast().showError(message) instead of rendering their own inline + * error , so a failure surfaces the same way everywhere. + */ +export function ToastProvider({ children }: { children: React.ReactNode }): React.JSX.Element { + const [message, setMessage] = useState(null); + const opacity = useRef(new Animated.Value(0)).current; + const dismissTimer = useRef(null); + + const showError = useCallback( + (text: string) => { + clearTimeout(dismissTimer.current ?? undefined); + setMessage(text); + Animated.timing(opacity, { toValue: 1, duration: 150, useNativeDriver: true }).start(); + dismissTimer.current = setTimeout(() => { + Animated.timing(opacity, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => setMessage(null)); + }, AUTO_DISMISS_MS) as unknown as number; + }, + [opacity], + ); + + return ( + + {children} + {message ? ( + + + + {message} + + + ) : null} + + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast() must be called within '); + return ctx; +} + +const styles = StyleSheet.create({ + toast: { + position: 'absolute', + left: spacing.lg, + right: spacing.lg, + bottom: spacing.xl, + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(251, 113, 117, 0.3)', + backgroundColor: colors.surface, + padding: spacing.md, + shadowColor: '#000', + shadowOpacity: 0.4, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + }, + dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.danger, marginTop: 5 }, + text: { flex: 1, fontSize: 13, color: colors.ink }, +}); diff --git a/example/src/components/ui/index.ts b/example/src/components/ui/index.ts new file mode 100644 index 0000000..bd99d74 --- /dev/null +++ b/example/src/components/ui/index.ts @@ -0,0 +1,10 @@ +export { Button, type ButtonVariant, type ButtonSize } from './Button'; +export { IconButton } from './IconButton'; +export { Input } from './Input'; +export { Card } from './Card'; +export { Divider } from './Divider'; +export { ScreenHeader } from './ScreenHeader'; +export { StatusBadge, type StatusKind } from './StatusBadge'; +export { EmptyState } from './EmptyState'; +export { ProgressBar } from './ProgressBar'; +export { ToastProvider, useToast, type ToastContextValue } from './Toast'; diff --git a/example/src/theme/icons.ts b/example/src/theme/icons.ts new file mode 100644 index 0000000..a05e63d --- /dev/null +++ b/example/src/theme/icons.ts @@ -0,0 +1,27 @@ +/** + * Single import point for every Phosphor icon the app uses — direct + * per-icon imports (not the barrel `phosphor-react-native` export) keep the + * bundle small; see the "Bundle size" note in phosphor-react-native's README. + */ +export { WalletIcon } from 'phosphor-react-native/src/icons/Wallet'; +export { StackIcon } from 'phosphor-react-native/src/icons/Stack'; +export { LightningIcon } from 'phosphor-react-native/src/icons/Lightning'; +export { ArrowsClockwiseIcon } from 'phosphor-react-native/src/icons/ArrowsClockwise'; +export { UserIcon } from 'phosphor-react-native/src/icons/User'; +export { ArrowCounterClockwiseIcon } from 'phosphor-react-native/src/icons/ArrowCounterClockwise'; +export { SignOutIcon } from 'phosphor-react-native/src/icons/SignOut'; +export { GearSixIcon } from 'phosphor-react-native/src/icons/GearSix'; +export { MagnifyingGlassIcon } from 'phosphor-react-native/src/icons/MagnifyingGlass'; +export { XIcon } from 'phosphor-react-native/src/icons/X'; +export { PlusIcon } from 'phosphor-react-native/src/icons/Plus'; +export { CheckIcon } from 'phosphor-react-native/src/icons/Check'; +export { PencilSimpleIcon } from 'phosphor-react-native/src/icons/PencilSimple'; +export { TrashIcon } from 'phosphor-react-native/src/icons/Trash'; +export { DotsThreeVerticalIcon } from 'phosphor-react-native/src/icons/DotsThreeVertical'; +export { CaretDownIcon } from 'phosphor-react-native/src/icons/CaretDown'; +export { ReceiptIcon } from 'phosphor-react-native/src/icons/Receipt'; +export { TrayIcon } from 'phosphor-react-native/src/icons/Tray'; +export { NoteIcon } from 'phosphor-react-native/src/icons/Note'; +export { WarningIcon } from 'phosphor-react-native/src/icons/Warning'; +export { ListIcon } from 'phosphor-react-native/src/icons/List'; +export { DatabaseIcon } from 'phosphor-react-native/src/icons/Database'; diff --git a/example/src/theme/tokens.ts b/example/src/theme/tokens.ts new file mode 100644 index 0000000..01e4c49 --- /dev/null +++ b/example/src/theme/tokens.ts @@ -0,0 +1,72 @@ +/** + * Design tokens ported 1:1 from packages/salve-db-studio/ui/src/styles.css + * (the Studio's Tailwind @theme block) — single source of truth for the + * example app's UI kit. Dark-only, single-accent, matching the Studio. + */ +export const colors = { + canvas: '#0d0d0d', + surface: '#151617', + surface2: '#1c1e1f', + line: '#292b2c', + lineStrong: '#3a3d3e', + ink: '#f4f5f6', + muted: '#9198a1', + accent: '#22d16c', + accentStrong: '#43da82', + accentInk: '#051b0c', + ok: '#22d16c', + danger: '#fb7175', + brandYellow: '#ffdf00', + brandNavy: '#0038a9', +} as const; + +/** Translucent variants of the tokens above — mirrors Tailwind's `/NN` opacity-slash syntax used throughout the Studio (bg-accent/15, border-danger/30, etc). */ +export const alpha = { + accent10: 'rgba(34, 209, 108, 0.1)', + accent15: 'rgba(34, 209, 108, 0.15)', + accent30: 'rgba(34, 209, 108, 0.3)', + danger10: 'rgba(251, 113, 117, 0.1)', + danger30: 'rgba(251, 113, 117, 0.3)', + ok10: 'rgba(34, 209, 108, 0.1)', + ok25: 'rgba(34, 209, 108, 0.25)', + pending10: 'rgba(255, 223, 0, 0.1)', + pending30: 'rgba(255, 223, 0, 0.3)', + white5: 'rgba(255, 255, 255, 0.05)', + white10: 'rgba(255, 255, 255, 0.1)', +} as const; + +/** 4px grid, matching the Studio's Tailwind spacing scale. */ +export const spacing = { + xxs: 2, + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +} as const; + +/** Studio uses rounded-md (6px) for controls, rounded-lg (8px) for cards/panels, rounded-full for pills/dots. */ +export const radius = { + sm: 6, + md: 8, + lg: 10, + pill: 999, +} as const; + +export const typography = { + title: { fontSize: 22, fontWeight: '800' as const, letterSpacing: -0.3 }, + sectionTitle: { fontSize: 16, fontWeight: '700' as const }, + body: { fontSize: 14, fontWeight: '400' as const }, + label: { fontSize: 12, fontWeight: '600' as const }, + caption: { fontSize: 11, fontWeight: '500' as const }, + mono: { fontFamily: 'Menlo', fontSize: 13 }, +}; + +/** House animation durations from the Studio's motion/react usage (0.12–0.2s). */ +export const motion = { + fast: 120, + base: 150, + slow: 200, + pulse: 1400, +} as const; diff --git a/package-lock.json b/package-lock.json index 15ac4e6..33380e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,10 +47,12 @@ "dependencies": { "@react-native/new-app-screen": "0.86.0", "@salve-software/salvetron-react-native": "^1.1.5", + "phosphor-react-native": "^3.0.6", "react": "19.2.8", "react-native": "0.86.0", "react-native-nitro-modules": "0.36.1", - "react-native-safe-area-context": "^5.5.2" + "react-native-safe-area-context": "^5.5.2", + "react-native-svg": "^15.15.5" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -9680,6 +9682,12 @@ "node": ">= 0.8" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -10755,6 +10763,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -10769,6 +10793,18 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -11119,6 +11155,73 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -20198,6 +20301,18 @@ "node": ">=18" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -20905,6 +21020,17 @@ "node": ">= 10.x" } }, + "node_modules/phosphor-react-native": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/phosphor-react-native/-/phosphor-react-native-3.0.6.tgz", + "integrity": "sha512-mIhv+hBbDWz3yLbTOq/esVUkrMoKzEvLIcSeZo3YTdT+sbOBxzxKg5WZN3wpqt3pkkhKf0wVpOyZ3d9mNBNRhg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-svg": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -21801,6 +21927,48 @@ "resolved": "example", "link": true }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/react-native-svg/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/react-native-svg/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-native-url-polyfill": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz", From de56e32e1e6d995f053767251d9a08664f7b1ce2 Mon Sep 17 00:00:00 2001 From: Gabriel-Pereira1788 Date: Thu, 3 Sep 2026 17:59:48 -0300 Subject: [PATCH 2/3] feat(example): redesign screens with new dark UI kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen re-skinned onto the shared token/component kit, plus targeted information-architecture cleanup per screen: - Query (ex-Expenses): title renamed to match its tab; composer collapses behind an "Add expense" button instead of always occupying screen space. - Infinite Query: the 3 seed/debug buttons move into an overflow menu, leaving the list + Load more as the default view. - Benchmark: both comparison bars now use the shared ProgressBar. - Sync Test: raw "PENDING 3 · FAILED 1" strings become StatusBadge pills, tucked behind a "Technical details" disclosure; the standalone explanatory paragraph is removed. - Login: re-skinned onto Card/Input/Button. Shell: the always-visible ResetControls bar is replaced by a hamburger-triggered SideMenu (slide-in panel) holding the same Reset (local) / Reset + Reconfigure / Logout actions, so they no longer occupy permanent screen space above every tab. Note: the Current User tab is intentionally left out of this branch — its screen and backing schema/session API exist in the working tree but were never committed, so they're excluded here to keep this PR scoped to already-committed screens/features. Co-Authored-By: Claude Haiku 4.5 --- example/App.tsx | 126 +++-- example/src/components/ResetControls.tsx | 150 ------ example/src/components/SideMenu.tsx | 180 +++++++ example/src/screens/BenchmarkScreen.tsx | 226 +++----- example/src/screens/ExpensesScreen.tsx | 539 +++++++------------- example/src/screens/InfiniteQueryScreen.tsx | 233 ++++----- example/src/screens/LoginScreen.tsx | 102 +--- example/src/screens/SyncTestScreen.tsx | 337 +++++------- 8 files changed, 766 insertions(+), 1127 deletions(-) delete mode 100644 example/src/components/ResetControls.tsx create mode 100644 example/src/components/SideMenu.tsx diff --git a/example/App.tsx b/example/App.tsx index a26e540..d44ecc5 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { SalveDbProvider } from '@salve-software/react-native-salve-db'; import Salvetron from '@salve-software/salvetron-react-native'; @@ -14,34 +14,28 @@ import { InfiniteQueryScreen } from './src/screens/InfiniteQueryScreen'; import { BenchmarkScreen } from './src/screens/BenchmarkScreen'; import { SyncTestScreen } from './src/screens/SyncTestScreen'; import { LoginScreen, type LoginTokens } from './src/screens/LoginScreen'; -import { ResetControls } from './src/components/ResetControls'; +import { SideMenu } from './src/components/SideMenu'; +import { IconButton, ToastProvider } from './src/components/ui'; +import { colors, spacing } from './src/theme/tokens'; +import { WalletIcon, StackIcon, LightningIcon, ArrowsClockwiseIcon, ListIcon } from './src/theme/icons'; import { SYNC_SERVER_BASE_URL } from './src/library/syncServer'; if (__DEV__) { Salvetron.connect({ host: 'localhost', port: 8765 }); } -const ACCENT = '#5B5FEF'; - const TABS = [ - { key: 'expenses', label: 'Query', icon: '💸' }, - { key: 'infinite', label: 'Infinite Query', icon: '📜' }, - { key: 'benchmark', label: 'Benchmark', icon: '⚡' }, - { key: 'sync', label: 'Sync Test', icon: '🔄' }, + { key: 'expenses', label: 'Query', Icon: WalletIcon }, + { key: 'infinite', label: 'Infinite Query', Icon: StackIcon }, + { key: 'benchmark', label: 'Benchmark', Icon: LightningIcon }, + { key: 'sync', label: 'Sync Test', Icon: ArrowsClockwiseIcon }, ] as const; type TabKey = (typeof TABS)[number]['key']; -const SCHEMAS = [ - ExpenseSchema, - BudgetSchema, - BenchmarkSchema, - FeedItemSchema, - UserSchema, - ProductSchema, -]; +const SCHEMAS = [ExpenseSchema, BudgetSchema, BenchmarkSchema, FeedItemSchema, UserSchema, ProductSchema]; -/** Shared by the initial SalveDbProvider mount and ResetControls' "reconfigure" button — same db name both times, so the connection (and its subscriptions) survives a reset(). */ +/** Shared by the initial SalveDbProvider mount and SideMenu's "reconfigure" button — same db name both times, so the connection (and its subscriptions) survives a reset(). */ function buildDbConfig(tokens: LoginTokens) { return { name: 'salve-db-example', @@ -66,10 +60,14 @@ interface AppTabsProps { function AppTabs({ tokens, onLogout }: AppTabsProps): React.JSX.Element { const [tab, setTab] = useState('expenses'); + const [menuOpen, setMenuOpen] = useState(false); return ( - buildDbConfig(tokens)} onLogout={onLogout} /> + + Salve DB + } onPress={() => setMenuOpen(true)} /> + {tab === 'expenses' ? : null} @@ -79,13 +77,27 @@ function AppTabs({ tokens, onLogout }: AppTabsProps): React.JSX.Element { - {TABS.map(({ key, label, icon }) => ( - setTab(key)} style={styles.tabItem}> - {icon} - {label} - - ))} + {TABS.map(({ key, label, Icon }) => { + const active = tab === key; + return ( + setTab(key)} style={styles.tabItem}> + {active ? : null} + + + {label} + + + ); + })} + + setMenuOpen(false)} + schemas={SCHEMAS} + buildConfig={() => buildDbConfig(tokens)} + onLogout={onLogout} + /> ); } @@ -93,19 +105,18 @@ function AppTabs({ tokens, onLogout }: AppTabsProps): React.JSX.Element { function App(): React.JSX.Element { const [tokens, setTokens] = useState(null); - if (tokens === null) { - return ( - - - - ); - } - return ( - - setTokens(null)} /> - + + + {tokens === null ? ( + + ) : ( + + setTokens(null)} /> + + )} + ); } @@ -115,33 +126,50 @@ export default App; const styles = StyleSheet.create({ flex: { flex: 1, + backgroundColor: colors.canvas, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.surface, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.line, + }, + headerTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.ink, }, tabBar: { flexDirection: 'row', - backgroundColor: '#FFFFFF', + backgroundColor: colors.surface, borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#E1E2F0', + borderTopColor: colors.line, }, tabItem: { flex: 1, alignItems: 'center', - paddingTop: 10, - paddingBottom: 6, - gap: 2, - }, - tabIcon: { - fontSize: 20, - opacity: 0.5, + paddingTop: spacing.sm, + paddingBottom: spacing.xs, + gap: spacing.xxs, }, - tabIconActive: { - opacity: 1, + tabIndicator: { + position: 'absolute', + top: 0, + width: 24, + height: 2, + borderRadius: 1, + backgroundColor: colors.accent, }, tabLabel: { - fontSize: 11, + fontSize: 10, fontWeight: '600', - color: '#9B9DB8', + color: colors.muted, }, tabLabelActive: { - color: ACCENT, + color: colors.accent, }, }); diff --git a/example/src/components/ResetControls.tsx b/example/src/components/ResetControls.tsx deleted file mode 100644 index 94e670b..0000000 --- a/example/src/components/ResetControls.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import type { AnySchema } from '@salve-software/react-native-salve-db'; -import { Database } from '@salve-software/react-native-salve-db'; - -const ACCENT = '#5B5FEF'; -const DANGER = '#E14F62'; - -type ConfigureProps = Parameters[0]; - -interface ResetControlsProps { - schemas: AnySchema[]; - buildConfig: () => ConfigureProps; - /** Called after a successful `Database.logout()`, so the caller can drop its held tokens and show the login screen again. */ - onLogout: () => void; -} - -/** - * Manual QA surface for Database.reset(): "Reset (local)" proves the wipe + - * reactivity without touching sync; "Reset + Reconfigure" additionally calls - * configure() again with the same db name, proving the connection (and every - * useQuery subscription mounted anywhere in the tab tree) survives instead of - * silently going stale. - */ -export function ResetControls({ schemas, buildConfig, onLogout }: ResetControlsProps): React.JSX.Element { - const [busy, setBusy] = useState(false); - const [lastResult, setLastResult] = useState(null); - - async function registerAllSchemas() { - for (const schema of schemas) { - await Database.register({ schema }); - } - } - - async function resetLocal() { - setBusy(true); - setLastResult(null); - try { - await Database.reset(); - await registerAllSchemas(); - setLastResult('Reset (local) ok — data wiped, sync needs configure() again to resume.'); - } catch (err) { - setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); - } finally { - setBusy(false); - } - } - - async function resetAndReconfigure() { - setBusy(true); - setLastResult(null); - try { - await Database.reset(); - Database.configure(buildConfig()); - await registerAllSchemas(); - setLastResult('Reset + Reconfigure ok — same db name, sync restored, no remount.'); - } catch (err) { - setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); - } finally { - setBusy(false); - } - } - - async function logout() { - setBusy(true); - setLastResult(null); - try { - Database.logout(); - onLogout(); - } catch (err) { - setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); - } finally { - setBusy(false); - } - } - - return ( - - - - Reset (local) - - - Reset + Reconfigure - - - Logout - - - {busy ? : null} - {lastResult ? ( - - {lastResult} - - ) : null} - - ); -} - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 16, - paddingTop: 8, - paddingBottom: 10, - backgroundColor: '#FFFFFF', - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E1E2F0', - }, - buttonRow: { - flexDirection: 'row', - gap: 8, - }, - button: { - flex: 1, - height: 34, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#F4F5FA', - }, - buttonDanger: { - backgroundColor: DANGER, - }, - buttonDisabled: { - opacity: 0.5, - }, - buttonText: { - fontSize: 12, - fontWeight: '700', - color: '#1C1D3E', - }, - buttonDangerText: { - fontSize: 12, - fontWeight: '700', - color: '#FFFFFF', - }, - spinner: { - marginTop: 6, - }, - resultText: { - marginTop: 6, - fontSize: 11, - color: '#4A4D7A', - fontWeight: '500', - }, - errorText: { - color: DANGER, - }, -}); diff --git a/example/src/components/SideMenu.tsx b/example/src/components/SideMenu.tsx new file mode 100644 index 0000000..668d1b9 --- /dev/null +++ b/example/src/components/SideMenu.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Animated, Dimensions, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import type { AnySchema } from '@salve-software/react-native-salve-db'; +import { Database } from '@salve-software/react-native-salve-db'; +import { Button, IconButton, Divider } from './ui'; +import { XIcon, SignOutIcon, DatabaseIcon } from '../theme/icons'; +import { colors, spacing, typography } from '../theme/tokens'; + +const PANEL_WIDTH = Math.min(300, Dimensions.get('window').width * 0.82); + +type ConfigureProps = Parameters[0]; + +interface SideMenuProps { + visible: boolean; + onClose: () => void; + schemas: AnySchema[]; + buildConfig: () => ConfigureProps; + /** Called after a successful `Database.logout()`, so the caller can drop its held tokens and show the login screen again. */ + onLogout: () => void; +} + +/** + * Replaces the always-visible ResetControls bar: the same reset/reconfigure/ + * logout QA actions, now tucked behind a hamburger trigger in a slide-in + * panel instead of permanently occupying screen space above every tab. + */ +export function SideMenu({ visible, onClose, schemas, buildConfig, onLogout }: SideMenuProps): React.JSX.Element { + const [busy, setBusy] = useState(false); + const [lastResult, setLastResult] = useState(null); + const translateX = useRef(new Animated.Value(PANEL_WIDTH)).current; + + useEffect(() => { + Animated.timing(translateX, { + toValue: visible ? 0 : PANEL_WIDTH, + duration: 200, + useNativeDriver: true, + }).start(); + }, [visible, translateX]); + + async function registerAllSchemas() { + for (const schema of schemas) { + await Database.register({ schema }); + } + } + + async function resetLocal() { + setBusy(true); + setLastResult(null); + try { + await Database.reset(); + await registerAllSchemas(); + setLastResult('Reset (local) ok — data wiped, sync needs configure() again to resume.'); + } catch (err) { + setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + async function resetAndReconfigure() { + setBusy(true); + setLastResult(null); + try { + await Database.reset(); + Database.configure(buildConfig()); + await registerAllSchemas(); + setLastResult('Reset + Reconfigure ok — same db name, sync restored, no remount.'); + } catch (err) { + setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + async function logout() { + setBusy(true); + setLastResult(null); + try { + Database.logout(); + onClose(); + onLogout(); + } catch (err) { + setLastResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + Menu + } onPress={onClose} /> + + + + + {buildConfig().name} + + + + + +