From 964d3325cdc98b99d3963390f71c0ef561fc4231 Mon Sep 17 00:00:00 2001 From: Edward Date: Wed, 11 Feb 2026 07:38:45 +0100 Subject: [PATCH 1/7] feat: add custom storybook for design system Adds a lightweight custom storybook showcasing Handy's UI components, icons, shared components, and design tokens (colors + typography). Supports light and dark mode. Run with `bun run storybook`. Co-Authored-By: Claude Sonnet 4.5 --- package.json | 3 +- src/components/ui/IconButton.tsx | 57 ++ storybook/.gitignore | 1 + storybook/index.html | 42 + storybook/main.tsx | 11 + storybook/mocks/bindings.ts | 85 ++ storybook/mocks/data.ts | 247 ++++++ storybook/mocks/modelStore.ts | 42 + storybook/mocks/settingsStore.ts | 45 ++ storybook/mocks/tauri-apps-api-app.ts | 1 + storybook/mocks/tauri-apps-api-core.ts | 1 + storybook/mocks/tauri-apps-api-event.ts | 3 + storybook/mocks/tauri-plugin-dialog.ts | 1 + storybook/mocks/tauri-plugin-fs.ts | 1 + .../tauri-plugin-macos-permissions-api.ts | 4 + storybook/mocks/tauri-plugin-opener.ts | 1 + storybook/mocks/tauri-plugin-os.ts | 3 + storybook/mocks/tauri-plugin-process.ts | 1 + storybook/mocks/tauri-plugin-updater.ts | 1 + storybook/mocks/useSettings.ts | 115 +++ storybook/storybook.css | 248 ++++++ storybook/storybook.tsx | 750 ++++++++++++++++++ vite.storybook.config.ts | 27 + 23 files changed, 1689 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/IconButton.tsx create mode 100644 storybook/.gitignore create mode 100644 storybook/index.html create mode 100644 storybook/main.tsx create mode 100644 storybook/mocks/bindings.ts create mode 100644 storybook/mocks/data.ts create mode 100644 storybook/mocks/modelStore.ts create mode 100644 storybook/mocks/settingsStore.ts create mode 100644 storybook/mocks/tauri-apps-api-app.ts create mode 100644 storybook/mocks/tauri-apps-api-core.ts create mode 100644 storybook/mocks/tauri-apps-api-event.ts create mode 100644 storybook/mocks/tauri-plugin-dialog.ts create mode 100644 storybook/mocks/tauri-plugin-fs.ts create mode 100644 storybook/mocks/tauri-plugin-macos-permissions-api.ts create mode 100644 storybook/mocks/tauri-plugin-opener.ts create mode 100644 storybook/mocks/tauri-plugin-os.ts create mode 100644 storybook/mocks/tauri-plugin-process.ts create mode 100644 storybook/mocks/tauri-plugin-updater.ts create mode 100644 storybook/mocks/useSettings.ts create mode 100644 storybook/storybook.css create mode 100644 storybook/storybook.tsx create mode 100644 vite.storybook.config.ts diff --git a/package.json b/package.json index 6abd4a370..d8d0b3d21 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", "test:playwright:ui": "playwright test --ui", - "check:translations": "bun scripts/check-translations.ts" + "check:translations": "bun scripts/check-translations.ts", + "storybook": "vite --config vite.storybook.config.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx new file mode 100644 index 000000000..859de7956 --- /dev/null +++ b/src/components/ui/IconButton.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +export type IconButtonVariant = "primary" | "secondary" | "danger" | "ghost"; +export type IconButtonSize = "sm" | "md" | "lg"; + +interface IconButtonProps extends Omit, "children"> { + /** Icon element to render (e.g. ). No label text. */ + icon: React.ReactNode; + /** Accessible name for the button (required when no visible text). */ + "aria-label": string; + variant?: IconButtonVariant; + size?: IconButtonSize; +} + +const variantClasses: Record = { + primary: + "text-white bg-background-ui border-background-ui hover:bg-background-ui/80 hover:border-background-ui/80 focus:ring-1 focus:ring-background-ui", + secondary: + "text-text bg-mid-gray/10 border-mid-gray/20 hover:bg-logo-primary/30 hover:border-logo-primary focus:outline-none focus:ring-1 focus:ring-logo-primary", + danger: + "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", + ghost: + "text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20 focus:ring-1 focus:ring-logo-primary", +}; + +const sizeClasses: Record = { + sm: "p-1 rounded [&_svg]:w-3 [&_svg]:h-3", + md: "p-2 rounded [&_svg]:w-4 [&_svg]:h-4", + lg: "p-2 rounded [&_svg]:w-5 [&_svg]:h-5", +}; + +export const IconButton: React.FC = ({ + icon, + "aria-label": ariaLabel, + variant = "secondary", + size = "md", + className = "", + disabled = false, + ...props +}) => ( + +); diff --git a/storybook/.gitignore b/storybook/.gitignore new file mode 100644 index 000000000..5197c87ee --- /dev/null +++ b/storybook/.gitignore @@ -0,0 +1 @@ +.vite/ diff --git a/storybook/index.html b/storybook/index.html new file mode 100644 index 000000000..6f6f05078 --- /dev/null +++ b/storybook/index.html @@ -0,0 +1,42 @@ + + + + + + Handy Storybook + + +
+
+

Handy Storybook

+

+ This page must be served with Vite. Opening this file directly will + not render React components. +

+

Run:

+
bun install
+bun run storybook
+

Then open: http://localhost:1422/HandyMain/storybook/

