markupr 2.1.8

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 (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,1830 @@
1
+ /**
2
+ * markupr Onboarding Wizard
3
+ *
4
+ * The first impression that makes users say "wow".
5
+ *
6
+ * Flow:
7
+ * 1. Welcome - Animated logo, tagline, Get Started button
8
+ * 2. Microphone - Permission request with audio level preview
9
+ * 3. Screen Recording - Permission request with system settings link
10
+ * 4. OpenAI API Key - Input, test, success/error feedback
11
+ * 5. Success - Confetti celebration, Start Recording button
12
+ */
13
+
14
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
15
+ import { useTheme } from '../hooks/useTheme';
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ interface OnboardingProps {
22
+ onComplete: () => void;
23
+ onSkip: () => void;
24
+ }
25
+
26
+ type OnboardingStep = 'welcome' | 'microphone' | 'screen' | 'apikey' | 'success';
27
+
28
+ interface PermissionStatus {
29
+ microphone: 'unknown' | 'pending' | 'granted' | 'denied';
30
+ screen: 'unknown' | 'pending' | 'granted' | 'denied';
31
+ }
32
+
33
+ interface ApiKeyStatus {
34
+ value: string;
35
+ testing: boolean;
36
+ valid: boolean | null;
37
+ error: string | null;
38
+ }
39
+
40
+ const API_TEST_TIMEOUT_MS = 15000;
41
+ const API_SAVE_TIMEOUT_MS = 12000;
42
+
43
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
44
+ return new Promise<T>((resolve, reject) => {
45
+ const timeout = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
46
+ promise
47
+ .then((value) => {
48
+ clearTimeout(timeout);
49
+ resolve(value);
50
+ })
51
+ .catch((error) => {
52
+ clearTimeout(timeout);
53
+ reject(error);
54
+ });
55
+ });
56
+ }
57
+
58
+ // ============================================================================
59
+ // Confetti Particle System
60
+ // ============================================================================
61
+
62
+ interface Particle {
63
+ id: number;
64
+ x: number;
65
+ y: number;
66
+ vx: number;
67
+ vy: number;
68
+ rotation: number;
69
+ rotationSpeed: number;
70
+ color: string;
71
+ size: number;
72
+ opacity: number;
73
+ }
74
+
75
+ const getConfettiColors = (colors: ReturnType<typeof import('../hooks/useTheme').useTheme>['colors']) => [
76
+ colors.accent.default,
77
+ colors.status.success,
78
+ colors.status.warning,
79
+ colors.status.error,
80
+ colors.text.link,
81
+ colors.accent.hover,
82
+ colors.status.info,
83
+ colors.status.success,
84
+ ];
85
+
86
+ const createParticle = (id: number, centerX: number, centerY: number): Particle => ({
87
+ id,
88
+ x: centerX,
89
+ y: centerY,
90
+ vx: (Math.random() - 0.5) * 20,
91
+ vy: Math.random() * -15 - 10,
92
+ rotation: Math.random() * 360,
93
+ rotationSpeed: (Math.random() - 0.5) * 20,
94
+ color: '',
95
+ size: Math.random() * 8 + 4,
96
+ opacity: 1,
97
+ });
98
+
99
+ const ConfettiCanvas: React.FC<{ active: boolean }> = ({ active }) => {
100
+ const canvasRef = useRef<HTMLCanvasElement>(null);
101
+ const particlesRef = useRef<Particle[]>([]);
102
+ const animationRef = useRef<number>();
103
+ const { colors } = useTheme();
104
+
105
+ const confettiColors = useMemo(() => getConfettiColors(colors), [colors]);
106
+
107
+ const prefersReducedMotion = typeof window !== 'undefined'
108
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
109
+
110
+ useEffect(() => {
111
+ if (!active || prefersReducedMotion) return;
112
+
113
+ const canvas = canvasRef.current;
114
+ if (!canvas) return;
115
+
116
+ const ctx = canvas.getContext('2d');
117
+ if (!ctx) return;
118
+
119
+ // Set canvas size
120
+ canvas.width = window.innerWidth;
121
+ canvas.height = window.innerHeight;
122
+
123
+ // Create initial burst of particles with theme colors
124
+ const themedCreateParticle = (id: number, cx: number, cy: number): Particle => ({
125
+ ...createParticle(id, cx, cy),
126
+ color: confettiColors[Math.floor(Math.random() * confettiColors.length)],
127
+ });
128
+
129
+ const centerX = canvas.width / 2;
130
+ const centerY = canvas.height / 2;
131
+ particlesRef.current = Array.from({ length: 150 }, (_, i) =>
132
+ themedCreateParticle(i, centerX, centerY)
133
+ );
134
+
135
+ const animate = () => {
136
+ if (!ctx || !canvas) return;
137
+
138
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
139
+
140
+ particlesRef.current = particlesRef.current.filter((p) => {
141
+ // Update physics
142
+ p.vy += 0.5; // gravity
143
+ p.x += p.vx;
144
+ p.y += p.vy;
145
+ p.rotation += p.rotationSpeed;
146
+ p.opacity -= 0.008;
147
+
148
+ // Draw particle
149
+ if (p.opacity > 0) {
150
+ ctx.save();
151
+ ctx.translate(p.x, p.y);
152
+ ctx.rotate((p.rotation * Math.PI) / 180);
153
+ ctx.globalAlpha = p.opacity;
154
+ ctx.fillStyle = p.color;
155
+ ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
156
+ ctx.restore();
157
+ }
158
+
159
+ return p.opacity > 0 && p.y < canvas.height + 50;
160
+ });
161
+
162
+ if (particlesRef.current.length > 0) {
163
+ animationRef.current = requestAnimationFrame(animate);
164
+ }
165
+ };
166
+
167
+ animationRef.current = requestAnimationFrame(animate);
168
+
169
+ return () => {
170
+ if (animationRef.current) {
171
+ cancelAnimationFrame(animationRef.current);
172
+ }
173
+ };
174
+ }, [active, prefersReducedMotion]);
175
+
176
+ if (!active || prefersReducedMotion) return null;
177
+
178
+ return (
179
+ <canvas
180
+ ref={canvasRef}
181
+ style={{
182
+ position: 'fixed',
183
+ inset: 0,
184
+ pointerEvents: 'none',
185
+ zIndex: 100,
186
+ }}
187
+ />
188
+ );
189
+ };
190
+
191
+ // ============================================================================
192
+ // Audio Level Visualizer
193
+ // ============================================================================
194
+
195
+ const AudioLevelMeter: React.FC<{ active: boolean }> = ({ active }) => {
196
+ const [levels, setLevels] = useState<number[]>(Array(20).fill(0));
197
+ const audioContextRef = useRef<AudioContext | null>(null);
198
+ const analyserRef = useRef<AnalyserNode | null>(null);
199
+ const streamRef = useRef<MediaStream | null>(null);
200
+ const animationRef = useRef<number>();
201
+ const { colors } = useTheme();
202
+
203
+ useEffect(() => {
204
+ if (!active) {
205
+ // Cleanup
206
+ if (streamRef.current) {
207
+ streamRef.current.getTracks().forEach((track) => track.stop());
208
+ }
209
+ if (audioContextRef.current) {
210
+ audioContextRef.current.close();
211
+ }
212
+ if (animationRef.current) {
213
+ cancelAnimationFrame(animationRef.current);
214
+ }
215
+ setLevels(Array(20).fill(0));
216
+ return;
217
+ }
218
+
219
+ const startVisualization = async () => {
220
+ try {
221
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
222
+ streamRef.current = stream;
223
+
224
+ const audioContext = new AudioContext();
225
+ audioContextRef.current = audioContext;
226
+
227
+ const analyser = audioContext.createAnalyser();
228
+ analyser.fftSize = 64;
229
+ analyserRef.current = analyser;
230
+
231
+ const source = audioContext.createMediaStreamSource(stream);
232
+ source.connect(analyser);
233
+
234
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
235
+
236
+ const updateLevels = () => {
237
+ if (!analyserRef.current) return;
238
+
239
+ analyserRef.current.getByteFrequencyData(dataArray);
240
+
241
+ // Sample 20 frequency bins
242
+ const newLevels = Array.from({ length: 20 }, (_, i) => {
243
+ const index = Math.floor((i / 20) * dataArray.length);
244
+ return dataArray[index] / 255;
245
+ });
246
+
247
+ setLevels(newLevels);
248
+ animationRef.current = requestAnimationFrame(updateLevels);
249
+ };
250
+
251
+ updateLevels();
252
+ } catch {
253
+ // Permission denied or error - show flat bars
254
+ setLevels(Array(20).fill(0.1));
255
+ }
256
+ };
257
+
258
+ startVisualization();
259
+
260
+ return () => {
261
+ if (streamRef.current) {
262
+ streamRef.current.getTracks().forEach((track) => track.stop());
263
+ }
264
+ if (audioContextRef.current) {
265
+ audioContextRef.current.close();
266
+ }
267
+ if (animationRef.current) {
268
+ cancelAnimationFrame(animationRef.current);
269
+ }
270
+ };
271
+ }, [active]);
272
+
273
+ return (
274
+ <div style={styles.audioMeter}>
275
+ {levels.map((level, i) => (
276
+ <div
277
+ key={i}
278
+ style={{
279
+ ...styles.audioBar,
280
+ height: `${Math.max(4, level * 48)}px`,
281
+ backgroundColor: level > 0.6 ? colors.status.success : level > 0.3 ? colors.accent.default : colors.text.tertiary,
282
+ opacity: 0.5 + level * 0.5,
283
+ }}
284
+ />
285
+ ))}
286
+ </div>
287
+ );
288
+ };
289
+
290
+ // ============================================================================
291
+ // Step Components
292
+ // ============================================================================
293
+
294
+ const WelcomeStep: React.FC<{ onNext: () => void; onSkip: () => void }> = ({
295
+ onNext,
296
+ onSkip,
297
+ }) => {
298
+ const [mounted, setMounted] = useState(false);
299
+ const { colors } = useTheme();
300
+
301
+ useEffect(() => {
302
+ const timer = setTimeout(() => setMounted(true), 50);
303
+ return () => clearTimeout(timer);
304
+ }, []);
305
+
306
+ return (
307
+ <div
308
+ style={{
309
+ ...styles.stepContent,
310
+ opacity: mounted ? 1 : 0,
311
+ transform: mounted ? 'translateY(0)' : 'translateY(20px)',
312
+ transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
313
+ }}
314
+ >
315
+ {/* Animated Logo */}
316
+ <div
317
+ style={{
318
+ ...styles.logoContainer,
319
+ transform: mounted ? 'scale(1)' : 'scale(0.8)',
320
+ transition: 'transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)',
321
+ }}
322
+ >
323
+ <div style={styles.logoGlow} />
324
+ <svg width="80" height="80" viewBox="0 0 80 80" fill="none">
325
+ {/* Recording circle */}
326
+ <circle cx="40" cy="40" r="35" fill="url(#gradient1)" />
327
+ {/* Inner microphone icon */}
328
+ <path
329
+ d="M40 20c-4.4 0-8 3.6-8 8v12c0 4.4 3.6 8 8 8s8-3.6 8-8V28c0-4.4-3.6-8-8-8z"
330
+ fill={colors.text.inverse}
331
+ opacity="0.9"
332
+ />
333
+ <path
334
+ d="M54 36v4c0 7.7-6.3 14-14 14s-14-6.3-14-14v-4h-4v4c0 9.4 7.2 17.2 16 18v6h-6v4h16v-4h-6v-6c8.8-.8 16-8.6 16-18v-4h-4z"
335
+ fill={colors.text.inverse}
336
+ opacity="0.9"
337
+ />
338
+ <defs>
339
+ <linearGradient id="gradient1" x1="5" y1="5" x2="75" y2="75">
340
+ <stop stopColor={colors.accent.default} />
341
+ <stop offset="1" stopColor={colors.text.link} />
342
+ </linearGradient>
343
+ </defs>
344
+ </svg>
345
+ </div>
346
+
347
+ {/* Title */}
348
+ <h1 style={styles.title}>Welcome to markupr</h1>
349
+
350
+ {/* Tagline */}
351
+ <p style={styles.tagline}>
352
+ Capture developer feedback with voice narration and intelligent screenshots.
353
+ <br />
354
+ AI-ready documentation in seconds.
355
+ </p>
356
+
357
+ <div style={styles.quickSteps}>
358
+ <div style={styles.quickStep}>
359
+ <span style={styles.quickStepNumber}>1</span>
360
+ <span style={styles.quickStepText}>Record and narrate your walkthrough.</span>
361
+ </div>
362
+ <div style={styles.quickStep}>
363
+ <span style={styles.quickStepNumber}>2</span>
364
+ <span style={styles.quickStepText}>Mark shots when needed. Markers confirm instantly.</span>
365
+ </div>
366
+ <div style={styles.quickStep}>
367
+ <span style={styles.quickStepNumber}>3</span>
368
+ <span style={styles.quickStepText}>Stop recording. AI aligns transcript + frames into a report.</span>
369
+ </div>
370
+ </div>
371
+
372
+ {/* Get Started Button */}
373
+ <button style={styles.primaryButton} onClick={onNext}>
374
+ Get Started
375
+ <svg
376
+ width="20"
377
+ height="20"
378
+ viewBox="0 0 20 20"
379
+ fill="none"
380
+ style={{ marginLeft: 8 }}
381
+ >
382
+ <path
383
+ d="M7.5 15l5-5-5-5"
384
+ stroke="currentColor"
385
+ strokeWidth="2"
386
+ strokeLinecap="round"
387
+ strokeLinejoin="round"
388
+ />
389
+ </svg>
390
+ </button>
391
+
392
+ {/* Skip Option */}
393
+ <button style={styles.skipButton} onClick={onSkip}>
394
+ Skip setup, configure later
395
+ </button>
396
+ </div>
397
+ );
398
+ };
399
+
400
+ const MicrophoneStep: React.FC<{
401
+ status: PermissionStatus['microphone'];
402
+ onRequestPermission: () => void;
403
+ onNext: () => void;
404
+ onBack: () => void;
405
+ }> = ({ status, onRequestPermission, onNext, onBack }) => {
406
+ const [isRechecking, setIsRechecking] = useState(false);
407
+ const { colors } = useTheme();
408
+
409
+ // Recheck permission after user returns from System Preferences
410
+ const handleRecheck = async () => {
411
+ setIsRechecking(true);
412
+ try {
413
+ onRequestPermission();
414
+ } finally {
415
+ setTimeout(() => setIsRechecking(false), 500);
416
+ }
417
+ };
418
+
419
+ return (
420
+ <div style={styles.stepContent}>
421
+ {/* Illustration */}
422
+ <div style={styles.illustrationContainer}>
423
+ <div
424
+ style={{
425
+ ...styles.iconCircle,
426
+ backgroundColor: status === 'granted' ? colors.status.successSubtle : colors.accent.subtle,
427
+ borderColor: status === 'granted' ? colors.status.success : colors.accent.default,
428
+ }}
429
+ >
430
+ {status === 'granted' ? (
431
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
432
+ <path
433
+ d="M18 24l4 4 8-8"
434
+ stroke={colors.status.success}
435
+ strokeWidth="3"
436
+ strokeLinecap="round"
437
+ strokeLinejoin="round"
438
+ />
439
+ </svg>
440
+ ) : (
441
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
442
+ <path
443
+ d="M24 8c-3.3 0-6 2.7-6 6v9c0 3.3 2.7 6 6 6s6-2.7 6-6v-9c0-3.3-2.7-6-6-6z"
444
+ stroke={colors.accent.default}
445
+ strokeWidth="2.5"
446
+ fill="none"
447
+ />
448
+ <path
449
+ d="M36 20v3c0 6.6-5.4 12-12 12s-12-5.4-12-12v-3"
450
+ stroke={colors.accent.default}
451
+ strokeWidth="2.5"
452
+ strokeLinecap="round"
453
+ />
454
+ <path
455
+ d="M24 35v5M18 40h12"
456
+ stroke={colors.accent.default}
457
+ strokeWidth="2.5"
458
+ strokeLinecap="round"
459
+ />
460
+ </svg>
461
+ )}
462
+ </div>
463
+ </div>
464
+
465
+ {/* Title */}
466
+ <h2 style={styles.stepTitle}>Microphone Access</h2>
467
+
468
+ {/* Explanation */}
469
+ <p style={styles.stepDescription}>
470
+ markupr needs microphone access to transcribe your voice narration as you
471
+ walk through your feedback. Your audio is processed locally and securely.
472
+ </p>
473
+
474
+ {/* Audio Level Preview */}
475
+ {status === 'granted' && (
476
+ <div style={styles.previewBox}>
477
+ <span style={styles.previewLabel}>Speak to test your microphone</span>
478
+ <AudioLevelMeter active={status === 'granted'} />
479
+ </div>
480
+ )}
481
+
482
+ {/* macOS System Preferences Instructions for denied */}
483
+ {status === 'denied' && (
484
+ <div style={styles.instructionBox}>
485
+ <div style={styles.instructionHeader}>
486
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0 }}>
487
+ <path
488
+ d="M10 6v4m0 4h.01M19 10a9 9 0 11-18 0 9 9 0 0118 0z"
489
+ stroke={colors.status.error}
490
+ strokeWidth="1.5"
491
+ strokeLinecap="round"
492
+ />
493
+ </svg>
494
+ <span style={{ ...styles.instructionTitle, color: colors.status.error }}>Permission Denied</span>
495
+ </div>
496
+ <ol style={styles.instructionList}>
497
+ <li>Click &quot;Open System Settings&quot; below</li>
498
+ <li>Find &quot;markupr&quot; in the list</li>
499
+ <li>Toggle the switch ON</li>
500
+ <li>Click &quot;Check Again&quot; to verify</li>
501
+ </ol>
502
+ </div>
503
+ )}
504
+
505
+ {/* Permission Button */}
506
+ {status !== 'granted' && (
507
+ <div style={styles.buttonGroup}>
508
+ <button
509
+ style={{
510
+ ...styles.primaryButton,
511
+ backgroundColor: status === 'denied' ? colors.status.error : colors.accent.default,
512
+ }}
513
+ onClick={onRequestPermission}
514
+ disabled={status === 'pending'}
515
+ >
516
+ {status === 'pending' && (
517
+ <span style={styles.spinner} />
518
+ )}
519
+ {status === 'denied'
520
+ ? 'Open System Settings'
521
+ : 'Allow Microphone Access'}
522
+ </button>
523
+
524
+ {status === 'denied' && (
525
+ <button
526
+ style={styles.secondaryButton}
527
+ onClick={handleRecheck}
528
+ disabled={isRechecking}
529
+ >
530
+ {isRechecking ? (
531
+ <span style={styles.spinner} />
532
+ ) : (
533
+ <>
534
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ marginRight: 6 }}>
535
+ <path
536
+ d="M14 8A6 6 0 1 1 8 2m0 0v3m0-3h3"
537
+ stroke="currentColor"
538
+ strokeWidth="1.5"
539
+ strokeLinecap="round"
540
+ strokeLinejoin="round"
541
+ />
542
+ </svg>
543
+ Check Again
544
+ </>
545
+ )}
546
+ </button>
547
+ )}
548
+ </div>
549
+ )}
550
+
551
+ {/* Continue Button */}
552
+ {status === 'granted' && (
553
+ <button style={styles.primaryButton} onClick={onNext}>
554
+ Continue
555
+ <svg
556
+ width="20"
557
+ height="20"
558
+ viewBox="0 0 20 20"
559
+ fill="none"
560
+ style={{ marginLeft: 8 }}
561
+ >
562
+ <path
563
+ d="M7.5 15l5-5-5-5"
564
+ stroke="currentColor"
565
+ strokeWidth="2"
566
+ strokeLinecap="round"
567
+ strokeLinejoin="round"
568
+ />
569
+ </svg>
570
+ </button>
571
+ )}
572
+
573
+ {/* Back Button */}
574
+ <button style={styles.backButton} onClick={onBack}>
575
+ Back
576
+ </button>
577
+ </div>
578
+ );
579
+ };
580
+
581
+ const ScreenRecordingStep: React.FC<{
582
+ status: PermissionStatus['screen'];
583
+ onRequestPermission: () => void;
584
+ onNext: () => void;
585
+ onBack: () => void;
586
+ }> = ({ status, onRequestPermission, onNext, onBack }) => {
587
+ const [isRechecking, setIsRechecking] = useState(false);
588
+ const { colors } = useTheme();
589
+
590
+ // Recheck permission after user returns from System Preferences
591
+ const handleRecheck = async () => {
592
+ setIsRechecking(true);
593
+ try {
594
+ onRequestPermission();
595
+ } finally {
596
+ setTimeout(() => setIsRechecking(false), 500);
597
+ }
598
+ };
599
+
600
+ return (
601
+ <div style={styles.stepContent}>
602
+ {/* Illustration */}
603
+ <div style={styles.illustrationContainer}>
604
+ <div
605
+ style={{
606
+ ...styles.iconCircle,
607
+ backgroundColor: status === 'granted' ? colors.status.successSubtle : colors.accent.subtle,
608
+ borderColor: status === 'granted' ? colors.status.success : colors.accent.default,
609
+ }}
610
+ >
611
+ {status === 'granted' ? (
612
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
613
+ <path
614
+ d="M18 24l4 4 8-8"
615
+ stroke={colors.status.success}
616
+ strokeWidth="3"
617
+ strokeLinecap="round"
618
+ strokeLinejoin="round"
619
+ />
620
+ </svg>
621
+ ) : (
622
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
623
+ <rect
624
+ x="6"
625
+ y="10"
626
+ width="36"
627
+ height="24"
628
+ rx="3"
629
+ stroke={colors.accent.default}
630
+ strokeWidth="2.5"
631
+ fill="none"
632
+ />
633
+ <path d="M14 38h20" stroke={colors.accent.default} strokeWidth="2.5" strokeLinecap="round" />
634
+ <path d="M24 34v4" stroke={colors.accent.default} strokeWidth="2.5" strokeLinecap="round" />
635
+ <circle cx="24" cy="22" r="4" stroke={colors.accent.default} strokeWidth="2" fill="none" />
636
+ </svg>
637
+ )}
638
+ </div>
639
+ </div>
640
+
641
+ {/* Title */}
642
+ <h2 style={styles.stepTitle}>Screen Recording</h2>
643
+
644
+ {/* Explanation */}
645
+ <p style={styles.stepDescription}>
646
+ markupr captures screenshots when you pause while speaking, automatically
647
+ documenting what you&apos;re looking at. Grant screen recording permission to enable
648
+ this feature.
649
+ </p>
650
+
651
+ {/* macOS System Preferences Instructions */}
652
+ {status === 'denied' && (
653
+ <div style={styles.instructionBox}>
654
+ <div style={styles.instructionHeader}>
655
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0 }}>
656
+ <path
657
+ d="M10 6v4m0 4h.01M19 10a9 9 0 11-18 0 9 9 0 0118 0z"
658
+ stroke={colors.status.warning}
659
+ strokeWidth="1.5"
660
+ strokeLinecap="round"
661
+ />
662
+ </svg>
663
+ <span style={styles.instructionTitle}>Manual Setup Required</span>
664
+ </div>
665
+ <ol style={styles.instructionList}>
666
+ <li>Click &quot;Open System Settings&quot; below</li>
667
+ <li>Find &quot;markupr&quot; in the list</li>
668
+ <li>Toggle the switch ON</li>
669
+ <li>Click &quot;Check Again&quot; to verify</li>
670
+ </ol>
671
+ <p style={styles.instructionNote}>
672
+ Note: You may need to restart markupr after enabling.
673
+ </p>
674
+ </div>
675
+ )}
676
+
677
+ {/* Permission Button */}
678
+ {status !== 'granted' && (
679
+ <div style={styles.buttonGroup}>
680
+ <button
681
+ style={{
682
+ ...styles.primaryButton,
683
+ backgroundColor: status === 'denied' ? colors.status.warning : colors.accent.default,
684
+ }}
685
+ onClick={onRequestPermission}
686
+ disabled={status === 'pending'}
687
+ >
688
+ {status === 'pending' && <span style={styles.spinner} />}
689
+ {status === 'denied'
690
+ ? 'Open System Settings'
691
+ : 'Allow Screen Recording'}
692
+ </button>
693
+
694
+ {status === 'denied' && (
695
+ <button
696
+ style={styles.secondaryButton}
697
+ onClick={handleRecheck}
698
+ disabled={isRechecking}
699
+ >
700
+ {isRechecking ? (
701
+ <span style={styles.spinner} />
702
+ ) : (
703
+ <>
704
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ marginRight: 6 }}>
705
+ <path
706
+ d="M14 8A6 6 0 1 1 8 2m0 0v3m0-3h3"
707
+ stroke="currentColor"
708
+ strokeWidth="1.5"
709
+ strokeLinecap="round"
710
+ strokeLinejoin="round"
711
+ />
712
+ </svg>
713
+ Check Again
714
+ </>
715
+ )}
716
+ </button>
717
+ )}
718
+ </div>
719
+ )}
720
+
721
+ {/* Success Preview */}
722
+ {status === 'granted' && (
723
+ <div style={styles.successBox}>
724
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
725
+ <path
726
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
727
+ stroke={colors.status.success}
728
+ strokeWidth="2"
729
+ strokeLinecap="round"
730
+ />
731
+ </svg>
732
+ <span>Screen recording enabled! markupr can now capture screenshots.</span>
733
+ </div>
734
+ )}
735
+
736
+ {/* Continue Button */}
737
+ {status === 'granted' && (
738
+ <button style={styles.primaryButton} onClick={onNext}>
739
+ Continue
740
+ <svg
741
+ width="20"
742
+ height="20"
743
+ viewBox="0 0 20 20"
744
+ fill="none"
745
+ style={{ marginLeft: 8 }}
746
+ >
747
+ <path
748
+ d="M7.5 15l5-5-5-5"
749
+ stroke="currentColor"
750
+ strokeWidth="2"
751
+ strokeLinecap="round"
752
+ strokeLinejoin="round"
753
+ />
754
+ </svg>
755
+ </button>
756
+ )}
757
+
758
+ {/* Back Button */}
759
+ <button style={styles.backButton} onClick={onBack}>
760
+ Back
761
+ </button>
762
+ </div>
763
+ );
764
+ };
765
+
766
+ const ApiKeyStep: React.FC<{
767
+ apiKey: ApiKeyStatus;
768
+ onApiKeyChange: (value: string) => void;
769
+ onTestApiKey: () => void;
770
+ onNext: () => void;
771
+ onSkip: () => void;
772
+ onBack: () => void;
773
+ }> = ({ apiKey, onApiKeyChange, onTestApiKey, onNext, onSkip, onBack }) => {
774
+ const inputRef = useRef<HTMLInputElement>(null);
775
+ const { colors } = useTheme();
776
+
777
+ useEffect(() => {
778
+ // Focus input on mount
779
+ inputRef.current?.focus();
780
+ }, []);
781
+
782
+ return (
783
+ <div style={styles.stepContent}>
784
+ {/* Illustration */}
785
+ <div style={styles.illustrationContainer}>
786
+ <div
787
+ style={{
788
+ ...styles.iconCircle,
789
+ backgroundColor: apiKey.valid ? colors.status.successSubtle : colors.accent.subtle,
790
+ borderColor: apiKey.valid ? colors.status.success : colors.accent.default,
791
+ }}
792
+ >
793
+ {apiKey.valid ? (
794
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
795
+ <path
796
+ d="M18 24l4 4 8-8"
797
+ stroke={colors.status.success}
798
+ strokeWidth="3"
799
+ strokeLinecap="round"
800
+ strokeLinejoin="round"
801
+ />
802
+ </svg>
803
+ ) : (
804
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
805
+ <path
806
+ d="M32 20l-8-8-8 8M16 28l8 8 8-8"
807
+ stroke={colors.accent.default}
808
+ strokeWidth="2.5"
809
+ strokeLinecap="round"
810
+ strokeLinejoin="round"
811
+ />
812
+ <circle
813
+ cx="24"
814
+ cy="24"
815
+ r="4"
816
+ stroke={colors.accent.default}
817
+ strokeWidth="2.5"
818
+ fill="none"
819
+ />
820
+ </svg>
821
+ )}
822
+ </div>
823
+ </div>
824
+
825
+ {/* Title */}
826
+ <h2 style={styles.stepTitle}>OpenAI API Key</h2>
827
+
828
+ {/* Explanation */}
829
+ <p style={styles.stepDescription}>
830
+ markupr uses OpenAI for post-session narration transcription. Create an API key
831
+ at{' '}
832
+ <a
833
+ href="https://platform.openai.com/api-keys"
834
+ target="_blank"
835
+ rel="noopener noreferrer"
836
+ style={styles.link}
837
+ >
838
+ platform.openai.com
839
+ </a>{' '}
840
+ (or skip and use a local Whisper model later).
841
+ </p>
842
+
843
+ {/* API Key Input */}
844
+ <div style={styles.inputGroup}>
845
+ <input
846
+ ref={inputRef}
847
+ type="password"
848
+ placeholder="Enter your OpenAI API key"
849
+ value={apiKey.value}
850
+ onChange={(e) => onApiKeyChange(e.target.value)}
851
+ style={{
852
+ ...styles.input,
853
+ borderColor: apiKey.error ? colors.status.error : apiKey.valid ? colors.status.success : colors.border.default,
854
+ }}
855
+ onKeyDown={(e) => {
856
+ if (e.key === 'Enter' && apiKey.value.length > 10) {
857
+ onTestApiKey();
858
+ }
859
+ }}
860
+ />
861
+ {apiKey.value && (
862
+ <button
863
+ style={{
864
+ ...styles.testButton,
865
+ backgroundColor: apiKey.testing ? colors.bg.tertiary : colors.accent.default,
866
+ }}
867
+ onClick={onTestApiKey}
868
+ disabled={apiKey.value.length < 10}
869
+ >
870
+ {apiKey.testing ? (
871
+ <span style={styles.spinner} />
872
+ ) : apiKey.valid ? (
873
+ 'Verified!'
874
+ ) : (
875
+ 'Test Key'
876
+ )}
877
+ </button>
878
+ )}
879
+ </div>
880
+
881
+ {/* Error Message */}
882
+ {apiKey.error && (
883
+ <div style={styles.errorBox}>
884
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
885
+ <path
886
+ d="M10 6v4m0 4h.01M19 10a9 9 0 11-18 0 9 9 0 0118 0z"
887
+ stroke={colors.status.error}
888
+ strokeWidth="1.5"
889
+ strokeLinecap="round"
890
+ />
891
+ </svg>
892
+ <span>{apiKey.error}</span>
893
+ </div>
894
+ )}
895
+
896
+ {/* Success Message */}
897
+ {apiKey.valid && (
898
+ <div style={styles.successBox}>
899
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
900
+ <path
901
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
902
+ stroke={colors.status.success}
903
+ strokeWidth="2"
904
+ strokeLinecap="round"
905
+ />
906
+ </svg>
907
+ <span>API key verified! You&apos;re ready to go.</span>
908
+ </div>
909
+ )}
910
+
911
+ {/* Continue Button */}
912
+ <button
913
+ style={{
914
+ ...styles.primaryButton,
915
+ opacity: apiKey.valid ? 1 : 0.5,
916
+ cursor: apiKey.valid ? 'pointer' : 'not-allowed',
917
+ }}
918
+ onClick={onNext}
919
+ disabled={!apiKey.valid}
920
+ >
921
+ Continue
922
+ <svg
923
+ width="20"
924
+ height="20"
925
+ viewBox="0 0 20 20"
926
+ fill="none"
927
+ style={{ marginLeft: 8 }}
928
+ >
929
+ <path
930
+ d="M7.5 15l5-5-5-5"
931
+ stroke="currentColor"
932
+ strokeWidth="2"
933
+ strokeLinecap="round"
934
+ strokeLinejoin="round"
935
+ />
936
+ </svg>
937
+ </button>
938
+
939
+ {/* Skip - use local transcription */}
940
+ <button style={styles.skipButton} onClick={onSkip}>
941
+ Skip — use local Whisper transcription
942
+ </button>
943
+
944
+ {/* Back Button */}
945
+ <button style={styles.backButton} onClick={onBack}>
946
+ Back
947
+ </button>
948
+ </div>
949
+ );
950
+ };
951
+
952
+ const SuccessStep: React.FC<{ onComplete: () => void }> = ({ onComplete }) => {
953
+ const [showConfetti, setShowConfetti] = useState(false);
954
+ const [mounted, setMounted] = useState(false);
955
+ const { colors } = useTheme();
956
+
957
+ useEffect(() => {
958
+ // Trigger animations after mount
959
+ const mountTimer = setTimeout(() => setMounted(true), 50);
960
+ const confettiTimer = setTimeout(() => setShowConfetti(true), 300);
961
+
962
+ return () => {
963
+ clearTimeout(mountTimer);
964
+ clearTimeout(confettiTimer);
965
+ };
966
+ }, []);
967
+
968
+ return (
969
+ <>
970
+ <ConfettiCanvas active={showConfetti} />
971
+
972
+ <div
973
+ style={{
974
+ ...styles.stepContent,
975
+ opacity: mounted ? 1 : 0,
976
+ transform: mounted ? 'translateY(0) scale(1)' : 'translateY(20px) scale(0.95)',
977
+ transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
978
+ }}
979
+ >
980
+ {/* Success Icon */}
981
+ <div style={styles.successIconContainer}>
982
+ <div
983
+ style={{
984
+ ...styles.successIconOuter,
985
+ backgroundColor: colors.status.successSubtle,
986
+ borderColor: colors.status.success,
987
+ transform: mounted ? 'scale(1)' : 'scale(0)',
988
+ transition: 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s',
989
+ }}
990
+ >
991
+ <svg width="64" height="64" viewBox="0 0 64 64" fill="none">
992
+ <path
993
+ d="M20 32l8 8 16-16"
994
+ stroke={colors.status.success}
995
+ strokeWidth="4"
996
+ strokeLinecap="round"
997
+ strokeLinejoin="round"
998
+ style={{
999
+ strokeDasharray: 50,
1000
+ strokeDashoffset: mounted ? 0 : 50,
1001
+ transition: 'stroke-dashoffset 0.6s ease-out 0.5s',
1002
+ }}
1003
+ />
1004
+ </svg>
1005
+ </div>
1006
+ </div>
1007
+
1008
+ {/* Title */}
1009
+ <h2 style={{ ...styles.stepTitle, color: colors.status.success }}>You&apos;re All Set!</h2>
1010
+
1011
+ {/* Summary */}
1012
+ <p style={styles.stepDescription}>
1013
+ markupr is ready to capture your feedback. Press{' '}
1014
+ <kbd style={styles.kbd}>Cmd+Shift+F</kbd> to start recording, and speak
1015
+ naturally as you walk through your feedback. Mark shots as needed, then stop
1016
+ to let AI assemble transcript + frames into a clean report.
1017
+ </p>
1018
+
1019
+ {/* Feature Summary */}
1020
+ <div style={styles.featureSummary}>
1021
+ <div style={styles.featureItem}>
1022
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
1023
+ <path
1024
+ d="M9 12l2 2 4-4"
1025
+ stroke="currentColor"
1026
+ strokeWidth="2"
1027
+ strokeLinecap="round"
1028
+ />
1029
+ </svg>
1030
+ <span>Voice transcription ready</span>
1031
+ </div>
1032
+ <div style={styles.featureItem}>
1033
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
1034
+ <path
1035
+ d="M9 12l2 2 4-4"
1036
+ stroke="currentColor"
1037
+ strokeWidth="2"
1038
+ strokeLinecap="round"
1039
+ />
1040
+ </svg>
1041
+ <span>Manual shot markers confirmed instantly</span>
1042
+ </div>
1043
+ <div style={styles.featureItem}>
1044
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
1045
+ <path
1046
+ d="M9 12l2 2 4-4"
1047
+ stroke="currentColor"
1048
+ strokeWidth="2"
1049
+ strokeLinecap="round"
1050
+ />
1051
+ </svg>
1052
+ <span>AI assembles transcript + frames after stop</span>
1053
+ </div>
1054
+ </div>
1055
+
1056
+ {/* Start Button */}
1057
+ <button
1058
+ style={{
1059
+ ...styles.primaryButton,
1060
+ backgroundColor: colors.status.success,
1061
+ }}
1062
+ onClick={onComplete}
1063
+ >
1064
+ Start Your First Recording
1065
+ <svg
1066
+ width="20"
1067
+ height="20"
1068
+ viewBox="0 0 20 20"
1069
+ fill="none"
1070
+ style={{ marginLeft: 8 }}
1071
+ >
1072
+ <path
1073
+ d="M6 4l10 6-10 6V4z"
1074
+ fill="currentColor"
1075
+ />
1076
+ </svg>
1077
+ </button>
1078
+ </div>
1079
+ </>
1080
+ );
1081
+ };
1082
+
1083
+ // ============================================================================
1084
+ // Progress Dots
1085
+ // ============================================================================
1086
+
1087
+ const STEPS: OnboardingStep[] = ['welcome', 'microphone', 'screen', 'apikey', 'success'];
1088
+
1089
+ const ProgressDots: React.FC<{ currentStep: OnboardingStep }> = ({ currentStep }) => {
1090
+ const currentIndex = STEPS.indexOf(currentStep);
1091
+ const { colors } = useTheme();
1092
+
1093
+ return (
1094
+ <div style={styles.progressContainer} role="navigation" aria-label="Setup progress">
1095
+ {STEPS.filter((s) => s !== 'welcome' && s !== 'success').map((step) => {
1096
+ const stepIndex = STEPS.indexOf(step);
1097
+ const isActive = stepIndex === currentIndex;
1098
+ const isCompleted = stepIndex < currentIndex;
1099
+
1100
+ return (
1101
+ <div
1102
+ key={step}
1103
+ style={{
1104
+ ...styles.progressDot,
1105
+ backgroundColor: isCompleted ? colors.status.success : isActive ? colors.accent.default : colors.bg.tertiary,
1106
+ transform: isActive ? 'scale(1.2)' : 'scale(1)',
1107
+ }}
1108
+ aria-label={`Step ${stepIndex}: ${step}${isCompleted ? ' (completed)' : isActive ? ' (current)' : ''}`}
1109
+ >
1110
+ {isCompleted && (
1111
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
1112
+ <path
1113
+ d="M2 5l2 2 4-4"
1114
+ stroke={colors.text.inverse}
1115
+ strokeWidth="1.5"
1116
+ strokeLinecap="round"
1117
+ />
1118
+ </svg>
1119
+ )}
1120
+ </div>
1121
+ );
1122
+ })}
1123
+ </div>
1124
+ );
1125
+ };
1126
+
1127
+ // ============================================================================
1128
+ // Main Onboarding Component
1129
+ // ============================================================================
1130
+
1131
+ export const Onboarding: React.FC<OnboardingProps> = ({ onComplete, onSkip }) => {
1132
+ const [currentStep, setCurrentStep] = useState<OnboardingStep>('welcome');
1133
+ const [permissions, setPermissions] = useState<PermissionStatus>({
1134
+ microphone: 'unknown',
1135
+ screen: 'unknown',
1136
+ });
1137
+ const [apiKey, setApiKey] = useState<ApiKeyStatus>({
1138
+ value: '',
1139
+ testing: false,
1140
+ valid: null,
1141
+ error: null,
1142
+ });
1143
+ const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('left');
1144
+
1145
+ // Navigate to next step
1146
+ const goToStep = useCallback((step: OnboardingStep, direction: 'left' | 'right' = 'left') => {
1147
+ setSlideDirection(direction);
1148
+ setCurrentStep(step);
1149
+ }, []);
1150
+
1151
+ // Keyboard navigation: Enter/Right = next, Escape/Left = back
1152
+ useEffect(() => {
1153
+ const handleKeyDown = (e: KeyboardEvent) => {
1154
+ // Don't intercept when typing in an input
1155
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
1156
+
1157
+ const stepIndex = STEPS.indexOf(currentStep);
1158
+
1159
+ if (e.key === 'ArrowRight' || e.key === 'Enter') {
1160
+ e.preventDefault();
1161
+ if (currentStep === 'welcome') goToStep('microphone');
1162
+ else if (currentStep === 'microphone' && permissions.microphone === 'granted') goToStep('screen');
1163
+ else if (currentStep === 'screen' && permissions.screen === 'granted') goToStep('apikey');
1164
+ else if (currentStep === 'apikey' && apiKey.valid) goToStep('success');
1165
+ else if (currentStep === 'success') onComplete();
1166
+ } else if (e.key === 'ArrowLeft' || e.key === 'Escape') {
1167
+ e.preventDefault();
1168
+ if (stepIndex > 0 && currentStep !== 'welcome') {
1169
+ goToStep(STEPS[stepIndex - 1], 'right');
1170
+ }
1171
+ }
1172
+ };
1173
+
1174
+ window.addEventListener('keydown', handleKeyDown);
1175
+ return () => window.removeEventListener('keydown', handleKeyDown);
1176
+ }, [currentStep, permissions, apiKey.valid, goToStep, onComplete]);
1177
+
1178
+ // Check initial permission status on mount
1179
+ useEffect(() => {
1180
+ const checkInitialPermissions = async () => {
1181
+ try {
1182
+ const permissionStatus = await window.markupr.permissions.getAll();
1183
+ setPermissions({
1184
+ microphone: permissionStatus.microphone ? 'granted' : 'unknown',
1185
+ screen: permissionStatus.screen ? 'granted' : 'unknown',
1186
+ });
1187
+ } catch {
1188
+ // Permissions API not available, leave as unknown
1189
+ }
1190
+ };
1191
+
1192
+ checkInitialPermissions();
1193
+ }, []);
1194
+
1195
+ // Request microphone permission
1196
+ const requestMicrophonePermission = useCallback(async () => {
1197
+ setPermissions((prev) => ({ ...prev, microphone: 'pending' }));
1198
+
1199
+ try {
1200
+ // First check via main process (macOS system permissions)
1201
+ const isGranted = await window.markupr.permissions.check('microphone');
1202
+ if (isGranted) {
1203
+ setPermissions((prev) => ({ ...prev, microphone: 'granted' }));
1204
+ return;
1205
+ }
1206
+
1207
+ // Request via main process first (triggers macOS prompt)
1208
+ const mainGranted = await window.markupr.permissions.request('microphone');
1209
+
1210
+ if (mainGranted) {
1211
+ // Verify with browser API as well
1212
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1213
+ stream.getTracks().forEach((track) => track.stop());
1214
+ setPermissions((prev) => ({ ...prev, microphone: 'granted' }));
1215
+ } else {
1216
+ setPermissions((prev) => ({ ...prev, microphone: 'denied' }));
1217
+ }
1218
+ } catch {
1219
+ setPermissions((prev) => ({ ...prev, microphone: 'denied' }));
1220
+ }
1221
+ }, []);
1222
+
1223
+ // Request screen recording permission via preload API
1224
+ const requestScreenPermission = useCallback(async () => {
1225
+ setPermissions((prev) => ({ ...prev, screen: 'pending' }));
1226
+
1227
+ try {
1228
+ // First check if already granted
1229
+ const isGranted = await window.markupr.permissions.check('screen');
1230
+ if (isGranted) {
1231
+ setPermissions((prev) => ({ ...prev, screen: 'granted' }));
1232
+ return;
1233
+ }
1234
+
1235
+ // Request permission - this will open System Preferences on macOS
1236
+ const granted = await window.markupr.permissions.request('screen');
1237
+
1238
+ if (granted) {
1239
+ setPermissions((prev) => ({ ...prev, screen: 'granted' }));
1240
+ } else {
1241
+ // Permission was denied or user needs to enable manually
1242
+ setPermissions((prev) => ({ ...prev, screen: 'denied' }));
1243
+ }
1244
+ } catch {
1245
+ setPermissions((prev) => ({ ...prev, screen: 'denied' }));
1246
+ }
1247
+ }, []);
1248
+
1249
+ // Test OpenAI API key
1250
+ const testApiKey = useCallback(async () => {
1251
+ setApiKey((prev) => ({ ...prev, testing: true, error: null }));
1252
+
1253
+ try {
1254
+ const candidateKey = apiKey.value.trim();
1255
+ const validation = await withTimeout(
1256
+ window.markupr.settings.testApiKey('openai', candidateKey),
1257
+ API_TEST_TIMEOUT_MS,
1258
+ 'OpenAI API test timed out. Please try again.'
1259
+ );
1260
+
1261
+ if (validation.valid) {
1262
+ const saved = await withTimeout(
1263
+ window.markupr.settings.setApiKey('openai', candidateKey),
1264
+ API_SAVE_TIMEOUT_MS,
1265
+ 'Saving OpenAI key timed out. Please try again.'
1266
+ );
1267
+ if (!saved) {
1268
+ setApiKey((prev) => ({
1269
+ ...prev,
1270
+ valid: false,
1271
+ error: 'OpenAI key validated, but local save verification failed. Relaunch app and try again.',
1272
+ }));
1273
+ return;
1274
+ }
1275
+
1276
+ setApiKey((prev) => ({ ...prev, valid: true }));
1277
+ } else {
1278
+ setApiKey((prev) => ({
1279
+ ...prev,
1280
+ valid: false,
1281
+ error: validation.error || 'OpenAI API key test failed. Please try again.',
1282
+ }));
1283
+ }
1284
+ } catch (error) {
1285
+ const detail = error instanceof Error ? error.message : 'Unknown error';
1286
+ setApiKey((prev) => ({
1287
+ ...prev,
1288
+ valid: false,
1289
+ error: `Failed to test API key: ${detail}`,
1290
+ }));
1291
+ } finally {
1292
+ setApiKey((prev) => ({ ...prev, testing: false }));
1293
+ }
1294
+ }, [apiKey.value]);
1295
+
1296
+ // Render current step
1297
+ const renderStep = () => {
1298
+ switch (currentStep) {
1299
+ case 'welcome':
1300
+ return <WelcomeStep onNext={() => goToStep('microphone')} onSkip={onSkip} />;
1301
+
1302
+ case 'microphone':
1303
+ return (
1304
+ <MicrophoneStep
1305
+ status={permissions.microphone}
1306
+ onRequestPermission={requestMicrophonePermission}
1307
+ onNext={() => goToStep('screen')}
1308
+ onBack={() => goToStep('welcome', 'right')}
1309
+ />
1310
+ );
1311
+
1312
+ case 'screen':
1313
+ return (
1314
+ <ScreenRecordingStep
1315
+ status={permissions.screen}
1316
+ onRequestPermission={requestScreenPermission}
1317
+ onNext={() => goToStep('apikey')}
1318
+ onBack={() => goToStep('microphone', 'right')}
1319
+ />
1320
+ );
1321
+
1322
+ case 'apikey':
1323
+ return (
1324
+ <ApiKeyStep
1325
+ apiKey={apiKey}
1326
+ onApiKeyChange={(value) =>
1327
+ setApiKey((prev) => ({ ...prev, value, valid: null, error: null }))
1328
+ }
1329
+ onTestApiKey={testApiKey}
1330
+ onNext={() => goToStep('success')}
1331
+ onSkip={() => goToStep('success')}
1332
+ onBack={() => goToStep('screen', 'right')}
1333
+ />
1334
+ );
1335
+
1336
+ case 'success':
1337
+ return <SuccessStep onComplete={onComplete} />;
1338
+
1339
+ default:
1340
+ return null;
1341
+ }
1342
+ };
1343
+
1344
+ return (
1345
+ <div style={styles.overlay} role="dialog" aria-modal="true" aria-label="Setup wizard">
1346
+ <div style={styles.backdrop} />
1347
+
1348
+ <div style={styles.modal}>
1349
+ {/* Progress Dots */}
1350
+ {currentStep !== 'welcome' && currentStep !== 'success' && (
1351
+ <ProgressDots currentStep={currentStep} />
1352
+ )}
1353
+
1354
+ {/* ARIA live region for step announcements */}
1355
+ <div aria-live="polite" aria-atomic="true" style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)' }}>
1356
+ {currentStep === 'welcome' && 'Welcome to markupr setup'}
1357
+ {currentStep === 'microphone' && 'Step 1 of 3: Microphone access'}
1358
+ {currentStep === 'screen' && 'Step 2 of 3: Screen recording'}
1359
+ {currentStep === 'apikey' && 'Step 3 of 3: OpenAI API key'}
1360
+ {currentStep === 'success' && 'Setup complete'}
1361
+ </div>
1362
+
1363
+ {/* Step Content with Animation */}
1364
+ <div
1365
+ key={currentStep}
1366
+ style={{
1367
+ ...styles.stepWrapper,
1368
+ animation: `pageSlideIn${slideDirection === 'left' ? 'Left' : 'Right'} 0.4s ease-out`,
1369
+ }}
1370
+ >
1371
+ {renderStep()}
1372
+ </div>
1373
+ </div>
1374
+
1375
+ {/* pageSlideInLeft, pageSlideInRight, spin, pulse, glowPulse keyframes provided by animations.css */}
1376
+ </div>
1377
+ );
1378
+ };
1379
+
1380
+ // ============================================================================
1381
+ // Styles
1382
+ // ============================================================================
1383
+
1384
+ type ExtendedCSSProperties = React.CSSProperties & {
1385
+ WebkitAppRegion?: 'drag' | 'no-drag';
1386
+ };
1387
+
1388
+ const styles: Record<string, ExtendedCSSProperties> = {
1389
+ overlay: {
1390
+ position: 'fixed',
1391
+ inset: 0,
1392
+ display: 'flex',
1393
+ alignItems: 'center',
1394
+ justifyContent: 'center',
1395
+ zIndex: 50,
1396
+ },
1397
+
1398
+ backdrop: {
1399
+ position: 'absolute',
1400
+ inset: 0,
1401
+ backgroundColor: 'var(--bg-overlay)',
1402
+ backdropFilter: 'blur(8px)',
1403
+ WebkitBackdropFilter: 'blur(8px)',
1404
+ },
1405
+
1406
+ modal: {
1407
+ position: 'relative',
1408
+ width: '100%',
1409
+ maxWidth: 480,
1410
+ margin: 24,
1411
+ backgroundColor: 'var(--bg-elevated)',
1412
+ borderRadius: 24,
1413
+ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--border-subtle)',
1414
+ overflow: 'hidden',
1415
+ WebkitAppRegion: 'no-drag',
1416
+ },
1417
+
1418
+ stepWrapper: {
1419
+ padding: '48px 40px',
1420
+ },
1421
+
1422
+ stepContent: {
1423
+ display: 'flex',
1424
+ flexDirection: 'column',
1425
+ alignItems: 'center',
1426
+ textAlign: 'center',
1427
+ },
1428
+
1429
+ // Logo
1430
+ logoContainer: {
1431
+ position: 'relative',
1432
+ marginBottom: 32,
1433
+ },
1434
+
1435
+ logoGlow: {
1436
+ position: 'absolute',
1437
+ inset: -20,
1438
+ background: 'radial-gradient(circle, var(--accent-muted) 0%, transparent 70%)',
1439
+ animation: 'glowPulse 3s ease-in-out infinite',
1440
+ },
1441
+
1442
+ // Typography
1443
+ title: {
1444
+ fontSize: 28,
1445
+ fontWeight: 700,
1446
+ color: 'var(--text-primary)',
1447
+ marginBottom: 16,
1448
+ letterSpacing: '-0.02em',
1449
+ },
1450
+
1451
+ tagline: {
1452
+ fontSize: 15,
1453
+ lineHeight: 1.6,
1454
+ color: 'var(--text-secondary)',
1455
+ marginBottom: 20,
1456
+ maxWidth: 360,
1457
+ },
1458
+
1459
+ quickSteps: {
1460
+ width: '100%',
1461
+ maxWidth: 360,
1462
+ marginBottom: 28,
1463
+ display: 'flex',
1464
+ flexDirection: 'column',
1465
+ gap: 10,
1466
+ textAlign: 'left',
1467
+ },
1468
+
1469
+ quickStep: {
1470
+ display: 'flex',
1471
+ alignItems: 'center',
1472
+ gap: 10,
1473
+ padding: '8px 10px',
1474
+ border: '1px solid var(--border-subtle)',
1475
+ borderRadius: 10,
1476
+ backgroundColor: 'var(--surface-inset)',
1477
+ },
1478
+
1479
+ quickStepNumber: {
1480
+ width: 20,
1481
+ height: 20,
1482
+ display: 'inline-flex',
1483
+ alignItems: 'center',
1484
+ justifyContent: 'center',
1485
+ borderRadius: '50%',
1486
+ backgroundColor: 'var(--accent-default)',
1487
+ color: 'var(--text-inverse)',
1488
+ fontSize: 12,
1489
+ fontWeight: 700,
1490
+ flexShrink: 0,
1491
+ },
1492
+
1493
+ quickStepText: {
1494
+ fontSize: 13,
1495
+ lineHeight: 1.45,
1496
+ color: 'var(--text-secondary)',
1497
+ },
1498
+
1499
+ stepTitle: {
1500
+ fontSize: 24,
1501
+ fontWeight: 600,
1502
+ color: 'var(--text-primary)',
1503
+ marginBottom: 12,
1504
+ letterSpacing: '-0.01em',
1505
+ },
1506
+
1507
+ stepDescription: {
1508
+ fontSize: 14,
1509
+ lineHeight: 1.6,
1510
+ color: 'var(--text-secondary)',
1511
+ marginBottom: 24,
1512
+ maxWidth: 340,
1513
+ },
1514
+
1515
+ // Buttons
1516
+ primaryButton: {
1517
+ display: 'flex',
1518
+ alignItems: 'center',
1519
+ justifyContent: 'center',
1520
+ width: '100%',
1521
+ maxWidth: 280,
1522
+ padding: '14px 24px',
1523
+ backgroundColor: 'var(--accent-default)',
1524
+ border: 'none',
1525
+ borderRadius: 12,
1526
+ color: 'var(--text-inverse)',
1527
+ fontSize: 15,
1528
+ fontWeight: 600,
1529
+ cursor: 'pointer',
1530
+ transition: 'all 0.2s ease',
1531
+ },
1532
+
1533
+ skipButton: {
1534
+ marginTop: 16,
1535
+ padding: '8px 16px',
1536
+ backgroundColor: 'transparent',
1537
+ border: 'none',
1538
+ color: 'var(--text-tertiary)',
1539
+ fontSize: 13,
1540
+ cursor: 'pointer',
1541
+ transition: 'color 0.2s ease',
1542
+ },
1543
+
1544
+ backButton: {
1545
+ marginTop: 12,
1546
+ padding: '8px 16px',
1547
+ backgroundColor: 'transparent',
1548
+ border: 'none',
1549
+ color: 'var(--text-tertiary)',
1550
+ fontSize: 13,
1551
+ cursor: 'pointer',
1552
+ transition: 'color 0.2s ease',
1553
+ },
1554
+
1555
+ testButton: {
1556
+ display: 'flex',
1557
+ alignItems: 'center',
1558
+ justifyContent: 'center',
1559
+ padding: '10px 16px',
1560
+ backgroundColor: 'var(--accent-default)',
1561
+ border: 'none',
1562
+ borderRadius: 8,
1563
+ color: 'var(--text-inverse)',
1564
+ fontSize: 13,
1565
+ fontWeight: 500,
1566
+ cursor: 'pointer',
1567
+ transition: 'all 0.2s ease',
1568
+ minWidth: 90,
1569
+ },
1570
+
1571
+ // Icons
1572
+ illustrationContainer: {
1573
+ marginBottom: 24,
1574
+ },
1575
+
1576
+ iconCircle: {
1577
+ width: 96,
1578
+ height: 96,
1579
+ borderRadius: '50%',
1580
+ border: '2px solid',
1581
+ display: 'flex',
1582
+ alignItems: 'center',
1583
+ justifyContent: 'center',
1584
+ transition: 'all 0.3s ease',
1585
+ },
1586
+
1587
+ // Audio Meter
1588
+ audioMeter: {
1589
+ display: 'flex',
1590
+ alignItems: 'flex-end',
1591
+ justifyContent: 'center',
1592
+ gap: 3,
1593
+ height: 48,
1594
+ padding: '12px 0',
1595
+ },
1596
+
1597
+ audioBar: {
1598
+ width: 4,
1599
+ borderRadius: 2,
1600
+ transition: 'height 0.05s ease, background-color 0.2s ease',
1601
+ },
1602
+
1603
+ // Inputs
1604
+ inputGroup: {
1605
+ display: 'flex',
1606
+ gap: 8,
1607
+ width: '100%',
1608
+ maxWidth: 360,
1609
+ marginBottom: 16,
1610
+ },
1611
+
1612
+ input: {
1613
+ flex: 1,
1614
+ padding: '12px 16px',
1615
+ backgroundColor: 'var(--surface-inset)',
1616
+ border: '1px solid var(--border-default)',
1617
+ borderRadius: 8,
1618
+ color: 'var(--text-primary)',
1619
+ fontSize: 14,
1620
+ transition: 'border-color 0.2s ease',
1621
+ },
1622
+
1623
+ // Boxes
1624
+ previewBox: {
1625
+ width: '100%',
1626
+ maxWidth: 320,
1627
+ padding: 16,
1628
+ backgroundColor: 'var(--surface-inset)',
1629
+ borderRadius: 12,
1630
+ marginBottom: 24,
1631
+ },
1632
+
1633
+ previewLabel: {
1634
+ display: 'block',
1635
+ fontSize: 12,
1636
+ color: 'var(--text-tertiary)',
1637
+ marginBottom: 12,
1638
+ },
1639
+
1640
+ warningBox: {
1641
+ display: 'flex',
1642
+ alignItems: 'flex-start',
1643
+ gap: 12,
1644
+ width: '100%',
1645
+ maxWidth: 360,
1646
+ padding: 12,
1647
+ backgroundColor: 'var(--status-warning-subtle)',
1648
+ border: '1px solid var(--status-warning)',
1649
+ borderRadius: 8,
1650
+ marginBottom: 16,
1651
+ fontSize: 13,
1652
+ color: 'var(--status-warning)',
1653
+ textAlign: 'left',
1654
+ },
1655
+
1656
+ successBox: {
1657
+ display: 'flex',
1658
+ alignItems: 'center',
1659
+ gap: 12,
1660
+ width: '100%',
1661
+ maxWidth: 360,
1662
+ padding: 12,
1663
+ backgroundColor: 'var(--status-success-subtle)',
1664
+ border: '1px solid var(--status-success)',
1665
+ borderRadius: 8,
1666
+ marginBottom: 24,
1667
+ fontSize: 13,
1668
+ color: 'var(--status-success)',
1669
+ textAlign: 'left',
1670
+ },
1671
+
1672
+ errorBox: {
1673
+ display: 'flex',
1674
+ alignItems: 'center',
1675
+ gap: 8,
1676
+ width: '100%',
1677
+ maxWidth: 360,
1678
+ padding: 12,
1679
+ backgroundColor: 'var(--status-error-subtle)',
1680
+ border: '1px solid var(--status-error)',
1681
+ borderRadius: 8,
1682
+ marginBottom: 16,
1683
+ fontSize: 13,
1684
+ color: 'var(--status-error)',
1685
+ textAlign: 'left',
1686
+ },
1687
+
1688
+ // Instruction box for permission setup
1689
+ instructionBox: {
1690
+ width: '100%',
1691
+ maxWidth: 360,
1692
+ padding: 16,
1693
+ backgroundColor: 'var(--status-warning-subtle)',
1694
+ border: '1px solid var(--status-warning)',
1695
+ borderRadius: 12,
1696
+ marginBottom: 20,
1697
+ textAlign: 'left',
1698
+ },
1699
+
1700
+ instructionHeader: {
1701
+ display: 'flex',
1702
+ alignItems: 'center',
1703
+ gap: 8,
1704
+ marginBottom: 12,
1705
+ },
1706
+
1707
+ instructionTitle: {
1708
+ fontSize: 14,
1709
+ fontWeight: 600,
1710
+ color: 'var(--status-warning)',
1711
+ },
1712
+
1713
+ instructionList: {
1714
+ margin: 0,
1715
+ paddingLeft: 20,
1716
+ fontSize: 13,
1717
+ color: 'var(--text-secondary)',
1718
+ lineHeight: 1.8,
1719
+ },
1720
+
1721
+ instructionNote: {
1722
+ marginTop: 12,
1723
+ fontSize: 12,
1724
+ color: 'var(--text-secondary)',
1725
+ fontStyle: 'italic',
1726
+ },
1727
+
1728
+ // Button group for multiple actions
1729
+ buttonGroup: {
1730
+ display: 'flex',
1731
+ flexDirection: 'column',
1732
+ gap: 10,
1733
+ width: '100%',
1734
+ maxWidth: 280,
1735
+ },
1736
+
1737
+ secondaryButton: {
1738
+ display: 'flex',
1739
+ alignItems: 'center',
1740
+ justifyContent: 'center',
1741
+ width: '100%',
1742
+ padding: '12px 24px',
1743
+ backgroundColor: 'transparent',
1744
+ border: '1px solid var(--border-strong)',
1745
+ borderRadius: 12,
1746
+ color: 'var(--text-secondary)',
1747
+ fontSize: 14,
1748
+ fontWeight: 500,
1749
+ cursor: 'pointer',
1750
+ transition: 'all 0.2s ease',
1751
+ },
1752
+
1753
+ // Success Step
1754
+ successIconContainer: {
1755
+ marginBottom: 24,
1756
+ },
1757
+
1758
+ successIconOuter: {
1759
+ width: 96,
1760
+ height: 96,
1761
+ borderRadius: '50%',
1762
+ backgroundColor: 'var(--status-success-subtle)',
1763
+ border: '2px solid var(--status-success)',
1764
+ display: 'flex',
1765
+ alignItems: 'center',
1766
+ justifyContent: 'center',
1767
+ },
1768
+
1769
+ featureSummary: {
1770
+ display: 'flex',
1771
+ flexDirection: 'column',
1772
+ gap: 8,
1773
+ marginBottom: 32,
1774
+ },
1775
+
1776
+ featureItem: {
1777
+ display: 'flex',
1778
+ alignItems: 'center',
1779
+ gap: 8,
1780
+ fontSize: 14,
1781
+ color: 'var(--status-success)',
1782
+ },
1783
+
1784
+ kbd: {
1785
+ display: 'inline-block',
1786
+ padding: '2px 8px',
1787
+ backgroundColor: 'var(--surface-inset)',
1788
+ borderRadius: 4,
1789
+ fontSize: 12,
1790
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
1791
+ color: 'var(--text-primary)',
1792
+ border: '1px solid var(--border-strong)',
1793
+ },
1794
+
1795
+ link: {
1796
+ color: 'var(--text-link)',
1797
+ textDecoration: 'none',
1798
+ },
1799
+
1800
+ // Progress
1801
+ progressContainer: {
1802
+ display: 'flex',
1803
+ justifyContent: 'center',
1804
+ gap: 8,
1805
+ paddingTop: 24,
1806
+ paddingBottom: 0,
1807
+ },
1808
+
1809
+ progressDot: {
1810
+ width: 10,
1811
+ height: 10,
1812
+ borderRadius: '50%',
1813
+ display: 'flex',
1814
+ alignItems: 'center',
1815
+ justifyContent: 'center',
1816
+ transition: 'all 0.3s ease',
1817
+ },
1818
+
1819
+ // Spinner
1820
+ spinner: {
1821
+ width: 16,
1822
+ height: 16,
1823
+ border: '2px solid var(--border-subtle)',
1824
+ borderTopColor: 'var(--text-inverse)',
1825
+ borderRadius: '50%',
1826
+ animation: 'spin 0.8s linear infinite',
1827
+ },
1828
+ };
1829
+
1830
+ export default Onboarding;