create-ekka-desktop-app 0.2.2

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 (96) hide show
  1. package/README.md +137 -0
  2. package/bin/cli.js +72 -0
  3. package/package.json +23 -0
  4. package/template/branding/app.json +6 -0
  5. package/template/branding/icon.icns +0 -0
  6. package/template/eslint.config.js +98 -0
  7. package/template/index.html +29 -0
  8. package/template/package.json +40 -0
  9. package/template/src/app/App.tsx +24 -0
  10. package/template/src/demo/DemoApp.tsx +260 -0
  11. package/template/src/demo/components/Banner.tsx +82 -0
  12. package/template/src/demo/components/EmptyState.tsx +61 -0
  13. package/template/src/demo/components/InfoPopover.tsx +171 -0
  14. package/template/src/demo/components/InfoTooltip.tsx +76 -0
  15. package/template/src/demo/components/LearnMore.tsx +98 -0
  16. package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
  17. package/template/src/demo/components/SetupWizard.tsx +48 -0
  18. package/template/src/demo/components/StatusBadge.tsx +83 -0
  19. package/template/src/demo/components/index.ts +10 -0
  20. package/template/src/demo/hooks/index.ts +6 -0
  21. package/template/src/demo/hooks/useAuditEvents.ts +30 -0
  22. package/template/src/demo/layout/Shell.tsx +110 -0
  23. package/template/src/demo/layout/Sidebar.tsx +192 -0
  24. package/template/src/demo/pages/AuditLogPage.tsx +235 -0
  25. package/template/src/demo/pages/DocGenPage.tsx +874 -0
  26. package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
  27. package/template/src/demo/pages/LoginPage.tsx +192 -0
  28. package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
  29. package/template/src/demo/pages/RunnerPage.tsx +445 -0
  30. package/template/src/demo/pages/SystemPage.tsx +557 -0
  31. package/template/src/demo/pages/VaultPage.tsx +805 -0
  32. package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
  33. package/template/src/ekka/audit/index.ts +7 -0
  34. package/template/src/ekka/audit/store.ts +68 -0
  35. package/template/src/ekka/audit/types.ts +22 -0
  36. package/template/src/ekka/auth/client.ts +212 -0
  37. package/template/src/ekka/auth/index.ts +30 -0
  38. package/template/src/ekka/auth/storage.ts +114 -0
  39. package/template/src/ekka/auth/types.ts +67 -0
  40. package/template/src/ekka/backend/demo.ts +151 -0
  41. package/template/src/ekka/backend/interface.ts +36 -0
  42. package/template/src/ekka/config.ts +48 -0
  43. package/template/src/ekka/constants.ts +143 -0
  44. package/template/src/ekka/errors.ts +54 -0
  45. package/template/src/ekka/index.ts +516 -0
  46. package/template/src/ekka/internal/backend.ts +156 -0
  47. package/template/src/ekka/internal/index.ts +7 -0
  48. package/template/src/ekka/ops/auth.ts +29 -0
  49. package/template/src/ekka/ops/debug.ts +68 -0
  50. package/template/src/ekka/ops/home.ts +101 -0
  51. package/template/src/ekka/ops/index.ts +16 -0
  52. package/template/src/ekka/ops/nodeCredentials.ts +131 -0
  53. package/template/src/ekka/ops/nodeSession.ts +145 -0
  54. package/template/src/ekka/ops/paths.ts +183 -0
  55. package/template/src/ekka/ops/runner.ts +86 -0
  56. package/template/src/ekka/ops/runtime.ts +31 -0
  57. package/template/src/ekka/ops/setup.ts +47 -0
  58. package/template/src/ekka/ops/vault.ts +459 -0
  59. package/template/src/ekka/ops/workflowRuns.ts +116 -0
  60. package/template/src/ekka/types.ts +82 -0
  61. package/template/src/ekka/utils/idempotency.ts +14 -0
  62. package/template/src/ekka/utils/index.ts +7 -0
  63. package/template/src/ekka/utils/time.ts +77 -0
  64. package/template/src/main.tsx +12 -0
  65. package/template/src/vite-env.d.ts +12 -0
  66. package/template/src-tauri/Cargo.toml +41 -0
  67. package/template/src-tauri/build.rs +3 -0
  68. package/template/src-tauri/capabilities/default.json +11 -0
  69. package/template/src-tauri/icons/icon.icns +0 -0
  70. package/template/src-tauri/icons/icon.png +0 -0
  71. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  72. package/template/src-tauri/src/bootstrap.rs +37 -0
  73. package/template/src-tauri/src/commands.rs +1215 -0
  74. package/template/src-tauri/src/device_secret.rs +111 -0
  75. package/template/src-tauri/src/engine_process.rs +538 -0
  76. package/template/src-tauri/src/grants.rs +129 -0
  77. package/template/src-tauri/src/handlers/home.rs +65 -0
  78. package/template/src-tauri/src/handlers/mod.rs +7 -0
  79. package/template/src-tauri/src/handlers/paths.rs +128 -0
  80. package/template/src-tauri/src/handlers/vault.rs +680 -0
  81. package/template/src-tauri/src/main.rs +243 -0
  82. package/template/src-tauri/src/node_auth.rs +858 -0
  83. package/template/src-tauri/src/node_credentials.rs +541 -0
  84. package/template/src-tauri/src/node_runner.rs +882 -0
  85. package/template/src-tauri/src/node_vault_crypto.rs +113 -0
  86. package/template/src-tauri/src/node_vault_store.rs +267 -0
  87. package/template/src-tauri/src/ops/auth.rs +50 -0
  88. package/template/src-tauri/src/ops/home.rs +251 -0
  89. package/template/src-tauri/src/ops/mod.rs +7 -0
  90. package/template/src-tauri/src/ops/runtime.rs +21 -0
  91. package/template/src-tauri/src/state.rs +639 -0
  92. package/template/src-tauri/src/types.rs +84 -0
  93. package/template/src-tauri/tauri.conf.json +41 -0
  94. package/template/tsconfig.json +26 -0
  95. package/template/tsconfig.tsbuildinfo +1 -0
  96. package/template/vite.config.ts +34 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * EKKA Demo App