+
+
+ + + diff --git a/storybook/main.tsx b/storybook/main.tsx new file mode 100644 index 000000000..e06ed9807 --- /dev/null +++ b/storybook/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "@/i18n"; +import "./storybook.css"; +import { StorybookApp } from "./storybook"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/storybook/mocks/bindings.ts b/storybook/mocks/bindings.ts new file mode 100644 index 000000000..2cb3288af --- /dev/null +++ b/storybook/mocks/bindings.ts @@ -0,0 +1,85 @@ +import { + MOCK_APP_DIR, + MOCK_AUDIO_FILE, + MOCK_HISTORY, + MOCK_LOG_DIR, + MOCK_MODELS, + MOCK_POST_PROCESS_PROVIDERS, + MOCK_PROMPTS, + MOCK_RECORDINGS_DIR, + MOCK_SETTINGS, +} from "./data"; + +const ok = (data: T) => ({ status: "ok" as const, data }); + +export const commands = { + // App + system + getAppSettings: async () => ok(MOCK_SETTINGS), + getDefaultSettings: async () => ok(MOCK_SETTINGS), + getAppDirPath: async () => ok(MOCK_APP_DIR), + openAppDataDir: async () => ok(null), + getLogDirPath: async () => ok(MOCK_LOG_DIR), + openLogDir: async () => ok(null), + + // Permissions + shortcuts + initializeEnigo: async () => ok(null), + initializeShortcuts: async () => ok(null), + suspendBinding: async () => ok(null), + resumeBinding: async () => ok(null), + startHandyKeysRecording: async () => ok(null), + stopHandyKeysRecording: async () => ok(null), + changeKeyboardImplementationSetting: async () => + ok({ success: true, reset_bindings: [] }), + + // Models + getTranscriptionModelStatus: async () => ok(MOCK_SETTINGS.selected_model), + isRecording: async () => false, + + // Hardware + environment + isLaptop: async () => ok(true), + + // Post-processing + addPostProcessPrompt: async (name: string, prompt: string) => + ok({ id: `prompt_${Date.now()}`, name, prompt }), + updatePostProcessPrompt: async () => ok(null), + deletePostProcessPrompt: async () => ok(null), + checkAppleIntelligenceAvailable: async () => false, + + // History + getHistoryEntries: async () => ok(MOCK_HISTORY), + toggleHistoryEntrySaved: async () => ok(null), + getAudioFilePath: async () => ok(MOCK_AUDIO_FILE), + deleteHistoryEntry: async () => ok(null), + openRecordingsFolder: async () => ok(MOCK_RECORDINGS_DIR), + + // Misc settings + setModelUnloadTimeout: async () => ok(null), + + // Models list for storybook previews (not used by components directly) + getAvailableModels: async () => ok(MOCK_MODELS), + + // Provider data for storybook previews (not used by components directly) + getPostProcessProviders: async () => ok(MOCK_POST_PROCESS_PROVIDERS), + getPostProcessPrompts: async () => ok(MOCK_PROMPTS), +}; + +export type { + AppSettings, + AudioDevice, + ClipboardHandling, + EngineType, + HistoryEntry, + ImplementationChangeResult, + KeyboardImplementation, + LLMPrompt, + LogLevel, + ModelInfo, + ModelUnloadTimeout, + OverlayPosition, + PasteMethod, + PostProcessProvider, + RecordingRetentionPeriod, + Result, + ShortcutBinding, + SoundTheme, +} from "../../../src/bindings"; diff --git a/storybook/mocks/data.ts b/storybook/mocks/data.ts new file mode 100644 index 000000000..f6adab034 --- /dev/null +++ b/storybook/mocks/data.ts @@ -0,0 +1,247 @@ +import type { + AppSettings, + AudioDevice, + HistoryEntry, + ModelInfo, + PostProcessProvider, + LLMPrompt, + ShortcutBinding, +} from "../../../src/bindings"; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +export const MOCK_BINDINGS: Record = { + toggle_recording: { + id: "toggle_recording", + name: "Toggle Recording", + description: "Start or stop recording", + default_binding: "Cmd+Shift+Space", + current_binding: "Cmd+Shift+Space", + }, + push_to_talk: { + id: "push_to_talk", + name: "Push To Talk", + description: "Hold to record", + default_binding: "Fn+Space", + current_binding: "Fn+Space", + }, +}; + +export const MOCK_POST_PROCESS_PROVIDERS: PostProcessProvider[] = [ + { + id: "openai", + label: "OpenAI", + base_url: "https://api.openai.com/v1", + allow_base_url_edit: false, + }, + { + id: "custom", + label: "Custom", + base_url: "https://llm.example.com/v1", + allow_base_url_edit: true, + }, + { + id: "apple_intelligence", + label: "Apple Intelligence", + base_url: "", + allow_base_url_edit: false, + }, +]; + +export const MOCK_PROMPTS: LLMPrompt[] = [ + { + id: "clean_up", + name: "Clean Up", + prompt: "Fix punctuation and remove filler words.", + }, + { + id: "meeting_notes", + name: "Meeting Notes", + prompt: "Convert the transcript into bullet-point meeting notes.", + }, +]; + +export const MOCK_SETTINGS: AppSettings = { + bindings: MOCK_BINDINGS, + push_to_talk: true, + audio_feedback: true, + audio_feedback_volume: 0.7, + sound_theme: "marimba", + start_hidden: false, + autostart_enabled: true, + update_checks_enabled: true, + selected_model: "whisper-small", + always_on_microphone: false, + selected_microphone: "Built-in Microphone", + clamshell_microphone: "Default", + selected_output_device: "Default", + translate_to_english: false, + selected_language: "en", + overlay_position: "bottom", + debug_mode: true, + log_level: "info", + custom_words: ["Handy", "Tauri", "Whisper"], + model_unload_timeout: "never", + word_correction_threshold: 0.6, + history_limit: 20, + recording_retention_period: "weeks2", + paste_method: "ctrl_v", + clipboard_handling: "copy_to_clipboard", + post_process_enabled: true, + post_process_provider_id: "openai", + post_process_providers: MOCK_POST_PROCESS_PROVIDERS, + post_process_api_keys: { openai: "sk-demo-key" }, + post_process_models: { openai: "gpt-4o-mini", custom: "custom-llm" }, + post_process_prompts: MOCK_PROMPTS, + post_process_selected_prompt_id: "clean_up", + mute_while_recording: true, + append_trailing_space: true, + app_language: "en", + experimental_enabled: true, + keyboard_implementation: "tauri", + paste_delay_ms: 80, +}; + +export const MOCK_AUDIO_DEVICES: AudioDevice[] = [ + { + index: "default", + name: "Default", + is_default: true, + }, + { + index: "mic-1", + name: "Built-in Microphone", + is_default: false, + }, + { + index: "mic-2", + name: "USB Podcast Mic", + is_default: false, + }, +]; + +export const MOCK_OUTPUT_DEVICES: AudioDevice[] = [ + { + index: "default", + name: "Default", + is_default: true, + }, + { + index: "out-1", + name: "Studio Speakers", + is_default: false, + }, + { + index: "out-2", + name: "USB Headset", + is_default: false, + }, +]; + +export const MOCK_MODELS: ModelInfo[] = [ + { + id: "whisper-small", + name: "Whisper Small", + description: "Good balance of speed and accuracy", + filename: "ggml-small.bin", + url: null, + size_mb: 488, + is_downloaded: true, + is_downloading: false, + partial_size: 0, + is_directory: false, + engine_type: "Whisper", + accuracy_score: 0.68, + speed_score: 0.72, + supports_translation: true, + is_recommended: true, + supported_languages: ["en", "es", "fr", "de"], + }, + { + id: "whisper-medium", + name: "Whisper Medium", + description: "Higher accuracy with moderate speed", + filename: "ggml-medium.bin", + url: null, + size_mb: 1530, + is_downloaded: false, + is_downloading: false, + partial_size: 0, + is_directory: false, + engine_type: "Whisper", + accuracy_score: 0.8, + speed_score: 0.55, + supports_translation: true, + is_recommended: false, + supported_languages: ["en", "es", "fr", "de", "it"], + }, + { + id: "whisper-large", + name: "Whisper Large", + description: "Maximum accuracy, slower on older machines", + filename: "ggml-large.bin", + url: null, + size_mb: 2950, + is_downloaded: false, + is_downloading: true, + partial_size: 620, + is_directory: false, + engine_type: "Whisper", + accuracy_score: 0.9, + speed_score: 0.35, + supports_translation: true, + is_recommended: false, + supported_languages: ["en", "es", "fr", "de", "it", "pt"], + }, +]; + +export const MOCK_DOWNLOAD_PROGRESS = { + "whisper-large": { + model_id: "whisper-large", + downloaded: 620, + total: 2950, + percentage: 21, + }, +}; + +export const MOCK_DOWNLOAD_STATS = { + "whisper-large": { + startTime: 0, + lastUpdate: 0, + totalDownloaded: 620, + speed: 12.4, + }, +}; + +export const MOCK_HISTORY: HistoryEntry[] = [ + { + id: 1, + file_name: "meeting-sync.wav", + timestamp: 1700000000, + saved: true, + title: "Weekly sync", + transcription_text: "We discussed Q1 goals and next steps.", + post_processed_text: + "Weekly sync notes: Q1 goals, shipping milestones, and next steps.", + post_process_prompt: "Meeting Notes", + }, + { + id: 2, + file_name: "voice-memo.wav", + timestamp: 1700500000, + saved: false, + title: "Voice memo", + transcription_text: "Remember to follow up with design.", + post_processed_text: null, + post_process_prompt: null, + }, +]; + +export const MOCK_APP_DIR = "/Users/edward/Library/Application Support/Handy"; +export const MOCK_LOG_DIR = "/Users/edward/Library/Logs/Handy"; +export const MOCK_RECORDINGS_DIR = + "/Users/edward/Library/Application Support/Handy/recordings"; +export const MOCK_AUDIO_FILE = + "/Users/edward/Library/Application Support/Handy/recordings/meeting-sync.wav"; diff --git a/storybook/mocks/modelStore.ts b/storybook/mocks/modelStore.ts new file mode 100644 index 000000000..f855a7152 --- /dev/null +++ b/storybook/mocks/modelStore.ts @@ -0,0 +1,42 @@ +import { + MOCK_DOWNLOAD_PROGRESS, + MOCK_DOWNLOAD_STATS, + MOCK_MODELS, +} from "./data"; + +const store = { + models: MOCK_MODELS, + currentModel: "whisper-small", + downloadingModels: { "whisper-large": true as const }, + extractingModels: {}, + downloadProgress: MOCK_DOWNLOAD_PROGRESS, + downloadStats: MOCK_DOWNLOAD_STATS, + loading: false, + error: null as string | null, + hasAnyModels: true, + isFirstRun: false, + initialized: true, + initialize: async () => {}, + loadModels: async () => {}, + loadCurrentModel: async () => {}, + checkFirstRun: async () => false, + selectModel: async (modelId: string) => { + store.currentModel = modelId; + return true; + }, + downloadModel: async () => true, + cancelDownload: async () => true, + deleteModel: async () => true, + getModelInfo: (modelId: string) => + store.models.find((model) => model.id === modelId), + isModelDownloading: (modelId: string) => modelId in store.downloadingModels, + isModelExtracting: (modelId: string) => modelId in store.extractingModels, + getDownloadProgress: (modelId: string) => store.downloadProgress[modelId], + setModels: () => {}, + setCurrentModel: () => {}, + setError: () => {}, + setLoading: () => {}, +}; + +export const useModelStore = (selector?: (state: typeof store) => any) => + selector ? selector(store) : store; diff --git a/storybook/mocks/settingsStore.ts b/storybook/mocks/settingsStore.ts new file mode 100644 index 000000000..b51f5b60c --- /dev/null +++ b/storybook/mocks/settingsStore.ts @@ -0,0 +1,45 @@ +import { MOCK_AUDIO_DEVICES, MOCK_OUTPUT_DEVICES, MOCK_SETTINGS } from "./data"; + +const store = { + settings: MOCK_SETTINGS, + defaultSettings: MOCK_SETTINGS, + isLoading: false, + isUpdating: {}, + audioDevices: MOCK_AUDIO_DEVICES, + outputDevices: MOCK_OUTPUT_DEVICES, + customSounds: { start: true, stop: true }, + postProcessModelOptions: { + openai: ["gpt-4o-mini", "gpt-4o"], + custom: ["custom-llm"], + }, + initialize: async () => {}, + loadDefaultSettings: async () => {}, + updateSetting: async () => {}, + resetSetting: async () => {}, + refreshSettings: async () => {}, + refreshAudioDevices: async () => {}, + refreshOutputDevices: async () => {}, + updateBinding: async () => {}, + resetBinding: async () => {}, + getSetting: (key: string) => (store.settings as any)?.[key], + isUpdatingKey: () => false, + playTestSound: async () => {}, + checkCustomSounds: async () => {}, + setPostProcessProvider: async () => {}, + updatePostProcessSetting: async () => {}, + updatePostProcessBaseUrl: async () => {}, + updatePostProcessApiKey: async () => {}, + updatePostProcessModel: async () => {}, + fetchPostProcessModels: async () => [], + setPostProcessModelOptions: () => {}, + setSettings: () => {}, + setDefaultSettings: () => {}, + setLoading: () => {}, + setUpdating: () => {}, + setAudioDevices: () => {}, + setOutputDevices: () => {}, + setCustomSounds: () => {}, +}; + +export const useSettingsStore = (selector: (state: typeof store) => any) => + selector(store); diff --git a/storybook/mocks/tauri-apps-api-app.ts b/storybook/mocks/tauri-apps-api-app.ts new file mode 100644 index 000000000..ad8048b25 --- /dev/null +++ b/storybook/mocks/tauri-apps-api-app.ts @@ -0,0 +1 @@ +export const getVersion = async () => "0.7.2-storybook"; diff --git a/storybook/mocks/tauri-apps-api-core.ts b/storybook/mocks/tauri-apps-api-core.ts new file mode 100644 index 000000000..aa169eb69 --- /dev/null +++ b/storybook/mocks/tauri-apps-api-core.ts @@ -0,0 +1 @@ +export const convertFileSrc = (path: string) => path; diff --git a/storybook/mocks/tauri-apps-api-event.ts b/storybook/mocks/tauri-apps-api-event.ts new file mode 100644 index 000000000..c24f86489 --- /dev/null +++ b/storybook/mocks/tauri-apps-api-event.ts @@ -0,0 +1,3 @@ +export const listen = async () => { + return () => {}; +}; diff --git a/storybook/mocks/tauri-plugin-dialog.ts b/storybook/mocks/tauri-plugin-dialog.ts new file mode 100644 index 000000000..7693c3753 --- /dev/null +++ b/storybook/mocks/tauri-plugin-dialog.ts @@ -0,0 +1 @@ +export const ask = async () => false; diff --git a/storybook/mocks/tauri-plugin-fs.ts b/storybook/mocks/tauri-plugin-fs.ts new file mode 100644 index 000000000..b2ce593f2 --- /dev/null +++ b/storybook/mocks/tauri-plugin-fs.ts @@ -0,0 +1 @@ +export const readFile = async () => new Uint8Array(); diff --git a/storybook/mocks/tauri-plugin-macos-permissions-api.ts b/storybook/mocks/tauri-plugin-macos-permissions-api.ts new file mode 100644 index 000000000..1aa2549da --- /dev/null +++ b/storybook/mocks/tauri-plugin-macos-permissions-api.ts @@ -0,0 +1,4 @@ +export const checkAccessibilityPermission = async () => false; +export const requestAccessibilityPermission = async () => {}; +export const checkMicrophonePermission = async () => false; +export const requestMicrophonePermission = async () => {}; diff --git a/storybook/mocks/tauri-plugin-opener.ts b/storybook/mocks/tauri-plugin-opener.ts new file mode 100644 index 000000000..3587629ba --- /dev/null +++ b/storybook/mocks/tauri-plugin-opener.ts @@ -0,0 +1 @@ +export const openUrl = async () => {}; diff --git a/storybook/mocks/tauri-plugin-os.ts b/storybook/mocks/tauri-plugin-os.ts new file mode 100644 index 000000000..ed748dce5 --- /dev/null +++ b/storybook/mocks/tauri-plugin-os.ts @@ -0,0 +1,3 @@ +export const type = () => "macos"; +export const platform = () => "macos"; +export const locale = async () => "en-US"; diff --git a/storybook/mocks/tauri-plugin-process.ts b/storybook/mocks/tauri-plugin-process.ts new file mode 100644 index 000000000..3a60c3b7d --- /dev/null +++ b/storybook/mocks/tauri-plugin-process.ts @@ -0,0 +1 @@ +export const relaunch = async () => {}; diff --git a/storybook/mocks/tauri-plugin-updater.ts b/storybook/mocks/tauri-plugin-updater.ts new file mode 100644 index 000000000..8be77c9ae --- /dev/null +++ b/storybook/mocks/tauri-plugin-updater.ts @@ -0,0 +1 @@ +export const check = async () => null; diff --git a/storybook/mocks/useSettings.ts b/storybook/mocks/useSettings.ts new file mode 100644 index 000000000..d048f3fc4 --- /dev/null +++ b/storybook/mocks/useSettings.ts @@ -0,0 +1,115 @@ +import type { AppSettings } from "../../../src/bindings"; +import { + MOCK_AUDIO_DEVICES, + MOCK_OUTPUT_DEVICES, + MOCK_SETTINGS, +} from "./data"; + +let settingsState: AppSettings = { ...MOCK_SETTINGS }; + +const updateSettingsState = (partial: Partial) => { + settingsState = { ...settingsState, ...partial }; +}; + +export const useSettings = () => { + const settings = settingsState; + + const updateSetting = async ( + key: K, + value: AppSettings[K], + ) => { + updateSettingsState({ [key]: value } as Partial); + }; + + const resetSetting = async (key: keyof AppSettings) => { + updateSettingsState({ [key]: MOCK_SETTINGS[key] } as Partial); + }; + + const updateBinding = async (id: string, binding: string) => { + const nextBindings = { + ...(settingsState.bindings || {}), + [id]: { + ...(settingsState.bindings?.[id] ?? { + id, + name: id, + description: "", + default_binding: binding, + current_binding: binding, + }), + current_binding: binding, + }, + }; + updateSettingsState({ bindings: nextBindings }); + }; + + const resetBinding = async (id: string) => { + const binding = settingsState.bindings?.[id]; + if (!binding) return; + updateBinding(id, binding.default_binding); + }; + + const setPostProcessProvider = async (providerId: string) => { + updateSettingsState({ post_process_provider_id: providerId }); + }; + + const updatePostProcessBaseUrl = async ( + providerId: string, + baseUrl: string, + ) => { + const providers = settingsState.post_process_providers || []; + const nextProviders = providers.map((provider) => + provider.id === providerId ? { ...provider, base_url: baseUrl } : provider, + ); + updateSettingsState({ post_process_providers: nextProviders }); + }; + + const updatePostProcessApiKey = async ( + providerId: string, + apiKey: string, + ) => { + updateSettingsState({ + post_process_api_keys: { + ...(settingsState.post_process_api_keys || {}), + [providerId]: apiKey, + }, + }); + }; + + const updatePostProcessModel = async (providerId: string, model: string) => { + updateSettingsState({ + post_process_models: { + ...(settingsState.post_process_models || {}), + [providerId]: model, + }, + }); + }; + + const postProcessModelOptions: Record = { + openai: ["gpt-4o-mini", "gpt-4o"], + custom: ["custom-llm", "local-llm"], + }; + + return { + settings, + isLoading: false, + isUpdating: () => false, + audioDevices: MOCK_AUDIO_DEVICES, + outputDevices: MOCK_OUTPUT_DEVICES, + audioFeedbackEnabled: settings.audio_feedback || false, + postProcessModelOptions, + updateSetting, + resetSetting, + refreshSettings: async () => {}, + refreshAudioDevices: async () => {}, + refreshOutputDevices: async () => {}, + updateBinding, + resetBinding, + getSetting: (key: K) => settings[key], + setPostProcessProvider, + updatePostProcessBaseUrl, + updatePostProcessApiKey, + updatePostProcessModel, + fetchPostProcessModels: async (providerId: string) => + postProcessModelOptions[providerId] || [], + }; +}; diff --git a/storybook/storybook.css b/storybook/storybook.css new file mode 100644 index 000000000..1bdbc4068 --- /dev/null +++ b/storybook/storybook.css @@ -0,0 +1,248 @@ +@import "tailwindcss"; + +/* Tell Tailwind v4 to scan the actual component source files */ +@source "../src/**/*.{ts,tsx}"; +@source "./*.{ts,tsx}"; + +/* Register all Handy design-token colors so Tailwind v4 generates + utilities like bg-background-ui, text-mid-gray/60, border-mid-gray/20 etc. */ +@theme { + --color-text: #0f0f0f; + --color-background: #fbfbfb; + --color-background-ui: #da5893; + --color-logo-primary: #faa2ca; + --color-logo-stroke: #382731; + --color-text-stroke: #f6f6f6; + --color-mid-gray: #808080; +} + +:root { + font-family: "Inter", system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +[data-theme="light"] { + color-scheme: light; + --color-text: #0f0f0f; + --color-background: #fbfbfb; + --color-background-ui: #da5893; + --color-logo-primary: #faa2ca; + --color-logo-stroke: #382731; + --color-text-stroke: #f6f6f6; + --color-mid-gray: #808080; + --color-error: #dc2626; + --color-error-hover: #b91c1c; + --color-error-text: #f87171; + --color-error-bg: rgba(239, 68, 68, 0.1); + --color-warning: #eab308; + --color-warning-text: #facc15; + --color-warning-bg: rgba(234, 179, 8, 0.1); + --color-info: #3b82f6; + --color-info-text: #60a5fa; + --color-info-bg: rgba(59, 130, 246, 0.1); + --color-success: #22c55e; + --color-success-text: #4ade80; + --color-success-bg: rgba(34, 197, 94, 0.1); + --color-primary-alpha-5: rgba(218, 88, 147, 0.05); + --color-primary-alpha-8: rgba(218, 88, 147, 0.08); + --color-primary-alpha-10: rgba(218, 88, 147, 0.1); + --color-primary-alpha-20: rgba(218, 88, 147, 0.2); + --color-primary-alpha-30: rgba(218, 88, 147, 0.3); + --color-primary-alpha-50: rgba(218, 88, 147, 0.5); + --color-primary-alpha-80: rgba(218, 88, 147, 0.8); + --color-primary-alpha-90: rgba(218, 88, 147, 0.9); + --color-gray-alpha-5: rgba(128, 128, 128, 0.05); + --color-gray-alpha-10: rgba(128, 128, 128, 0.1); + --color-gray-alpha-20: rgba(128, 128, 128, 0.2); + --color-gray-alpha-40: rgba(128, 128, 128, 0.4); + --color-gray-alpha-60: rgba(128, 128, 128, 0.6); + --color-gray-alpha-80: rgba(128, 128, 128, 0.8); + --color-text-alpha-40: rgba(15, 15, 15, 0.4); + --color-text-alpha-45: rgba(15, 15, 15, 0.45); + --color-text-alpha-50: rgba(15, 15, 15, 0.5); + --color-text-alpha-60: rgba(15, 15, 15, 0.6); + --color-text-alpha-70: rgba(15, 15, 15, 0.7); + --color-text-alpha-80: rgba(15, 15, 15, 0.8); + --color-text-alpha-90: rgba(15, 15, 15, 0.9); + --color-text-on-primary: #ffffff; + --sb-sidebar-bg: #f6f6f6; + --sb-border: #e4e4e4; + --sb-canvas: #ffffff; + --sb-toolbar: #ffffff; + --sb-hover: rgba(218, 88, 147, 0.07); + --sb-active: rgba(218, 88, 147, 0.13); + --sb-dim: #777; +} + +[data-theme="dark"] { + color-scheme: dark; + --color-text: #f7f5f3; + --color-background: #262523; + --color-background-ui: #da5893; + --color-logo-primary: #f28cbb; + --color-logo-stroke: #f3cbe2; + --color-text-stroke: #262523; + --color-mid-gray: #9b9b9b; + --color-error: #dc2626; + --color-error-hover: #b91c1c; + --color-error-text: #f87171; + --color-error-bg: rgba(239, 68, 68, 0.1); + --color-warning: #eab308; + --color-warning-text: #facc15; + --color-warning-bg: rgba(234, 179, 8, 0.1); + --color-info: #3b82f6; + --color-info-text: #60a5fa; + --color-info-bg: rgba(59, 130, 246, 0.1); + --color-success: #22c55e; + --color-success-text: #4ade80; + --color-success-bg: rgba(34, 197, 94, 0.1); + --color-primary-alpha-5: rgba(218, 88, 147, 0.05); + --color-primary-alpha-8: rgba(218, 88, 147, 0.08); + --color-primary-alpha-10: rgba(218, 88, 147, 0.1); + --color-primary-alpha-20: rgba(218, 88, 147, 0.2); + --color-primary-alpha-30: rgba(218, 88, 147, 0.3); + --color-primary-alpha-50: rgba(218, 88, 147, 0.5); + --color-primary-alpha-80: rgba(218, 88, 147, 0.8); + --color-primary-alpha-90: rgba(218, 88, 147, 0.9); + --color-gray-alpha-5: rgba(128, 128, 128, 0.05); + --color-gray-alpha-10: rgba(128, 128, 128, 0.1); + --color-gray-alpha-20: rgba(128, 128, 128, 0.2); + --color-gray-alpha-40: rgba(128, 128, 128, 0.4); + --color-gray-alpha-60: rgba(128, 128, 128, 0.6); + --color-gray-alpha-80: rgba(128, 128, 128, 0.8); + --color-text-alpha-40: rgba(251, 251, 251, 0.4); + --color-text-alpha-45: rgba(251, 251, 251, 0.45); + --color-text-alpha-50: rgba(251, 251, 251, 0.5); + --color-text-alpha-60: rgba(251, 251, 251, 0.6); + --color-text-alpha-70: rgba(251, 251, 251, 0.7); + --color-text-alpha-80: rgba(251, 251, 251, 0.8); + --color-text-alpha-90: rgba(251, 251, 251, 0.9); + --color-text-on-primary: #ffffff; + --sb-sidebar-bg: #1e1d1b; + --sb-border: #333; + --sb-canvas: #2c2b29; + --sb-toolbar: #1e1d1b; + --sb-hover: rgba(242, 140, 187, 0.1); + --sb-active: rgba(242, 140, 187, 0.18); + --sb-dim: #888; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--sb-canvas); color: var(--color-text); } + +/* ---- Layout ---- */ +.sb-root { display: flex; height: 100vh; overflow: hidden; } + +/* ---- Sidebar ---- */ +.sb-sidebar { + width: 240px; + min-width: 240px; + background: var(--sb-sidebar-bg); + border-right: 1px solid var(--sb-border); + display: flex; + flex-direction: column; +} + +.sb-sidebar-head { + padding: 14px 14px 10px; + border-bottom: 1px solid var(--sb-border); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.sb-sidebar-scroll { + flex: 1; + overflow-y: auto; + padding: 6px 0; +} +.sb-sidebar-scroll::-webkit-scrollbar { width: 4px; } +.sb-sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.3); border-radius: 4px; } + +.sb-group-btn { + display: flex; align-items: center; gap: 4px; width: 100%; + padding: 5px 14px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + color: var(--sb-dim); background: none; border: none; cursor: pointer; text-align: left; +} +.sb-group-btn:hover { color: var(--color-text); } +.sb-group-btn svg { width: 10px; height: 10px; transition: transform 120ms; flex-shrink: 0; } +.sb-group-btn[data-open="false"] svg { transform: rotate(-90deg); } + +.sb-group-list { overflow: hidden; } +.sb-group-list[data-open="false"] { display: none; } + +.sb-nav-item { + display: block; width: 100%; padding: 4px 14px 4px 26px; + font-size: 13px; color: var(--color-text); opacity: 0.7; + background: none; border: none; cursor: pointer; text-align: left; + transition: background 80ms, opacity 80ms; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.sb-nav-item:hover { background: var(--sb-hover); opacity: 1; } +.sb-nav-item[data-active="true"] { + background: var(--sb-active); opacity: 1; font-weight: 500; color: var(--color-logo-primary); +} + +.sb-sidebar-foot { + padding: 10px 14px; border-top: 1px solid var(--sb-border); flex-shrink: 0; +} +.sb-theme-row { display: flex; border-radius: 6px; border: 1px solid var(--sb-border); overflow: hidden; } +.sb-theme-btn { + flex: 1; padding: 4px 0; font-size: 12px; font-weight: 500; + border: none; cursor: pointer; background: transparent; color: var(--sb-dim); transition: all 80ms; +} +.sb-theme-btn[data-active="true"] { background: var(--color-background-ui); color: #fff; } +.sb-theme-btn:not([data-active="true"]):hover { background: var(--sb-hover); color: var(--color-text); } + +/* ---- Main ---- */ +.sb-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } + +.sb-toolbar { + height: 38px; min-height: 38px; background: var(--sb-toolbar); + border-bottom: 1px solid var(--sb-border); + display: flex; align-items: center; padding: 0 16px; gap: 6px; + font-size: 12px; color: var(--sb-dim); +} +.sb-toolbar-sep { opacity: 0.4; } +.sb-toolbar-cur { font-weight: 500; color: var(--color-text); } + +.sb-canvas { + flex: 1; overflow: auto; padding: 32px; + background: var(--sb-canvas); +} + +/* ---- Story rendering ---- */ +.sb-story-name { font-size: 18px; font-weight: 600; margin-bottom: 4px; } +.sb-story-hint { font-size: 13px; color: var(--sb-dim); margin-bottom: 20px; } + +.sb-variant { margin-bottom: 24px; } +.sb-variant-label { + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--sb-dim); margin-bottom: 8px; +} + +.sb-box { + padding: 20px; border: 1px solid var(--sb-border); + border-radius: 6px; background: var(--color-background); +} +.sb-box-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.sb-box-col { display: flex; flex-direction: column; gap: 10px; } + +.sb-fullscreen { + position: relative; height: 480px; overflow: hidden; + border-radius: 6px; border: 1px solid var(--sb-border); background: var(--color-background); +} +.sb-fullscreen > div { transform-origin: top left; transform: scale(0.65); width: 154%; height: 154%; } + +@layer utilities { + .text-stroke { -webkit-text-stroke: 2px var(--color-text-stroke); } +} +.logo-primary { fill: var(--color-logo-primary); } +.logo-stroke { fill: var(--color-logo-stroke); stroke: var(--color-logo-stroke); stroke-width: 1; } diff --git a/storybook/storybook.tsx b/storybook/storybook.tsx new file mode 100644 index 000000000..14896de2b --- /dev/null +++ b/storybook/storybook.tsx @@ -0,0 +1,750 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Toaster } from "sonner"; +import ProgressBar from "@/components/shared/ProgressBar"; +import HandyTextLogo from "@/components/icons/HandyTextLogo"; +import CancelIcon from "@/components/icons/CancelIcon"; +import HandyHand from "@/components/icons/HandyHand"; +import ResetIcon from "@/components/icons/ResetIcon"; +import TranscriptionIcon from "@/components/icons/TranscriptionIcon"; +import MicrophoneIcon from "@/components/icons/MicrophoneIcon"; +import { AudioPlayer } from "@/components/ui/AudioPlayer"; +import { Slider } from "@/components/ui/Slider"; +import { SettingsGroup } from "@/components/ui/SettingsGroup"; +import { SettingContainer } from "@/components/ui/SettingContainer"; +import { TextDisplay } from "@/components/ui/TextDisplay"; +import { Tooltip } from "@/components/ui/Tooltip"; +import { Alert } from "@/components/ui/Alert"; +import { ResetButton } from "@/components/ui/ResetButton"; +import { Dropdown } from "@/components/ui/Dropdown"; +import Badge from "@/components/ui/Badge"; +import { ToggleSwitch } from "@/components/ui/ToggleSwitch"; +import { IconButton } from "@/components/ui/IconButton"; +import { PathDisplay } from "@/components/ui/PathDisplay"; +import { Button } from "@/components/ui/Button"; +import { Select } from "@/components/ui/Select"; +import { Textarea } from "@/components/ui/Textarea"; +import { Input } from "@/components/ui/Input"; + +// ─── Types ─────────────────────────────────────────────────────────── +type ThemeMode = "light" | "dark"; + +interface StoryDef { + id: string; + name: string; + group: string; + render: () => React.ReactNode; +} + +// ─── Helpers ───────────────────────────────────────────────────────── +const SILENT_AUDIO = + "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA="; + +const Variant: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( +
+
{label}
+
{children}
+
+); + +// ─── Story definitions ─────────────────────────────────────────────── +function useStories() { + const [toggleA, setToggleA] = useState(true); + const [toggleB, setToggleB] = useState(false); + const [slider, setSlider] = useState(0.65); + const [ddVal, setDdVal] = useState("alpha"); + const [selVal, setSelVal] = useState("alpha"); + const [crVal, setCrVal] = useState("custom-llm"); + const tooltipRef = useRef(null); + + const opts = useMemo( + () => [ + { value: "alpha", label: "Alpha" }, + { value: "bravo", label: "Bravo" }, + { value: "charlie", label: "Charlie" }, + ], + [], + ); + + const stories: StoryDef[] = useMemo( + () => [ + // ── Design Tokens ────────────────────────────────── + { + id: "colors", + name: "Colors", + group: "Design Tokens", + render: () => { + const colorGroups: { label: string; tokens: { name: string; var: string }[] }[] = [ + { + label: "Brand", + tokens: [ + { name: "background-ui (Primary)", var: "--color-background-ui" }, + { name: "logo-primary", var: "--color-logo-primary" }, + { name: "logo-stroke", var: "--color-logo-stroke" }, + ], + }, + { + label: "Surface", + tokens: [ + { name: "background", var: "--color-background" }, + { name: "text", var: "--color-text" }, + { name: "text-stroke", var: "--color-text-stroke" }, + { name: "mid-gray", var: "--color-mid-gray" }, + ], + }, + { + label: "Semantic", + tokens: [ + { name: "error", var: "--color-error" }, + { name: "error-text", var: "--color-error-text" }, + { name: "error-bg", var: "--color-error-bg" }, + { name: "warning", var: "--color-warning" }, + { name: "warning-text", var: "--color-warning-text" }, + { name: "warning-bg", var: "--color-warning-bg" }, + { name: "info", var: "--color-info" }, + { name: "info-text", var: "--color-info-text" }, + { name: "info-bg", var: "--color-info-bg" }, + { name: "success", var: "--color-success" }, + { name: "success-text", var: "--color-success-text" }, + { name: "success-bg", var: "--color-success-bg" }, + ], + }, + { + label: "Primary Alpha", + tokens: [ + { name: "primary-alpha-5", var: "--color-primary-alpha-5" }, + { name: "primary-alpha-10", var: "--color-primary-alpha-10" }, + { name: "primary-alpha-20", var: "--color-primary-alpha-20" }, + { name: "primary-alpha-30", var: "--color-primary-alpha-30" }, + { name: "primary-alpha-50", var: "--color-primary-alpha-50" }, + { name: "primary-alpha-80", var: "--color-primary-alpha-80" }, + ], + }, + { + label: "Gray Alpha", + tokens: [ + { name: "gray-alpha-5", var: "--color-gray-alpha-5" }, + { name: "gray-alpha-10", var: "--color-gray-alpha-10" }, + { name: "gray-alpha-20", var: "--color-gray-alpha-20" }, + { name: "gray-alpha-40", var: "--color-gray-alpha-40" }, + { name: "gray-alpha-60", var: "--color-gray-alpha-60" }, + { name: "gray-alpha-80", var: "--color-gray-alpha-80" }, + ], + }, + { + label: "Text Alpha", + tokens: [ + { name: "text-alpha-40", var: "--color-text-alpha-40" }, + { name: "text-alpha-50", var: "--color-text-alpha-50" }, + { name: "text-alpha-60", var: "--color-text-alpha-60" }, + { name: "text-alpha-70", var: "--color-text-alpha-70" }, + { name: "text-alpha-80", var: "--color-text-alpha-80" }, + { name: "text-alpha-90", var: "--color-text-alpha-90" }, + ], + }, + ]; + return ( +
+ {colorGroups.map((group) => ( +
+
{group.label}
+
+ {group.tokens.map((token) => ( +
+
+ {token.name} + {token.var} +
+ ))} +
+
+ ))} +
+ ); + }, + }, + { + id: "typography", + name: "Typography", + group: "Design Tokens", + render: () => { + const typeScale = [ + { label: "Display", size: "28px", weight: "700", lineHeight: "1.2", sample: "Handy — Voice to Text" }, + { label: "Heading 1", size: "22px", weight: "600", lineHeight: "1.3", sample: "Settings & Preferences" }, + { label: "Heading 2", size: "18px", weight: "600", lineHeight: "1.3", sample: "General Settings" }, + { label: "Heading 3", size: "15px", weight: "600", lineHeight: "1.4", sample: "Recording Options" }, + { label: "Body", size: "14px", weight: "400", lineHeight: "1.5", sample: "Select your preferred microphone and configure how recordings are handled." }, + { label: "Body Medium", size: "14px", weight: "500", lineHeight: "1.5", sample: "Push to talk is enabled" }, + { label: "Small", size: "13px", weight: "400", lineHeight: "1.5", sample: "Choose an audio input device from the list below." }, + { label: "Small Medium", size: "13px", weight: "500", lineHeight: "1.5", sample: "Model downloaded successfully" }, + { label: "Caption", size: "12px", weight: "400", lineHeight: "1.4", sample: "Last updated 3 minutes ago" }, + { label: "Label", size: "11px", weight: "600", lineHeight: "1.4", sample: "AUDIO INPUT", style: "uppercase" as const, letterSpacing: "0.05em" }, + { label: "Mono", size: "13px", weight: "400", lineHeight: "1.5", sample: "sk-ant-api03-xxxxxxxxxxxxxx", fontFamily: "monospace" }, + ]; + return ( +
+
+ Style + Size / Weight + Line Height + Sample +
+ {typeScale.map((row) => ( +
+ {row.label} + {row.size} / {row.weight} + {row.lineHeight} + {row.sample} +
+ ))} +
+ ); + }, + }, + + // ── Icons ────────────────────────────────────────── + { + id: "icons", + name: "All Icons", + group: "Icons", + render: () => ( + +
+ {[ + { label: "HandyTextLogo", node: }, + { label: "HandyHand", node: }, + { label: "TranscriptionIcon", node: }, + { label: "MicrophoneIcon", node: }, + { label: "ResetIcon", node: }, + { label: "CancelIcon", node: }, + ].map(({ label, node }) => ( +
+ {node} + {label} +
+ ))} +
+
+ ), + }, + + // ── UI Components ────────────────────────────────── + { + id: "button", + name: "Button", + group: "UI", + render: () => ( + <> + +
+ + + + + + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+ + ), + }, + { + id: "icon-button", + name: "IconButton", + group: "UI", + render: () => ( + <> + +
+ } /> + } /> + } /> + } /> +
+
+ +
+ } /> + } /> + } /> +
+
+ +
+ } /> +
+
+ + ), + }, + { + id: "input", + name: "Input", + group: "UI", + render: () => ( + <> + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + ), + }, + { + id: "textarea", + name: "Textarea", + group: "UI", + render: () => ( + <> + +