rn-backstage 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +80 -0
  2. package/lib/commonjs/Backstage.js +130 -0
  3. package/lib/commonjs/Backstage.js.map +1 -0
  4. package/lib/commonjs/components/BackstagePanel.js +192 -0
  5. package/lib/commonjs/components/BackstagePanel.js.map +1 -0
  6. package/lib/commonjs/components/FloatingPill.js +147 -0
  7. package/lib/commonjs/components/FloatingPill.js.map +1 -0
  8. package/lib/commonjs/components/InfoTab.js +260 -0
  9. package/lib/commonjs/components/InfoTab.js.map +1 -0
  10. package/lib/commonjs/components/JsonTreeView.js +245 -0
  11. package/lib/commonjs/components/JsonTreeView.js.map +1 -0
  12. package/lib/commonjs/components/LogItem.js +191 -0
  13. package/lib/commonjs/components/LogItem.js.map +1 -0
  14. package/lib/commonjs/components/LogsTab.js +235 -0
  15. package/lib/commonjs/components/LogsTab.js.map +1 -0
  16. package/lib/commonjs/components/TabBar.js +132 -0
  17. package/lib/commonjs/components/TabBar.js.map +1 -0
  18. package/lib/commonjs/constants.js +89 -0
  19. package/lib/commonjs/constants.js.map +1 -0
  20. package/lib/commonjs/index.js +20 -0
  21. package/lib/commonjs/index.js.map +1 -0
  22. package/lib/commonjs/log-interceptor.js +114 -0
  23. package/lib/commonjs/log-interceptor.js.map +1 -0
  24. package/lib/commonjs/package.json +1 -0
  25. package/lib/commonjs/types.js +21 -0
  26. package/lib/commonjs/types.js.map +1 -0
  27. package/lib/commonjs/utils/formatTimestamp.js +26 -0
  28. package/lib/commonjs/utils/formatTimestamp.js.map +1 -0
  29. package/lib/commonjs/utils/stringify.js +78 -0
  30. package/lib/commonjs/utils/stringify.js.map +1 -0
  31. package/lib/module/Backstage.js +125 -0
  32. package/lib/module/Backstage.js.map +1 -0
  33. package/lib/module/components/BackstagePanel.js +187 -0
  34. package/lib/module/components/BackstagePanel.js.map +1 -0
  35. package/lib/module/components/FloatingPill.js +142 -0
  36. package/lib/module/components/FloatingPill.js.map +1 -0
  37. package/lib/module/components/InfoTab.js +255 -0
  38. package/lib/module/components/InfoTab.js.map +1 -0
  39. package/lib/module/components/JsonTreeView.js +241 -0
  40. package/lib/module/components/JsonTreeView.js.map +1 -0
  41. package/lib/module/components/LogItem.js +186 -0
  42. package/lib/module/components/LogItem.js.map +1 -0
  43. package/lib/module/components/LogsTab.js +230 -0
  44. package/lib/module/components/LogsTab.js.map +1 -0
  45. package/lib/module/components/TabBar.js +127 -0
  46. package/lib/module/components/TabBar.js.map +1 -0
  47. package/lib/module/constants.js +85 -0
  48. package/lib/module/constants.js.map +1 -0
  49. package/lib/module/index.js +5 -0
  50. package/lib/module/index.js.map +1 -0
  51. package/lib/module/log-interceptor.js +108 -0
  52. package/lib/module/log-interceptor.js.map +1 -0
  53. package/lib/module/package.json +1 -0
  54. package/lib/module/types.js +25 -0
  55. package/lib/module/types.js.map +1 -0
  56. package/lib/module/utils/formatTimestamp.js +22 -0
  57. package/lib/module/utils/formatTimestamp.js.map +1 -0
  58. package/lib/module/utils/stringify.js +73 -0
  59. package/lib/module/utils/stringify.js.map +1 -0
  60. package/lib/typescript/Backstage.d.ts +4 -0
  61. package/lib/typescript/Backstage.d.ts.map +1 -0
  62. package/lib/typescript/components/BackstagePanel.d.ts +21 -0
  63. package/lib/typescript/components/BackstagePanel.d.ts.map +1 -0
  64. package/lib/typescript/components/FloatingPill.d.ts +13 -0
  65. package/lib/typescript/components/FloatingPill.d.ts.map +1 -0
  66. package/lib/typescript/components/InfoTab.d.ts +16 -0
  67. package/lib/typescript/components/InfoTab.d.ts.map +1 -0
  68. package/lib/typescript/components/JsonTreeView.d.ts +10 -0
  69. package/lib/typescript/components/JsonTreeView.d.ts.map +1 -0
  70. package/lib/typescript/components/LogItem.d.ts +10 -0
  71. package/lib/typescript/components/LogItem.d.ts.map +1 -0
  72. package/lib/typescript/components/LogsTab.d.ts +11 -0
  73. package/lib/typescript/components/LogsTab.d.ts.map +1 -0
  74. package/lib/typescript/components/TabBar.d.ts +14 -0
  75. package/lib/typescript/components/TabBar.d.ts.map +1 -0
  76. package/lib/typescript/constants.d.ts +43 -0
  77. package/lib/typescript/constants.d.ts.map +1 -0
  78. package/lib/typescript/index.d.ts +4 -0
  79. package/lib/typescript/index.d.ts.map +1 -0
  80. package/lib/typescript/log-interceptor.d.ts +16 -0
  81. package/lib/typescript/log-interceptor.d.ts.map +1 -0
  82. package/lib/typescript/types.d.ts +110 -0
  83. package/lib/typescript/types.d.ts.map +1 -0
  84. package/lib/typescript/utils/formatTimestamp.d.ts +7 -0
  85. package/lib/typescript/utils/formatTimestamp.d.ts.map +1 -0
  86. package/lib/typescript/utils/stringify.d.ts +9 -0
  87. package/lib/typescript/utils/stringify.d.ts.map +1 -0
  88. package/package.json +91 -0
  89. package/src/Backstage.tsx +145 -0
  90. package/src/components/BackstagePanel.tsx +208 -0
  91. package/src/components/FloatingPill.tsx +150 -0
  92. package/src/components/InfoTab.tsx +264 -0
  93. package/src/components/JsonTreeView.tsx +259 -0
  94. package/src/components/LogItem.tsx +189 -0
  95. package/src/components/LogsTab.tsx +239 -0
  96. package/src/components/TabBar.tsx +150 -0
  97. package/src/constants.ts +82 -0
  98. package/src/index.ts +14 -0
  99. package/src/log-interceptor.ts +137 -0
  100. package/src/types.ts +147 -0
  101. package/src/utils/formatTimestamp.ts +22 -0
  102. package/src/utils/stringify.ts +90 -0