3
+ *
4
+ * Flow:
5
+ * 1. Check setup status (pre-login) - only node credentials
6
+ * 2. If setup incomplete (no node credentials), show SetupWizard
7
+ * 3. Connect to engine
8
+ * 4. Check auth - if not logged in, show LoginPage
9
+ * 5. After login, check home.status for HOME grant
10
+ * 6. If not HOME_GRANTED, show HomeSetupPage (requests grant from engine)
11
+ * 7. Show main app when HOME_GRANTED
12
+ */
13
+
14
+ import { useState, useEffect, type ReactElement, type CSSProperties } from 'react';
15
+ import { ekka, advanced, EkkaError, addAuditEvent, type HomeStatus, type SetupStatus } from '../ekka';
16
+ import { Shell } from './layout/Shell';
17
+ import { type Page } from './layout/Sidebar';
18
+ import { SystemPage } from './pages/SystemPage';
19
+ import { AuditLogPage } from './pages/AuditLogPage';
20
+ import { PathPermissionsPage } from './pages/PathPermissionsPage';
21
+ import { VaultPage } from './pages/VaultPage';
22
+ import { DocGenPage } from './pages/DocGenPage';
23
+ import { RunnerPage } from './pages/RunnerPage';
24
+ import { LoginPage } from './pages/LoginPage';
25
+ import { HomeSetupPage } from './pages/HomeSetupPage';
26
+ import { SetupWizard } from './components/SetupWizard';
27
+
28
+ type AppState = 'loading' | 'setup' | 'login' | 'home-setup' | 'ready';
29
+
30
+ interface DemoState {
31
+ appState: AppState;
32
+ setupStatus: SetupStatus | null;
33
+ homeStatus: HomeStatus | null;
34
+ connected: boolean;
35
+ error: string | null;
36
+ }
37
+
38
+ export function DemoApp(): ReactElement {
39
+ const [selectedPage, setSelectedPage] = useState<Page>('path-permissions');
40
+ const [darkMode, setDarkMode] = useState<boolean>(() => {
41
+ if (typeof window !== 'undefined') {
42
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
43
+ }
44
+ return false;
45
+ });
46
+ const [state, setState] = useState<DemoState>({
47
+ appState: 'loading',
48
+ setupStatus: null,
49
+ homeStatus: null,
50
+ connected: false,
51
+ error: null,
52
+ });
53
+
54
+ useEffect(() => {
55
+ void initializeApp();
56
+ }, []);
57
+
58
+ async function initializeApp(): Promise<void> {
59
+ try {
60
+ // Step 1: Check setup status BEFORE connect (pre-login)
61
+ const setupStatus = await ekka.setup.status();
62
+ setState((s) => ({ ...s, setupStatus }));
63
+
64
+ // Log setup gate status
65
+ console.log(`[ekka] op=desktop.setup.gate setupComplete=${setupStatus.setupComplete}`);
66
+
67
+ // HARD GATE: If setup is incomplete, show wizard - NO exceptions
68
+ if (!setupStatus.setupComplete) {
69
+ setState((s) => ({ ...s, appState: 'setup' }));
70
+ return;
71
+ }
72
+
73
+ // Step 2: Connect to engine (REQUIRED before login)
74
+ await ekka.connect();
75
+ setState((s) => ({ ...s, connected: true }));
76
+
77
+ console.log('[ekka] op=desktop.connect.success');
78
+
79
+ addAuditEvent({
80
+ type: 'connection.established',
81
+ description: 'Connected to EKKA backend',
82
+ technical: { mode: advanced.internal.mode() },
83
+ });
84
+
85
+ // Step 3: Check auth
86
+ if (!ekka.auth.isLoggedIn()) {
87
+ setState((s) => ({ ...s, appState: 'login' }));
88
+ return;
89
+ }
90
+
91
+ const user = ekka.auth.user();
92
+ if (user) {
93
+ const tenantId = user.company?.id || 'default';
94
+ await advanced.auth.setContext({ tenantId, sub: user.id, jwt: '' });
95
+ }
96
+
97
+ await checkHomeStatus();
98
+ } catch (err: unknown) {
99
+ const message = err instanceof EkkaError ? err.message : 'Connection failed';
100
+
101
+ // Check if setup is incomplete - if so, stay in setup mode
102
+ try {
103
+ const setupStatus = await ekka.setup.status();
104
+ if (!setupStatus.setupComplete) {
105
+ setState((s) => ({ ...s, setupStatus, appState: 'setup', error: message }));
106
+ return;
107
+ }
108
+ } catch {
109
+ // If we can't check setup status, stay in loading with error
110
+ setState((s) => ({ ...s, error: message }));
111
+ return;
112
+ }
113
+
114
+ // Only go to login if setup IS complete but connection failed
115
+ setState((s) => ({ ...s, error: message, appState: 'login' }));
116
+
117
+ addAuditEvent({
118
+ type: 'connection.failed',
119
+ description: 'Failed to connect to EKKA backend',
120
+ technical: { error: message },
121
+ });
122
+ }
123
+ }
124
+
125
+ async function checkHomeStatus(): Promise<void> {
126
+ try {
127
+ const status = await advanced.home.status();
128
+ setState((s) => ({ ...s, homeStatus: status }));
129
+
130
+ if (status.state === 'HOME_GRANTED') {
131
+ setState((s) => ({ ...s, appState: 'ready' }));
132
+ } else if (status.state === 'AUTHENTICATED_NO_HOME_GRANT') {
133
+ setState((s) => ({ ...s, appState: 'home-setup' }));
134
+ } else {
135
+ setState((s) => ({ ...s, appState: 'login' }));
136
+ }
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : 'Failed to check home status';
139
+ setState((s) => ({ ...s, error: message }));
140
+ }
141
+ }
142
+
143
+ async function handleSetupComplete(): Promise<void> {
144
+ // After setup wizard completes:
145
+ // Wizard already verified credentials saved - proceed directly to login
146
+ // (Don't re-verify via setup.status - keychain read-after-write has race condition)
147
+ setState((s) => ({ ...s, appState: 'loading' }));
148
+
149
+ try {
150
+ // Ensure connected (wizard should have called connect, but verify)
151
+ if (!ekka.isConnected()) {
152
+ await ekka.connect();
153
+ console.log('[ekka] op=desktop.connect.success (post-setup)');
154
+ }
155
+ setState((s) => ({ ...s, connected: true }));
156
+
157
+ // Now proceed to login
158
+ if (!ekka.auth.isLoggedIn()) {
159
+ setState((s) => ({ ...s, appState: 'login' }));
160
+ return;
161
+ }
162
+
163
+ // Already logged in - check home status
164
+ await checkHomeStatus();
165
+ } catch (err) {
166
+ const message = err instanceof Error ? err.message : 'Setup verification failed';
167
+ setState((s) => ({ ...s, error: message, appState: 'setup' }));
168
+ }
169
+ }
170
+
171
+ async function handleLoginSuccess(): Promise<void> {
172
+ // Belt + suspenders: ensure connected before any engine ops
173
+ if (!state.connected) {
174
+ try {
175
+ await ekka.connect();
176
+ setState((s) => ({ ...s, connected: true }));
177
+ console.log('[ekka] op=desktop.connect.success (post-login)');
178
+ } catch (err) {
179
+ const message = err instanceof Error ? err.message : 'Failed to connect';
180
+ setState((s) => ({ ...s, error: message }));
181
+ return;
182
+ }
183
+ }
184
+ await checkHomeStatus();
185
+ }
186
+
187
+ function handleHomeGranted(): void {
188
+ setState((s) => ({ ...s, appState: 'ready' }));
189
+ }
190
+
191
+ // Loading
192
+ if (state.appState === 'loading') {
193
+ const style: CSSProperties = {
194
+ display: 'flex',
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ minHeight: '100vh',
198
+ background: darkMode ? '#1c1c1e' : '#ffffff',
199
+ fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif',
200
+ color: darkMode ? '#98989d' : '#86868b',
201
+ fontSize: '14px',
202
+ };
203
+ return <div style={style}>Connecting...</div>;
204
+ }
205
+
206
+ // Setup wizard (pre-login)
207
+ if (state.appState === 'setup' && state.setupStatus) {
208
+ return (
209
+ <SetupWizard
210
+ initialStatus={state.setupStatus}
211
+ onComplete={handleSetupComplete}
212
+ darkMode={darkMode}
213
+ />
214
+ );
215
+ }
216
+
217
+ // Login
218
+ if (state.appState === 'login') {
219
+ return <LoginPage onLoginSuccess={handleLoginSuccess} darkMode={darkMode} />;
220
+ }
221
+
222
+ // Home setup
223
+ if (state.appState === 'home-setup' && state.homeStatus) {
224
+ return (
225
+ <HomeSetupPage
226
+ homeStatus={state.homeStatus}
227
+ onGranted={handleHomeGranted}
228
+ darkMode={darkMode}
229
+ />
230
+ );
231
+ }
232
+
233
+ // Main app
234
+ const errorStyle: CSSProperties = {
235
+ marginBottom: '20px',
236
+ padding: '12px 14px',
237
+ background: darkMode ? '#3c1618' : '#fef2f2',
238
+ border: `1px solid ${darkMode ? '#7f1d1d' : '#fecaca'}`,
239
+ borderRadius: '6px',
240
+ fontSize: '13px',
241
+ color: darkMode ? '#fca5a5' : '#991b1b',
242
+ };
243
+
244
+ return (
245
+ <Shell
246
+ selectedPage={selectedPage}
247
+ onNavigate={setSelectedPage}
248
+ darkMode={darkMode}
249
+ onToggleDarkMode={() => setDarkMode((prev) => !prev)}
250
+ >
251
+ {state.error && <div style={errorStyle}>{state.error}</div>}
252
+ {selectedPage === 'path-permissions' && <PathPermissionsPage darkMode={darkMode} />}
253
+ {selectedPage === 'vault' && <VaultPage darkMode={darkMode} />}
254
+ {selectedPage === 'doc-gen' && <DocGenPage darkMode={darkMode} />}
255
+ {selectedPage === 'runner' && <RunnerPage darkMode={darkMode} />}
256
+ {selectedPage === 'audit-log' && <AuditLogPage darkMode={darkMode} />}
257
+ {selectedPage === 'system' && <SystemPage darkMode={darkMode} />}
258
+ </Shell>
259
+ );
260
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Banner Component
3
+ * Informational banner for warnings, info, and errors.
4
+ */
5
+
6
+ import { type CSSProperties, type ReactElement } from 'react';
7
+
8
+ type BannerType = 'warning' | 'info' | 'error';
9
+
10
+ interface BannerProps {
11
+ type: BannerType;
12
+ message: string;
13
+ darkMode?: boolean;
14
+ }
15
+
16
+ export function Banner({ type, message, darkMode = false }: BannerProps): ReactElement {
17
+ const colorMap: Record<BannerType, { bg: string; border: string; text: string; icon: string }> = {
18
+ warning: {
19
+ bg: darkMode ? '#422006' : '#fffbeb',
20
+ border: darkMode ? '#854d0e' : '#fcd34d',
21
+ text: darkMode ? '#fcd34d' : '#92400e',
22
+ icon: darkMode ? '#fbbf24' : '#f59e0b',
23
+ },
24
+ info: {
25
+ bg: darkMode ? '#1e3a5f' : '#eff6ff',
26
+ border: darkMode ? '#1d4ed8' : '#93c5fd',
27
+ text: darkMode ? '#93c5fd' : '#1e40af',
28
+ icon: darkMode ? '#60a5fa' : '#3b82f6',
29
+ },
30
+ error: {
31
+ bg: darkMode ? '#3c1618' : '#fef2f2',
32
+ border: darkMode ? '#7f1d1d' : '#fecaca',
33
+ text: darkMode ? '#fca5a5' : '#991b1b',
34
+ icon: darkMode ? '#f87171' : '#ef4444',
35
+ },
36
+ };
37
+
38
+ const colors = colorMap[type];
39
+
40
+ const styles: Record<string, CSSProperties> = {
41
+ banner: {
42
+ display: 'flex',
43
+ alignItems: 'flex-start',
44
+ gap: '12px',
45
+ padding: '12px 14px',
46
+ background: colors.bg,
47
+ border: `1px solid ${colors.border}`,
48
+ borderRadius: '6px',
49
+ fontSize: '13px',
50
+ lineHeight: 1.5,
51
+ color: colors.text,
52
+ },
53
+ icon: {
54
+ width: '16px',
55
+ height: '16px',
56
+ flexShrink: 0,
57
+ marginTop: '1px',
58
+ color: colors.icon,
59
+ },
60
+ message: {
61
+ flex: 1,
62
+ margin: 0,
63
+ },
64
+ };
65
+
66
+ const iconPaths: Record<BannerType, string> = {
67
+ warning:
68
+ 'M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z',
69
+ info: 'M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z',
70
+ error:
71
+ 'M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z',
72
+ };
73
+
74
+ return (
75
+ <div style={styles.banner}>
76
+ <svg style={styles.icon} viewBox="0 0 16 16" fill="currentColor">
77
+ <path d={iconPaths[type]} />
78
+ </svg>
79
+ <p style={styles.message}>{message}</p>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Empty State Component
3
+ * Placeholder UI for empty lists or sections.
4
+ */
5
+
6
+ import { type CSSProperties, type ReactElement, type ReactNode } from 'react';
7
+
8
+ interface EmptyStateProps {
9
+ icon: ReactNode;
10
+ message: string;
11
+ hint?: string;
12
+ darkMode?: boolean;
13
+ }
14
+
15
+ export function EmptyState({
16
+ icon,
17
+ message,
18
+ hint,
19
+ darkMode = false,
20
+ }: EmptyStateProps): ReactElement {
21
+ const colors = {
22
+ text: darkMode ? '#ffffff' : '#1d1d1f',
23
+ textMuted: darkMode ? '#98989d' : '#6e6e73',
24
+ iconColor: darkMode ? '#48484a' : '#d2d2d7',
25
+ };
26
+
27
+ const styles: Record<string, CSSProperties> = {
28
+ container: {
29
+ display: 'flex',
30
+ flexDirection: 'column',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ padding: '48px 24px',
34
+ textAlign: 'center',
35
+ },
36
+ iconWrapper: {
37
+ marginBottom: '16px',
38
+ color: colors.iconColor,
39
+ },
40
+ message: {
41
+ fontSize: '14px',
42
+ fontWeight: 500,
43
+ color: colors.text,
44
+ marginBottom: hint ? '8px' : '0',
45
+ },
46
+ hint: {
47
+ fontSize: '13px',
48
+ color: colors.textMuted,
49
+ maxWidth: '280px',
50
+ lineHeight: 1.5,
51
+ },
52
+ };
53
+
54
+ return (
55
+ <div style={styles.container}>
56
+ <div style={styles.iconWrapper}>{icon}</div>
57
+ <p style={styles.message}>{message}</p>
58
+ {hint && <p style={styles.hint}>{hint}</p>}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Info Popover Component
3
+ * Shows an info icon that displays detailed info on click.
4
+ * Uses position: fixed to avoid being clipped by parent overflow.
5
+ */
6
+
7
+ import { useState, useRef, useEffect, type CSSProperties, type ReactElement, type ReactNode } from 'react';
8
+
9
+ interface InfoItem {
10
+ label: string;
11
+ value: string | ReactNode;
12
+ mono?: boolean;
13
+ }
14
+
15
+ interface InfoPopoverProps {
16
+ items: InfoItem[];
17
+ darkMode?: boolean;
18
+ }
19
+
20
+ export function InfoPopover({ items, darkMode = false }: InfoPopoverProps): ReactElement {
21
+ const [isOpen, setIsOpen] = useState(false);
22
+ const [position, setPosition] = useState({ top: 0, right: 0 });
23
+ const buttonRef = useRef<HTMLButtonElement>(null);
24
+
25
+ const colors = {
26
+ icon: darkMode ? '#98989d' : '#86868b',
27
+ iconHover: darkMode ? '#0a84ff' : '#007aff',
28
+ bg: darkMode ? '#2c2c2e' : '#ffffff',
29
+ border: darkMode ? '#3a3a3c' : '#e5e5e5',
30
+ text: darkMode ? '#ffffff' : '#1d1d1f',
31
+ textMuted: darkMode ? '#98989d' : '#86868b',
32
+ };
33
+
34
+ const styles: Record<string, CSSProperties> = {
35
+ container: {
36
+ position: 'relative',
37
+ display: 'inline-flex',
38
+ alignItems: 'center',
39
+ },
40
+ button: {
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ width: '24px',
45
+ height: '24px',
46
+ padding: 0,
47
+ background: 'transparent',
48
+ border: 'none',
49
+ borderRadius: '4px',
50
+ cursor: 'pointer',
51
+ color: isOpen ? colors.iconHover : colors.icon,
52
+ transition: 'color 0.15s ease',
53
+ },
54
+ popover: {
55
+ position: 'fixed',
56
+ top: position.top,
57
+ right: position.right,
58
+ padding: '12px',
59
+ background: colors.bg,
60
+ border: `1px solid ${colors.border}`,
61
+ borderRadius: '8px',
62
+ boxShadow: darkMode
63
+ ? '0 4px 16px rgba(0, 0, 0, 0.4)'
64
+ : '0 4px 16px rgba(0, 0, 0, 0.12)',
65
+ zIndex: 9999,
66
+ minWidth: '260px',
67
+ maxWidth: '360px',
68
+ maxHeight: '400px',
69
+ overflowY: 'auto',
70
+ },
71
+ row: {
72
+ display: 'flex',
73
+ flexDirection: 'column',
74
+ gap: '2px',
75
+ padding: '6px 0',
76
+ borderBottom: `1px solid ${colors.border}`,
77
+ },
78
+ rowLast: {
79
+ borderBottom: 'none',
80
+ },
81
+ label: {
82
+ fontSize: '10px',
83
+ fontWeight: 600,
84
+ color: colors.textMuted,
85
+ textTransform: 'uppercase',
86
+ letterSpacing: '0.04em',
87
+ },
88
+ value: {
89
+ fontSize: '12px',
90
+ color: colors.text,
91
+ wordBreak: 'break-all',
92
+ },
93
+ valueMono: {
94
+ fontFamily: 'SF Mono, Monaco, Consolas, monospace',
95
+ },
96
+ };
97
+
98
+ // Update position when opening
99
+ useEffect(() => {
100
+ if (isOpen && buttonRef.current) {
101
+ const rect = buttonRef.current.getBoundingClientRect();
102
+ setPosition({
103
+ top: rect.bottom + 8,
104
+ right: window.innerWidth - rect.right,
105
+ });
106
+ }
107
+ }, [isOpen]);
108
+
109
+ // Close on click outside or scroll
110
+ useEffect(() => {
111
+ if (!isOpen) return;
112
+
113
+ const handleClickOutside = (e: MouseEvent) => {
114
+ if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) {
115
+ setIsOpen(false);
116
+ }
117
+ };
118
+
119
+ const handleScroll = () => setIsOpen(false);
120
+
121
+ document.addEventListener('click', handleClickOutside);
122
+ document.addEventListener('scroll', handleScroll, true);
123
+
124
+ return () => {
125
+ document.removeEventListener('click', handleClickOutside);
126
+ document.removeEventListener('scroll', handleScroll, true);
127
+ };
128
+ }, [isOpen]);
129
+
130
+ return (
131
+ <div style={styles.container}>
132
+ <button
133
+ ref={buttonRef}
134
+ style={styles.button}
135
+ onClick={(e) => {
136
+ e.stopPropagation();
137
+ setIsOpen(!isOpen);
138
+ }}
139
+ title="View details"
140
+ >
141
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
142
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
143
+ <path
144
+ d="M8 7v4M8 5.5v.01"
145
+ stroke="currentColor"
146
+ strokeWidth="1.5"
147
+ strokeLinecap="round"
148
+ />
149
+ </svg>
150
+ </button>
151
+ {isOpen && (
152
+ <div style={styles.popover} onClick={(e) => e.stopPropagation()}>
153
+ {items.map((item, idx) => (
154
+ <div
155
+ key={item.label}
156
+ style={{
157
+ ...styles.row,
158
+ ...(idx === items.length - 1 ? styles.rowLast : {}),
159
+ }}
160
+ >
161
+ <span style={styles.label}>{item.label}</span>
162
+ <span style={{ ...styles.value, ...(item.mono ? styles.valueMono : {}) }}>
163
+ {item.value}
164
+ </span>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Info Tooltip Component
3
+ * Shows an info icon that displays tooltip text on hover.
4
+ */
5
+
6
+ import { useState, type CSSProperties, type ReactElement } from 'react';
7
+
8
+ interface InfoTooltipProps {
9
+ text: string;
10
+ darkMode?: boolean;
11
+ }
12
+
13
+ export function InfoTooltip({ text, darkMode = false }: InfoTooltipProps): ReactElement {
14
+ const [isVisible, setIsVisible] = useState(false);
15
+
16
+ const colors = {
17
+ icon: darkMode ? '#98989d' : '#86868b',
18
+ iconHover: darkMode ? '#ffffff' : '#1d1d1f',
19
+ tooltipBg: darkMode ? '#3a3a3c' : '#1d1d1f',
20
+ tooltipText: '#ffffff',
21
+ };
22
+
23
+ const styles: Record<string, CSSProperties> = {
24
+ container: {
25
+ position: 'relative',
26
+ display: 'inline-flex',
27
+ alignItems: 'center',
28
+ },
29
+ icon: {
30
+ width: '14px',
31
+ height: '14px',
32
+ cursor: 'help',
33
+ color: isVisible ? colors.iconHover : colors.icon,
34
+ transition: 'color 0.15s ease',
35
+ },
36
+ tooltip: {
37
+ position: 'absolute',
38
+ bottom: '100%',
39
+ left: '50%',
40
+ transform: 'translateX(-50%)',
41
+ marginBottom: '8px',
42
+ padding: '8px 12px',
43
+ background: colors.tooltipBg,
44
+ color: colors.tooltipText,
45
+ fontSize: '12px',
46
+ lineHeight: 1.4,
47
+ borderRadius: '6px',
48
+ whiteSpace: 'nowrap',
49
+ maxWidth: '280px',
50
+ zIndex: 1000,
51
+ opacity: isVisible ? 1 : 0,
52
+ visibility: isVisible ? 'visible' : 'hidden',
53
+ transition: 'opacity 0.15s ease, visibility 0.15s ease',
54
+ pointerEvents: 'none',
55
+ },
56
+ };
57
+
58
+ return (
59
+ <span
60
+ style={styles.container}
61
+ onMouseEnter={() => setIsVisible(true)}
62
+ onMouseLeave={() => setIsVisible(false)}
63
+ >
64
+ <svg style={styles.icon} viewBox="0 0 16 16" fill="none">
65
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
66
+ <path
67
+ d="M8 7v4M8 5.5v.01"
68
+ stroke="currentColor"
69
+ strokeWidth="1.5"
70
+ strokeLinecap="round"
71
+ />
72
+ </svg>
73
+ <span style={styles.tooltip}>{text}</span>
74
+ </span>
75
+ );
76
+ }