@@ -0,0 +1,150 @@
1
+ import React, { useCallback, useRef, useEffect } from 'react'
2
+ import {
3
+ Animated,
4
+ LayoutChangeEvent,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from 'react-native'
11
+ import { DarkTheme, MonospaceFont } from '../constants'
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ interface Tab {
16
+ key: string
17
+ title: string
18
+ icon?: string
19
+ }
20
+
21
+ interface TabBarProps {
22
+ tabs: Tab[]
23
+ activeTab: string
24
+ onTabChange: (key: string) => void
25
+ }
26
+
27
+ // ─── Component ───────────────────────────────────────────────────────────────
28
+
29
+ export const TabBar: React.FC<TabBarProps> = ({ tabs, activeTab, onTabChange }) => {
30
+ const indicatorAnim = useRef(new Animated.Value(0)).current
31
+ const tabWidths = useRef<Record<string, number>>({})
32
+ const tabOffsets = useRef<Record<string, number>>({})
33
+ const scrollRef = useRef<ScrollView>(null)
34
+
35
+ // Animate indicator when active tab changes
36
+ useEffect(() => {
37
+ const offset = tabOffsets.current[activeTab] || 0
38
+ Animated.spring(indicatorAnim, {
39
+ toValue: offset,
40
+ useNativeDriver: true,
41
+ friction: 8,
42
+ tension: 60,
43
+ }).start()
44
+ }, [activeTab, indicatorAnim])
45
+
46
+ const handleTabLayout = useCallback(
47
+ (key: string) => (e: LayoutChangeEvent) => {
48
+ const { x, width } = e.nativeEvent.layout
49
+ tabWidths.current[key] = width
50
+ tabOffsets.current[key] = x
51
+
52
+ // Re-animate if this is the active tab
53
+ if (key === activeTab) {
54
+ indicatorAnim.setValue(x)
55
+ }
56
+ },
57
+ [activeTab, indicatorAnim],
58
+ )
59
+
60
+ const activeWidth = tabWidths.current[activeTab] || 80
61
+
62
+ return (
63
+ <View style={styles.container}>
64
+ <ScrollView
65
+ ref={scrollRef}
66
+ horizontal
67
+ showsHorizontalScrollIndicator={false}
68
+ contentContainerStyle={styles.scrollContent}
69
+ >
70
+ {tabs.map(tab => {
71
+ const isActive = tab.key === activeTab
72
+ return (
73
+ <TouchableOpacity
74
+ key={tab.key}
75
+ onPress={() => onTabChange(tab.key)}
76
+ onLayout={handleTabLayout(tab.key)}
77
+ style={styles.tab}
78
+ activeOpacity={0.7}
79
+ testID={`backstage.tab.${tab.key}`}
80
+ >
81
+ {tab.icon && (
82
+ <Text style={[styles.tabIcon, isActive && styles.tabIconActive]}>{tab.icon}</Text>
83
+ )}
84
+ <Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.title}</Text>
85
+ </TouchableOpacity>
86
+ )
87
+ })}
88
+
89
+ {/* Animated underline */}
90
+ <Animated.View
91
+ style={[
92
+ styles.indicator,
93
+ {
94
+ width: activeWidth,
95
+ transform: [{ translateX: indicatorAnim }],
96
+ },
97
+ ]}
98
+ />
99
+ </ScrollView>
100
+ </View>
101
+ )
102
+ }
103
+
104
+ // ─── Styles ──────────────────────────────────────────────────────────────────
105
+
106
+ const styles = StyleSheet.create({
107
+ container: {
108
+ borderBottomWidth: 1,
109
+ borderBottomColor: DarkTheme.border,
110
+ },
111
+ scrollContent: {
112
+ flexDirection: 'row',
113
+ position: 'relative',
114
+ paddingBottom: 0,
115
+ },
116
+ tab: {
117
+ flexDirection: 'row',
118
+ alignItems: 'center',
119
+ justifyContent: 'center',
120
+ paddingHorizontal: 20,
121
+ paddingVertical: 14,
122
+ minWidth: 80,
123
+ },
124
+ tabIcon: {
125
+ fontSize: 14,
126
+ marginRight: 6,
127
+ opacity: 0.5,
128
+ },
129
+ tabIconActive: {
130
+ opacity: 1,
131
+ },
132
+ tabText: {
133
+ fontFamily: MonospaceFont,
134
+ fontSize: 13,
135
+ fontWeight: '600',
136
+ color: DarkTheme.textMuted,
137
+ textTransform: 'uppercase',
138
+ letterSpacing: 1,
139
+ },
140
+ tabTextActive: {
141
+ color: DarkTheme.accent,
142
+ },
143
+ indicator: {
144
+ position: 'absolute',
145
+ bottom: 0,
146
+ height: 2,
147
+ backgroundColor: DarkTheme.accent,
148
+ borderRadius: 1,
149
+ },
150
+ })
@@ -0,0 +1,82 @@
1
+ import { Dimensions, Platform } from 'react-native'
2
+ import type { BackstageTheme } from './types'
3
+
4
+ // ─── Metrics ─────────────────────────────────────────────────────────────────
5
+
6
+ const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
7
+
8
+ export const Metrics = {
9
+ screenWidth,
10
+ screenHeight,
11
+ isIOS: Platform.OS === 'ios',
12
+ pillWidth: 72,
13
+ pillHeight: 32,
14
+ panelBorderRadius: 20,
15
+ tabBarHeight: 48,
16
+ headerHeight: 52,
17
+ sectionSpacing: 16,
18
+ contentPadding: 16,
19
+ }
20
+
21
+ // ─── Dark Theme ──────────────────────────────────────────────────────────────
22
+
23
+ export const DarkTheme: BackstageTheme = {
24
+ background: '#0D0D12',
25
+ surface: '#16161E',
26
+ surfaceElevated: '#1E1E2A',
27
+ border: '#2A2A3A',
28
+ text: '#E8E8ED',
29
+ textSecondary: '#A0A0B0',
30
+ textMuted: '#606075',
31
+ accent: '#7C5CFC',
32
+ accentDim: 'rgba(124, 92, 252, 0.15)',
33
+ error: '#FF4D6A',
34
+ errorDim: 'rgba(255, 77, 106, 0.12)',
35
+ warning: '#FFB224',
36
+ warningDim: 'rgba(255, 178, 36, 0.12)',
37
+ success: '#34D399',
38
+ info: '#38BDF8',
39
+ infoDim: 'rgba(56, 189, 248, 0.12)',
40
+ debugColor: '#A78BFA',
41
+ debugDim: 'rgba(167, 139, 250, 0.12)',
42
+ }
43
+
44
+ // ─── Max Logs ────────────────────────────────────────────────────────────────
45
+
46
+ export const DEFAULT_MAX_LOGS = 500
47
+
48
+ // ─── Monospace Font ──────────────────────────────────────────────────────────
49
+
50
+ export const MonospaceFont = Platform.select({
51
+ ios: 'Menlo',
52
+ default: 'monospace',
53
+ })
54
+
55
+ // ─── Test IDs ────────────────────────────────────────────────────────────────
56
+
57
+ export const TestIDs = {
58
+ floatingPill: 'backstage.floating-pill',
59
+ panel: 'backstage.panel',
60
+ header: {
61
+ container: 'backstage.header',
62
+ title: 'backstage.header.title',
63
+ closeButton: 'backstage.header.close',
64
+ },
65
+ tabBar: 'backstage.tab-bar',
66
+ tabs: {
67
+ info: 'backstage.tab.info',
68
+ logs: 'backstage.tab.logs',
69
+ },
70
+ infoTab: {
71
+ section: 'backstage.info',
72
+ deviceInfo: 'backstage.info.device',
73
+ stateTree: 'backstage.info.state-tree',
74
+ quickActions: 'backstage.info.quick-actions',
75
+ },
76
+ logsTab: {
77
+ section: 'backstage.logs',
78
+ searchInput: 'backstage.logs.search',
79
+ copyButton: 'backstage.logs.copy',
80
+ list: 'backstage.logs.list',
81
+ },
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { Backstage } from './Backstage'
2
+
3
+ export type {
4
+ BackstageProps,
5
+ BackstageRef,
6
+ BackstageTab,
7
+ BackstageStyleOverrides,
8
+ BackstageTheme,
9
+ QuickAction,
10
+ AppInfoItem,
11
+ LogEntry,
12
+ } from './types'
13
+
14
+ export { LogLevel } from './types'
@@ -0,0 +1,137 @@
1
+ import { LogLevel } from './types'
2
+ import type { LogEntry } from './types'
3
+ import { formatLogMessage } from './utils/stringify'
4
+ import { DEFAULT_MAX_LOGS } from './constants'
5
+
6
+ // ─── Original Console Methods ────────────────────────────────────────────────
7
+
8
+ const originalConsole = {
9
+ log: console.log,
10
+ debug: console.debug,
11
+ info: console.info,
12
+ warn: console.warn,
13
+ error: console.error,
14
+ }
15
+
16
+ type LogCallback = (entry: LogEntry) => void
17
+
18
+ let activeCallback: LogCallback | null = null
19
+ let isInstalled = false
20
+
21
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
22
+
23
+ function generateId(): string {
24
+ return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
25
+ }
26
+
27
+ function createLogEntry(level: LogLevel, message: unknown, optionalParams: unknown[]): LogEntry {
28
+ const formattedMessage = formatLogMessage(message, optionalParams)
29
+ const hasObjectData =
30
+ (typeof message === 'object' && message !== null) ||
31
+ optionalParams.some(p => typeof p === 'object' && p !== null)
32
+
33
+ return {
34
+ id: generateId(),
35
+ level,
36
+ message: formattedMessage,
37
+ data: hasObjectData ? { message, params: optionalParams } : undefined,
38
+ timestamp: Date.now(),
39
+ }
40
+ }
41
+
42
+ function shouldLog(message: unknown, filters: string[]): boolean {
43
+ if (filters.length === 0) return true
44
+
45
+ const messageStr =
46
+ typeof message === 'string'
47
+ ? message.toLowerCase()
48
+ : typeof message === 'object' && message !== null
49
+ ? JSON.stringify(message).toLowerCase()
50
+ : String(message).toLowerCase()
51
+
52
+ return !filters.some(filter => messageStr.includes(filter.toLowerCase()))
53
+ }
54
+
55
+ // ─── Install / Uninstall ─────────────────────────────────────────────────────
56
+
57
+ export function installInterceptor(callback: LogCallback, filters: string[] = []): void {
58
+ if (isInstalled) {
59
+ // Just update callback if already installed
60
+ activeCallback = callback
61
+ return
62
+ }
63
+
64
+ activeCallback = callback
65
+ isInstalled = true
66
+
67
+ const levels: Array<[LogLevel, keyof typeof originalConsole]> = [
68
+ [LogLevel.log, 'log'],
69
+ [LogLevel.debug, 'debug'],
70
+ [LogLevel.info, 'info'],
71
+ [LogLevel.warn, 'warn'],
72
+ [LogLevel.error, 'error'],
73
+ ]
74
+
75
+ for (const [level, method] of levels) {
76
+ console[method] = (message?: unknown, ...optionalParams: unknown[]) => {
77
+ // Always forward to original console
78
+ originalConsole[method](message, ...optionalParams)
79
+
80
+ // Check filters
81
+ if (!shouldLog(message, filters)) return
82
+
83
+ // Create log entry and notify
84
+ if (activeCallback) {
85
+ const entry = createLogEntry(level, message, optionalParams)
86
+ activeCallback(entry)
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ export function uninstallInterceptor(): void {
93
+ if (!isInstalled) return
94
+
95
+ console.log = originalConsole.log
96
+ console.debug = originalConsole.debug
97
+ console.info = originalConsole.info
98
+ console.warn = originalConsole.warn
99
+ console.error = originalConsole.error
100
+
101
+ activeCallback = null
102
+ isInstalled = false
103
+ }
104
+
105
+ // ─── Log Buffer ──────────────────────────────────────────────────────────────
106
+
107
+ export class LogBuffer {
108
+ private logs: LogEntry[] = []
109
+ private maxSize: number
110
+
111
+ constructor(maxSize = DEFAULT_MAX_LOGS) {
112
+ this.maxSize = maxSize
113
+ }
114
+
115
+ push(entry: LogEntry): void {
116
+ this.logs.unshift(entry)
117
+ if (this.logs.length > this.maxSize) {
118
+ this.logs = this.logs.slice(0, this.maxSize)
119
+ }
120
+ }
121
+
122
+ getAll(): LogEntry[] {
123
+ return [...this.logs]
124
+ }
125
+
126
+ clear(): void {
127
+ this.logs = []
128
+ }
129
+
130
+ get hasErrors(): boolean {
131
+ return this.logs.some(log => log.level === LogLevel.error)
132
+ }
133
+
134
+ get size(): number {
135
+ return this.logs.length
136
+ }
137
+ }
package/src/types.ts ADDED
@@ -0,0 +1,147 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native'
3
+
4
+ // ─── Log Types ───────────────────────────────────────────────────────────────
5
+
6
+ export enum LogLevel {
7
+ log = 'log',
8
+ debug = 'debug',
9
+ info = 'info',
10
+ warn = 'warn',
11
+ error = 'error',
12
+ }
13
+
14
+ export interface LogEntry {
15
+ id: string
16
+ level: LogLevel
17
+ message: string
18
+ data?: unknown
19
+ timestamp: number
20
+ }
21
+
22
+ // ─── App Info ────────────────────────────────────────────────────────────────
23
+
24
+ export interface AppInfoItem {
25
+ label: string
26
+ value: string
27
+ }
28
+
29
+ // ─── Quick Actions ───────────────────────────────────────────────────────────
30
+
31
+ export interface QuickAction {
32
+ title: string
33
+ onPress: () => void
34
+ closeOnPress?: boolean
35
+ icon?: string
36
+ destructive?: boolean
37
+ testID?: string
38
+ }
39
+
40
+ // ─── Extensible Tabs ─────────────────────────────────────────────────────────
41
+
42
+ export interface BackstageTab {
43
+ key: string
44
+ title: string
45
+ icon?: string
46
+ render: () => ReactNode
47
+ }
48
+
49
+ // ─── Theme ───────────────────────────────────────────────────────────────────
50
+
51
+ export interface BackstageTheme {
52
+ background: string
53
+ surface: string
54
+ surfaceElevated: string
55
+ border: string
56
+ text: string
57
+ textSecondary: string
58
+ textMuted: string
59
+ accent: string
60
+ accentDim: string
61
+ error: string
62
+ errorDim: string
63
+ warning: string
64
+ warningDim: string
65
+ success: string
66
+ info: string
67
+ infoDim: string
68
+ debugColor: string
69
+ debugDim: string
70
+ }
71
+
72
+ // ─── Main Props ──────────────────────────────────────────────────────────────
73
+
74
+ export interface BackstageProps {
75
+ /** Whether the floating pill trigger is visible. Default: true */
76
+ visible?: boolean
77
+
78
+ /** App version string (e.g., "1.2.3") */
79
+ appVersion?: string
80
+
81
+ /** Build number (e.g., "42") */
82
+ buildNumber?: string
83
+
84
+ /** Bundle identifier (e.g., "com.example.app") */
85
+ bundleId?: string
86
+
87
+ /** Additional device/app information rows */
88
+ deviceInfo?: AppInfoItem[]
89
+
90
+ /** State object to display in the tree viewer (Redux store, Zustand, etc.) */
91
+ state?: Record<string, unknown>
92
+
93
+ /** Custom action buttons in the Info tab */
94
+ quickActions?: QuickAction[]
95
+
96
+ /** Maximum number of logs to retain in memory. Default: 500 */
97
+ maxLogs?: number
98
+
99
+ /** Log messages containing these strings will be excluded */
100
+ logFilters?: string[]
101
+
102
+ /** Callback triggered when user copies logs */
103
+ onCopyLogs?: (logs: string) => void
104
+
105
+ /** Additional custom tabs beyond Info and Logs */
106
+ extraTabs?: BackstageTab[]
107
+
108
+ /** Extra content rendered at the bottom of the Info tab */
109
+ children?: ReactNode
110
+
111
+ /** Custom styles overrides */
112
+ styles?: BackstageStyleOverrides
113
+
114
+ /** Initial X position for the floating pill */
115
+ initialX?: number
116
+
117
+ /** Initial Y position for the floating pill */
118
+ initialY?: number
119
+
120
+ /** Text displayed on the floating pill. Defaults to appVersion or "DEV" */
121
+ pillText?: string
122
+ }
123
+
124
+ export interface BackstageStyleOverrides {
125
+ pillStyle?: StyleProp<ViewStyle>
126
+ pillTextStyle?: StyleProp<TextStyle>
127
+ panelStyle?: StyleProp<ViewStyle>
128
+ headerTitleStyle?: StyleProp<TextStyle>
129
+ sectionTitleStyle?: StyleProp<TextStyle>
130
+ infoLabelStyle?: StyleProp<TextStyle>
131
+ infoValueStyle?: StyleProp<TextStyle>
132
+ actionButtonStyle?: StyleProp<ViewStyle>
133
+ actionButtonTitleStyle?: StyleProp<TextStyle>
134
+ logTimestampStyle?: StyleProp<TextStyle>
135
+ logMessageStyle?: StyleProp<TextStyle>
136
+ }
137
+
138
+ // ─── Ref Methods ─────────────────────────────────────────────────────────────
139
+
140
+ export interface BackstageRef {
141
+ /** Open the backstage panel */
142
+ open: () => void
143
+ /** Close the backstage panel */
144
+ close: () => void
145
+ /** Clear all captured logs */
146
+ clearLogs: () => void
147
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Formats a timestamp (Date.now()) into a human-readable local time string.
3
+ * Format: "h:mm:ss.SSS AM/PM"
4
+ * No external dependencies — uses native Date API.
5
+ */
6
+ export function formatTimestamp(timestamp: number): string {
7
+ const date = new Date(timestamp)
8
+ let hours = date.getHours()
9
+ const minutes = date.getMinutes()
10
+ const seconds = date.getSeconds()
11
+ const milliseconds = date.getMilliseconds()
12
+ const ampm = hours >= 12 ? 'PM' : 'AM'
13
+
14
+ hours = hours % 12
15
+ hours = hours || 12
16
+
17
+ const mm = minutes.toString().padStart(2, '0')
18
+ const ss = seconds.toString().padStart(2, '0')
19
+ const ms = milliseconds.toString().padStart(3, '0')
20
+
21
+ return `${hours}:${mm}:${ss}.${ms} ${ampm}`
22
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Safely stringifies a value, handling circular references and Error objects.
3
+ */
4
+ export function safeStringify(value: unknown, indent = 2): string {
5
+ if (value === undefined) return 'undefined'
6
+ if (value === null) return 'null'
7
+
8
+ if (value instanceof Error) {
9
+ return JSON.stringify(
10
+ {
11
+ name: value.name,
12
+ message: value.message,
13
+ stack: value.stack,
14
+ },
15
+ null,
16
+ indent,
17
+ )
18
+ }
19
+
20
+ const seen = new WeakSet()
21
+
22
+ try {
23
+ return JSON.stringify(
24
+ value,
25
+ (_key, val) => {
26
+ if (typeof val === 'object' && val !== null) {
27
+ if (seen.has(val)) {
28
+ return '[Circular]'
29
+ }
30
+ seen.add(val)
31
+ }
32
+ if (typeof val === 'bigint') {
33
+ return val.toString()
34
+ }
35
+ if (typeof val === 'function') {
36
+ return `[Function: ${val.name || 'anonymous'}]`
37
+ }
38
+ if (typeof val === 'symbol') {
39
+ return val.toString()
40
+ }
41
+ return val
42
+ },
43
+ indent,
44
+ )
45
+ } catch {
46
+ return String(value)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Formats a console message and its optional params into a single string.
52
+ */
53
+ export function formatLogMessage(message: unknown, optionalParams?: unknown[]): string {
54
+ const parts: string[] = []
55
+
56
+ if (message instanceof Error) {
57
+ parts.push(`${message.name}: ${message.message}`)
58
+ } else if (typeof message === 'object' && message !== null) {
59
+ parts.push(safeStringify(message, 0))
60
+ } else if (typeof message === 'string') {
61
+ // Strip console color formatting (%c)
62
+ parts.push(message.replace(/%c/g, '').trim())
63
+ } else {
64
+ parts.push(String(message))
65
+ }
66
+
67
+ if (optionalParams && optionalParams.length > 0) {
68
+ for (const param of optionalParams) {
69
+ if (typeof param === 'string' && isColorString(param)) {
70
+ continue // skip css color strings from console.log('%c ...', 'color: ...')
71
+ }
72
+ if (typeof param === 'object' && param !== null) {
73
+ parts.push(safeStringify(param, 0))
74
+ } else {
75
+ parts.push(String(param))
76
+ }
77
+ }
78
+ }
79
+
80
+ return parts.join(' ')
81
+ }
82
+
83
+ function isColorString(str: string): boolean {
84
+ return (
85
+ str.includes('color:') ||
86
+ str.includes('background:') ||
87
+ str.includes('font-') ||
88
+ /^#[0-9A-Fa-f]{3,8}$/.test(str.trim())
89
+ )
90
+ }