From 4b9be993294b34894fd1de5b14ce9c67be34f68c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 24 Aug 2026 19:42:06 +0900 Subject: [PATCH 01/21] Add: First-run onboarding flow for new installs Guides new users through the popup menu, AI prompt command, and link preview right after install via a dedicated onboarding tab, with a final Customize/Complete screen that persists hasShownOnboarding so it only runs once. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019NHfjgYsFbd7tPbEdQSg4p --- .../public/_locales/de/messages.json | 66 ++++++++ .../public/_locales/en/messages.json | 88 ++++++++++ .../public/_locales/es/messages.json | 66 ++++++++ .../public/_locales/fr/messages.json | 66 ++++++++ .../public/_locales/hi/messages.json | 66 ++++++++ .../public/_locales/id/messages.json | 66 ++++++++ .../public/_locales/it/messages.json | 66 ++++++++ .../public/_locales/ja/messages.json | 66 ++++++++ .../public/_locales/ko/messages.json | 66 ++++++++ .../public/_locales/ms/messages.json | 66 ++++++++ .../public/_locales/pt_BR/messages.json | 66 ++++++++ .../public/_locales/pt_PT/messages.json | 66 ++++++++ .../public/_locales/ru/messages.json | 66 ++++++++ .../public/_locales/zh_CN/messages.json | 66 ++++++++ .../extension/src/action/executor.test.ts | 22 +++ packages/extension/src/action/executor.ts | 3 + .../extension/src/background_script.test.ts | 46 ++++++ packages/extension/src/background_script.ts | 2 + .../src/components/menu/MenuItem.tsx | 1 + .../onboarding/Onboarding.module.css | 32 ++++ .../onboarding/OnboardingCallout.tsx | 49 ++++++ .../onboarding/OnboardingFadeIn.tsx | 16 ++ .../onboarding/OnboardingOverlay.tsx | 54 +++++++ .../components/onboarding/OnboardingPage.tsx | 50 ++++++ .../onboarding/onboardingAnalytics.ts | 12 ++ .../onboarding/onboardingCommand.ts | 36 +++++ .../components/onboarding/onboardingEvents.ts | 42 +++++ .../onboarding/steps/StepAiPromptCommand.tsx | 117 ++++++++++++++ .../onboarding/steps/StepComplete.tsx | 44 +++++ .../onboarding/steps/StepCustomize.tsx | 36 +++++ .../components/onboarding/steps/StepIntro.tsx | 36 +++++ .../onboarding/steps/StepLinkPreview.tsx | 78 +++++++++ .../onboarding/steps/StepSearchCommand.tsx | 150 ++++++++++++++++++ .../onboarding/useOnboardingState.ts | 90 +++++++++++ .../src/components/option/ImportExport.tsx | 2 + packages/extension/src/const.ts | 3 + .../src/hooks/useDetectLinkCommand.ts | 5 + .../extension/src/hooks/useSettings.test.tsx | 2 + packages/extension/src/onboarding_page.html | 15 ++ packages/extension/src/onboarding_page.tsx | 32 ++++ .../extension/src/services/analytics.test.ts | 17 ++ packages/extension/src/services/analytics.ts | 6 + .../services/option/defaultSettings.test.ts | 15 ++ .../src/services/option/defaultSettings.ts | 92 +++++++++++ .../src/services/settings/enhancedSettings.ts | 3 + .../src/services/settings/settings.test.ts | 3 + .../src/services/settings/settings.ts | 2 + .../extension/src/services/storage/index.ts | 1 + packages/extension/src/types/index.ts | 1 + packages/extension/src/types/onboarding.ts | 21 +++ packages/extension/vite.config.ts | 61 +++---- 51 files changed, 2113 insertions(+), 30 deletions(-) create mode 100644 packages/extension/src/components/onboarding/Onboarding.module.css create mode 100644 packages/extension/src/components/onboarding/OnboardingCallout.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingFadeIn.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingOverlay.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingPage.tsx create mode 100644 packages/extension/src/components/onboarding/onboardingAnalytics.ts create mode 100644 packages/extension/src/components/onboarding/onboardingCommand.ts create mode 100644 packages/extension/src/components/onboarding/onboardingEvents.ts create mode 100644 packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx create mode 100644 packages/extension/src/components/onboarding/steps/StepComplete.tsx create mode 100644 packages/extension/src/components/onboarding/steps/StepCustomize.tsx create mode 100644 packages/extension/src/components/onboarding/steps/StepIntro.tsx create mode 100644 packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx create mode 100644 packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx create mode 100644 packages/extension/src/components/onboarding/useOnboardingState.ts create mode 100644 packages/extension/src/onboarding_page.html create mode 100644 packages/extension/src/onboarding_page.tsx create mode 100644 packages/extension/src/types/onboarding.ts diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 91a1d709..85f9b1d3 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Beim Hub anmelden" + }, + "onboarding_step0Title": { + "message": "Danke, dass du Selection Command installiert hast!" + }, + "onboarding_step0Body": { + "message": "Lass uns das Ganze in etwa einer Minute direkt ausprobieren." + }, + "onboarding_startButton": { + "message": "Los geht's" + }, + "onboarding_skipButton": { + "message": "Überspringen" + }, + "onboarding_step1Explain": { + "message": "Markiere den folgenden Text, um sofort eine Suche auszuprobieren." + }, + "onboarding_step1TargetText": { + "message": "Mit Selection Command kannst du mit einem Klick suchen, die KI fragen oder Links in der Vorschau ansehen." + }, + "onboarding_step1Callout": { + "message": "Klicke auf diesen Befehl, um mit Google zu suchen." + }, + "onboarding_step1ValueMessage": { + "message": "Fertig – jetzt kannst du alles, was du markierst, sofort durchsuchen." + }, + "onboarding_nextButton": { + "message": "Weiter" + }, + "onboarding_step2Explain": { + "message": "Als Nächstes fragen wir die KI zum folgenden Text." + }, + "onboarding_step2TargetText": { + "message": "Dies ist eine Erweiterung, mit der du markierten Text von einer Webseite in eine Prompt-Vorlage einfügen und ausführen kannst. Überlege dir ein paar Ideen, wofür du sie nutzen könntest." + }, + "onboarding_step2Callout": { + "message": "Klicke auf diesen Befehl, um deine Auswahl an die KI zu senden." + }, + "onboarding_step2ValueMessage": { + "message": "Jetzt kannst du die KI direkt auf der Seite, die du liest, zu allem fragen, was du markierst." + }, + "onboarding_step3Explain": { + "message": "Zum Schluss schauen wir uns einen Link an, ohne einen neuen Tab zu öffnen. Klicke bei gedrückter Umschalttaste (Shift) auf den folgenden Link." + }, + "onboarding_step3LinkLabel": { + "message": "Beispiel-Link" + }, + "onboarding_step3ValueMessage": { + "message": "Du kannst Links in der Vorschau ansehen, ohne die Seite zu verlassen." + }, + "onboarding_step4Explain": { + "message": "Die gerade ausprobierten Befehle kannst du jederzeit in den Einstellungen bearbeiten, entfernen oder neue hinzufügen." + }, + "onboarding_finishButton": { + "message": "Fertig" + }, + "onboarding_completeTitle": { + "message": "Alles bereit!" + }, + "onboarding_completeBody": { + "message": "Selection Command ist einsatzbereit – wähle einfach Text auf einer beliebigen Seite aus, um loszulegen." + }, + "onboarding_openSettingsButton": { + "message": "Einstellungen öffnen" + }, + "onboarding_closeButton": { + "message": "Schließen" } } diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 8514e366..02925497 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1319,5 +1319,93 @@ }, "hub_login": { "message": "Login to Hub" + }, + "onboarding_step0Title": { + "message": "Thanks for installing Selection Command!", + "description": "Onboarding Step0 (intro screen) heading, shown right after install." + }, + "onboarding_step0Body": { + "message": "Let's try it out for about a minute, using it as you go.", + "description": "Onboarding Step0 (intro screen) body text." + }, + "onboarding_startButton": { + "message": "Get Started", + "description": "Onboarding Step0 button that advances to Step1 (Search)." + }, + "onboarding_skipButton": { + "message": "Skip", + "description": "Button shown on every onboarding step to end the onboarding early." + }, + "onboarding_step1Explain": { + "message": "Select the text below to try a search right away.", + "description": "Onboarding Step1 (Search command) instruction shown before the user selects the sample text." + }, + "onboarding_step1TargetText": { + "message": "Selection Command lets you search, ask AI, or preview links in a single click.", + "description": "Onboarding Step1 sample text the user is asked to select to trigger the real popup menu." + }, + "onboarding_step1Callout": { + "message": "Click this command to search Google.", + "description": "Onboarding Step1 callout/speech-bubble text pointing at the default Google search command in the popup menu." + }, + "onboarding_step1ValueMessage": { + "message": "That's it - you can now search for anything you select in an instant.", + "description": "Onboarding Step1 value message shown after the user runs the search command and returns to the onboarding tab." + }, + "onboarding_nextButton": { + "message": "Next", + "description": "Button that advances from a step's value message to the next onboarding step." + }, + "onboarding_step2Explain": { + "message": "Next, let's ask AI about the text below.", + "description": "Onboarding Step2 (AiPrompt command) instruction shown before the user selects the sample text." + }, + "onboarding_step2TargetText": { + "message": "This is an extension that lets you embed selected text from a web page into a prompt template and run it. Think of some ideas for what you could use it for.", + "description": "Onboarding Step2 sample text the user is asked to select to trigger the AiPrompt command." + }, + "onboarding_step2Callout": { + "message": "Click this command to send your selection to AI.", + "description": "Onboarding Step2 callout/speech-bubble text pointing at the onboarding's dedicated AiPrompt command in the popup menu." + }, + "onboarding_step2ValueMessage": { + "message": "You can now ask AI about anything you select, right from the page you're reading.", + "description": "Onboarding Step2 value message shown after the user runs the AiPrompt command." + }, + "onboarding_step3Explain": { + "message": "Finally, let's preview a link without opening a new tab. Shift+click the link below.", + "description": "Onboarding Step3 (Link Preview) instruction." + }, + "onboarding_step3LinkLabel": { + "message": "Sample Link", + "description": "Label of the sample link shown in Onboarding Step3 for the user to Shift+click." + }, + "onboarding_step3ValueMessage": { + "message": "You can preview links without leaving the page.", + "description": "Onboarding Step3 value message shown after the user triggers Link Preview." + }, + "onboarding_step4Explain": { + "message": "The commands you tried can be edited, removed, or added to anytime from Settings.", + "description": "Onboarding Step4 (Customize) explanation shown after Link Preview, before the final Complete screen." + }, + "onboarding_finishButton": { + "message": "Finish", + "description": "Onboarding Step4 button that advances to the final Complete screen." + }, + "onboarding_completeTitle": { + "message": "You're all set!", + "description": "Onboarding Step5 (Complete) heading, the final screen." + }, + "onboarding_completeBody": { + "message": "Selection Command is ready to use - just select text on any page to get started.", + "description": "Onboarding Step5 (Complete) body text." + }, + "onboarding_openSettingsButton": { + "message": "Open Settings", + "description": "Onboarding Step5 button that opens the extension's options page." + }, + "onboarding_closeButton": { + "message": "Close", + "description": "Onboarding Step5 button that closes the onboarding tab." } } diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 64e8143a..581ec011 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Iniciar sesión en Hub" + }, + "onboarding_step0Title": { + "message": "¡Gracias por instalar Selection Command!" + }, + "onboarding_step0Body": { + "message": "Probémoslo durante un minuto, usándolo sobre la marcha." + }, + "onboarding_startButton": { + "message": "Empezar" + }, + "onboarding_skipButton": { + "message": "Omitir" + }, + "onboarding_step1Explain": { + "message": "Selecciona el siguiente texto para probar una búsqueda al instante." + }, + "onboarding_step1TargetText": { + "message": "Con Selection Command puedes buscar, preguntar a la IA o previsualizar enlaces con un solo clic." + }, + "onboarding_step1Callout": { + "message": "Haz clic en este comando para buscar en Google." + }, + "onboarding_step1ValueMessage": { + "message": "Listo: ahora puedes buscar al instante cualquier texto que selecciones." + }, + "onboarding_nextButton": { + "message": "Siguiente" + }, + "onboarding_step2Explain": { + "message": "A continuación, preguntemos a la IA sobre el siguiente texto." + }, + "onboarding_step2TargetText": { + "message": "Esta es una extensión que te permite insertar el texto seleccionado de una página web en una plantilla de prompt y ejecutarlo. Piensa en algunas ideas de para qué podrías usarla." + }, + "onboarding_step2Callout": { + "message": "Haz clic en este comando para enviar tu selección a la IA." + }, + "onboarding_step2ValueMessage": { + "message": "Ahora puedes preguntarle a la IA sobre cualquier cosa que selecciones, sin salir de la página que estás leyendo." + }, + "onboarding_step3Explain": { + "message": "Por último, previsualicemos un enlace sin abrir una nueva pestaña. Haz clic en el siguiente enlace manteniendo pulsada la tecla Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Enlace de ejemplo" + }, + "onboarding_step3ValueMessage": { + "message": "Puedes previsualizar enlaces sin salir de la página." + }, + "onboarding_step4Explain": { + "message": "Los comandos que acabas de probar se pueden editar, eliminar o añadir en cualquier momento desde la configuración." + }, + "onboarding_finishButton": { + "message": "Finalizar" + }, + "onboarding_completeTitle": { + "message": "¡Todo listo!" + }, + "onboarding_completeBody": { + "message": "Selection Command ya está listo para usarse. Solo selecciona texto en cualquier página para empezar." + }, + "onboarding_openSettingsButton": { + "message": "Abrir configuración" + }, + "onboarding_closeButton": { + "message": "Cerrar" } } diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 3f91714e..daec358c 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Se connecter au Hub" + }, + "onboarding_step0Title": { + "message": "Merci d'avoir installé Selection Command !" + }, + "onboarding_step0Body": { + "message": "Prenons une minute pour l'essayer en pratique." + }, + "onboarding_startButton": { + "message": "Commencer" + }, + "onboarding_skipButton": { + "message": "Passer" + }, + "onboarding_step1Explain": { + "message": "Sélectionnez le texte ci-dessous pour essayer une recherche immédiatement." + }, + "onboarding_step1TargetText": { + "message": "Avec Selection Command, vous pouvez rechercher, interroger l'IA ou prévisualiser des liens en un clic." + }, + "onboarding_step1Callout": { + "message": "Cliquez sur cette commande pour rechercher sur Google." + }, + "onboarding_step1ValueMessage": { + "message": "Et voilà — vous pouvez désormais rechercher instantanément tout ce que vous sélectionnez." + }, + "onboarding_nextButton": { + "message": "Suivant" + }, + "onboarding_step2Explain": { + "message": "Ensuite, interrogeons l'IA à propos du texte ci-dessous." + }, + "onboarding_step2TargetText": { + "message": "Il s'agit d'une extension qui permet d'intégrer le texte sélectionné d'une page web dans un modèle de prompt et de l'exécuter. Réfléchissez à quelques idées d'utilisation." + }, + "onboarding_step2Callout": { + "message": "Cliquez sur cette commande pour envoyer votre sélection à l'IA." + }, + "onboarding_step2ValueMessage": { + "message": "Vous pouvez désormais interroger l'IA sur tout ce que vous sélectionnez, directement depuis la page que vous lisez." + }, + "onboarding_step3Explain": { + "message": "Enfin, prévisualisons un lien sans ouvrir un nouvel onglet. Cliquez sur le lien ci-dessous en maintenant la touche Maj (Shift)." + }, + "onboarding_step3LinkLabel": { + "message": "Lien d'exemple" + }, + "onboarding_step3ValueMessage": { + "message": "Vous pouvez prévisualiser des liens sans quitter la page." + }, + "onboarding_step4Explain": { + "message": "Les commandes que vous venez d'essayer peuvent être modifiées, supprimées ou complétées à tout moment depuis les paramètres." + }, + "onboarding_finishButton": { + "message": "Terminer" + }, + "onboarding_completeTitle": { + "message": "Tout est prêt !" + }, + "onboarding_completeBody": { + "message": "Selection Command est prêt à l'emploi : il vous suffit de sélectionner du texte sur n'importe quelle page pour commencer." + }, + "onboarding_openSettingsButton": { + "message": "Ouvrir les paramètres" + }, + "onboarding_closeButton": { + "message": "Fermer" } } diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 21d441da..e7702e1a 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Hub में लॉगिन करें" + }, + "onboarding_step0Title": { + "message": "Selection Command इंस्टॉल करने के लिए धन्यवाद!" + }, + "onboarding_step0Body": { + "message": "चलिए, इसे इस्तेमाल करते हुए लगभग एक मिनट में आज़माते हैं।" + }, + "onboarding_startButton": { + "message": "शुरू करें" + }, + "onboarding_skipButton": { + "message": "छोड़ें" + }, + "onboarding_step1Explain": { + "message": "तुरंत सर्च आज़माने के लिए नीचे दिए गए टेक्स्ट को चुनें।" + }, + "onboarding_step1TargetText": { + "message": "Selection Command से आप एक क्लिक में सर्च कर सकते हैं, AI से पूछ सकते हैं, या लिंक का पूर्वावलोकन देख सकते हैं।" + }, + "onboarding_step1Callout": { + "message": "इस कमांड को चलाने पर Google पर सर्च होगा।" + }, + "onboarding_step1ValueMessage": { + "message": "बस इतना ही — अब आप जो भी टेक्स्ट चुनें, उसे तुरंत सर्च कर सकते हैं।" + }, + "onboarding_nextButton": { + "message": "आगे" + }, + "onboarding_step2Explain": { + "message": "अब, नीचे दिए गए टेक्स्ट के बारे में AI से पूछते हैं।" + }, + "onboarding_step2TargetText": { + "message": "यह एक ऐसा एक्सटेंशन है जो वेब पेज पर चुने गए टेक्स्ट को एक प्रॉम्प्ट टेम्पलेट में डालकर चला सकता है। सोचिए, इससे आप क्या-क्या कर सकते हैं।" + }, + "onboarding_step2Callout": { + "message": "इस कमांड को चलाने पर आपकी चुनी हुई टेक्स्ट AI को भेजी जाएगी।" + }, + "onboarding_step2ValueMessage": { + "message": "अब आप जो पेज पढ़ रहे हैं, वहीं से चुनी हुई किसी भी चीज़ के बारे में AI से पूछ सकते हैं।" + }, + "onboarding_step3Explain": { + "message": "आखिर में, बिना नया टैब खोले किसी लिंक का पूर्वावलोकन करते हैं। नीचे दिए गए लिंक पर Shift दबाकर क्लिक करें।" + }, + "onboarding_step3LinkLabel": { + "message": "नमूना लिंक" + }, + "onboarding_step3ValueMessage": { + "message": "अब आप पेज छोड़े बिना लिंक का पूर्वावलोकन कर सकते हैं।" + }, + "onboarding_step4Explain": { + "message": "अभी आज़माए गए कमांड को आप सेटिंग्स में कभी भी संपादित, हटा या नए जोड़ सकते हैं।" + }, + "onboarding_finishButton": { + "message": "समाप्त करें" + }, + "onboarding_completeTitle": { + "message": "सब तैयार है!" + }, + "onboarding_completeBody": { + "message": "Selection Command उपयोग के लिए तैयार है। शुरू करने के लिए किसी भी पेज पर टेक्स्ट चुनें।" + }, + "onboarding_openSettingsButton": { + "message": "सेटिंग्स खोलें" + }, + "onboarding_closeButton": { + "message": "बंद करें" } } diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index a34ea6d4..f661b163 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1304,5 +1304,71 @@ }, "hub_login": { "message": "Masuk ke Hub" + }, + "onboarding_step0Title": { + "message": "Terima kasih telah menginstal Selection Command!" + }, + "onboarding_step0Body": { + "message": "Mari coba selama sekitar satu menit sambil langsung mempraktikkannya." + }, + "onboarding_startButton": { + "message": "Mulai" + }, + "onboarding_skipButton": { + "message": "Lewati" + }, + "onboarding_step1Explain": { + "message": "Pilih teks di bawah ini untuk langsung mencoba pencarian." + }, + "onboarding_step1TargetText": { + "message": "Dengan Selection Command, Anda bisa mencari, bertanya ke AI, atau melihat pratinjau tautan hanya dengan satu klik." + }, + "onboarding_step1Callout": { + "message": "Klik perintah ini untuk mencari di Google." + }, + "onboarding_step1ValueMessage": { + "message": "Selesai — sekarang Anda bisa langsung mencari apa pun yang Anda pilih." + }, + "onboarding_nextButton": { + "message": "Berikutnya" + }, + "onboarding_step2Explain": { + "message": "Selanjutnya, mari tanyakan ke AI tentang teks di bawah ini." + }, + "onboarding_step2TargetText": { + "message": "Ini adalah ekstensi yang memungkinkan Anda menyematkan teks yang dipilih dari halaman web ke dalam templat prompt dan menjalankannya. Pikirkan beberapa ide tentang apa yang bisa Anda lakukan dengannya." + }, + "onboarding_step2Callout": { + "message": "Klik perintah ini untuk mengirim teks yang dipilih ke AI." + }, + "onboarding_step2ValueMessage": { + "message": "Sekarang Anda bisa bertanya ke AI tentang apa pun yang Anda pilih, langsung dari halaman yang sedang Anda baca." + }, + "onboarding_step3Explain": { + "message": "Terakhir, mari pratinjau sebuah tautan tanpa membuka tab baru. Klik tautan di bawah ini sambil menekan tombol Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Tautan Contoh" + }, + "onboarding_step3ValueMessage": { + "message": "Anda bisa melihat pratinjau tautan tanpa meninggalkan halaman." + }, + "onboarding_step4Explain": { + "message": "Perintah yang baru saja Anda coba dapat diedit, dihapus, atau ditambahkan kapan saja dari Pengaturan." + }, + "onboarding_finishButton": { + "message": "Selesai" + }, + "onboarding_completeTitle": { + "message": "Semua sudah siap!" + }, + "onboarding_completeBody": { + "message": "Selection Command siap digunakan. Cukup pilih teks di halaman mana pun untuk memulai." + }, + "onboarding_openSettingsButton": { + "message": "Buka Pengaturan" + }, + "onboarding_closeButton": { + "message": "Tutup" } } diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index ce36201e..c827008f 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Accedi all'Hub" + }, + "onboarding_step0Title": { + "message": "Grazie per aver installato Selection Command!" + }, + "onboarding_step0Body": { + "message": "Proviamolo per circa un minuto, usandolo direttamente." + }, + "onboarding_startButton": { + "message": "Inizia" + }, + "onboarding_skipButton": { + "message": "Salta" + }, + "onboarding_step1Explain": { + "message": "Seleziona il testo qui sotto per provare subito una ricerca." + }, + "onboarding_step1TargetText": { + "message": "Con Selection Command puoi cercare, chiedere all'IA o visualizzare l'anteprima dei link con un solo clic." + }, + "onboarding_step1Callout": { + "message": "Fai clic su questo comando per cercare su Google." + }, + "onboarding_step1ValueMessage": { + "message": "Fatto: ora puoi cercare all'istante qualsiasi cosa selezioni." + }, + "onboarding_nextButton": { + "message": "Avanti" + }, + "onboarding_step2Explain": { + "message": "Ora chiediamo all'IA il testo qui sotto." + }, + "onboarding_step2TargetText": { + "message": "Questa è un'estensione che ti permette di inserire il testo selezionato da una pagina web in un modello di prompt ed eseguirlo. Pensa a qualche idea su come potresti usarla." + }, + "onboarding_step2Callout": { + "message": "Fai clic su questo comando per inviare la tua selezione all'IA." + }, + "onboarding_step2ValueMessage": { + "message": "Ora puoi chiedere all'IA qualsiasi cosa selezioni, direttamente dalla pagina che stai leggendo." + }, + "onboarding_step3Explain": { + "message": "Infine, visualizziamo l'anteprima di un link senza aprire una nuova scheda. Fai clic sul link qui sotto tenendo premuto Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Link di esempio" + }, + "onboarding_step3ValueMessage": { + "message": "Puoi visualizzare l'anteprima dei link senza lasciare la pagina." + }, + "onboarding_step4Explain": { + "message": "I comandi che hai appena provato possono essere modificati, rimossi o aggiunti in qualsiasi momento dalle impostazioni." + }, + "onboarding_finishButton": { + "message": "Fine" + }, + "onboarding_completeTitle": { + "message": "Tutto pronto!" + }, + "onboarding_completeBody": { + "message": "Selection Command è pronto all'uso: seleziona del testo in qualsiasi pagina per iniziare." + }, + "onboarding_openSettingsButton": { + "message": "Apri impostazioni" + }, + "onboarding_closeButton": { + "message": "Chiudi" } } diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 453c3517..486371d2 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1298,5 +1298,71 @@ }, "hub_login": { "message": "Hubへログイン" + }, + "onboarding_step0Title": { + "message": "Selection Commandのインストール、ありがとうございます!" + }, + "onboarding_step0Body": { + "message": "まずは1分で、実際に使いながら試してみましょう。" + }, + "onboarding_startButton": { + "message": "はじめる" + }, + "onboarding_skipButton": { + "message": "スキップ" + }, + "onboarding_step1Explain": { + "message": "気になるテキストを選択して、すぐに検索してみましょう。" + }, + "onboarding_step1TargetText": { + "message": "Selection Commandを使うと、検索・AIへの質問・リンクのプレビューをワンクリックで実行できます。" + }, + "onboarding_step1Callout": { + "message": "このコマンドを実行すると、Googleで検索できます。" + }, + "onboarding_step1ValueMessage": { + "message": "これで、気になった情報をすぐに検索できました。" + }, + "onboarding_nextButton": { + "message": "次へ" + }, + "onboarding_step2Explain": { + "message": "次は、選択したテキストをAIに聞いてみましょう。" + }, + "onboarding_step2TargetText": { + "message": "Webページ上のテキストをプロンプトテンプレートに埋め込んで実行できる拡張機能で、どんなことが出来るか、アイデアを考えてください。" + }, + "onboarding_step2Callout": { + "message": "このコマンドを実行すると、選択したテキストをAIに送れます。" + }, + "onboarding_step2ValueMessage": { + "message": "Webページを読みながら、その場でAIに質問できました。" + }, + "onboarding_step3Explain": { + "message": "最後に、リンク先をタブを開かずに確認してみましょう。以下のリンクをShift+クリックしてください。" + }, + "onboarding_step3LinkLabel": { + "message": "サンプルリンク" + }, + "onboarding_step3ValueMessage": { + "message": "リンク先をその場で確認できました。" + }, + "onboarding_step4Explain": { + "message": "ここまで試したコマンドは、設定画面からいつでも編集・削除・追加できます。" + }, + "onboarding_finishButton": { + "message": "完了" + }, + "onboarding_completeTitle": { + "message": "準備は完了です!" + }, + "onboarding_completeBody": { + "message": "Selection Commandを使う準備が整いました。ページ上のテキストを選択して、さっそく使ってみましょう。" + }, + "onboarding_openSettingsButton": { + "message": "設定を開く" + }, + "onboarding_closeButton": { + "message": "閉じる" } } diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index c6f89e08..a8922fa8 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Hub에 로그인" + }, + "onboarding_step0Title": { + "message": "Selection Command을 설치해 주셔서 감사합니다!" + }, + "onboarding_step0Body": { + "message": "먼저 1분 동안 직접 사용해보며 체험해 봅시다." + }, + "onboarding_startButton": { + "message": "시작하기" + }, + "onboarding_skipButton": { + "message": "건너뛰기" + }, + "onboarding_step1Explain": { + "message": "아래 텍스트를 선택해서 바로 검색해 보세요." + }, + "onboarding_step1TargetText": { + "message": "Selection Command를 사용하면 검색, AI에게 질문, 링크 미리보기를 클릭 한 번으로 실행할 수 있습니다." + }, + "onboarding_step1Callout": { + "message": "이 명령을 실행하면 Google로 검색할 수 있습니다." + }, + "onboarding_step1ValueMessage": { + "message": "이제 궁금했던 정보를 바로 검색할 수 있게 되었습니다." + }, + "onboarding_nextButton": { + "message": "다음" + }, + "onboarding_step2Explain": { + "message": "다음으로 아래 텍스트에 대해 AI에게 물어봅시다." + }, + "onboarding_step2TargetText": { + "message": "웹페이지의 텍스트를 프롬프트 템플릿에 넣어 실행할 수 있는 확장 프로그램으로 무엇을 할 수 있을지 아이디어를 생각해 보세요." + }, + "onboarding_step2Callout": { + "message": "이 명령을 실행하면 선택한 텍스트를 AI로 보낼 수 있습니다." + }, + "onboarding_step2ValueMessage": { + "message": "이제 페이지를 읽으면서 바로 AI에게 무엇이든 물어볼 수 있습니다." + }, + "onboarding_step3Explain": { + "message": "마지막으로 새 탭을 열지 않고 링크를 미리 볼까요? 아래 링크를 Shift+클릭하세요." + }, + "onboarding_step3LinkLabel": { + "message": "샘플 링크" + }, + "onboarding_step3ValueMessage": { + "message": "페이지를 벗어나지 않고 링크를 미리 볼 수 있습니다." + }, + "onboarding_step4Explain": { + "message": "지금까지 사용해본 명령어는 설정 화면에서 언제든지 편집, 삭제하거나 새로 추가할 수 있습니다." + }, + "onboarding_finishButton": { + "message": "완료" + }, + "onboarding_completeTitle": { + "message": "준비가 완료되었습니다!" + }, + "onboarding_completeBody": { + "message": "Selection Command를 사용할 준비가 되었습니다. 페이지에서 텍스트를 선택해 바로 사용해 보세요." + }, + "onboarding_openSettingsButton": { + "message": "설정 열기" + }, + "onboarding_closeButton": { + "message": "닫기" } } diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 0bb6f011..f58c0377 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1304,5 +1304,71 @@ }, "hub_login": { "message": "Log masuk ke Hub" + }, + "onboarding_step0Title": { + "message": "Terima kasih kerana memasang Selection Command!" + }, + "onboarding_step0Body": { + "message": "Jom cuba selama lebih kurang seminit sambil terus menggunakannya." + }, + "onboarding_startButton": { + "message": "Mula" + }, + "onboarding_skipButton": { + "message": "Langkau" + }, + "onboarding_step1Explain": { + "message": "Pilih teks di bawah untuk cuba carian dengan segera." + }, + "onboarding_step1TargetText": { + "message": "Dengan Selection Command, anda boleh mencari, bertanya kepada AI, atau pratonton pautan hanya dengan satu klik." + }, + "onboarding_step1Callout": { + "message": "Klik arahan ini untuk mencari di Google." + }, + "onboarding_step1ValueMessage": { + "message": "Selesai — kini anda boleh mencari dengan segera apa sahaja yang anda pilih." + }, + "onboarding_nextButton": { + "message": "Seterusnya" + }, + "onboarding_step2Explain": { + "message": "Seterusnya, mari tanya AI tentang teks di bawah ini." + }, + "onboarding_step2TargetText": { + "message": "Ini adalah sambungan yang membolehkan anda menyisipkan teks yang dipilih daripada halaman web ke dalam templat gesaan dan menjalankannya. Fikirkan beberapa idea tentang apa yang boleh anda lakukan dengannya." + }, + "onboarding_step2Callout": { + "message": "Klik arahan ini untuk menghantar pilihan anda kepada AI." + }, + "onboarding_step2ValueMessage": { + "message": "Kini anda boleh bertanya kepada AI tentang apa sahaja yang anda pilih, terus daripada halaman yang sedang anda baca." + }, + "onboarding_step3Explain": { + "message": "Akhir sekali, mari pratonton pautan tanpa membuka tab baharu. Klik pautan di bawah sambil menekan Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Pautan Contoh" + }, + "onboarding_step3ValueMessage": { + "message": "Anda boleh pratonton pautan tanpa meninggalkan halaman." + }, + "onboarding_step4Explain": { + "message": "Perintah yang baru anda cuba boleh diedit, dibuang atau ditambah pada bila-bila masa dari Tetapan." + }, + "onboarding_finishButton": { + "message": "Selesai" + }, + "onboarding_completeTitle": { + "message": "Semuanya sedia!" + }, + "onboarding_completeBody": { + "message": "Selection Command sedia digunakan. Pilih sahaja teks pada mana-mana halaman untuk bermula." + }, + "onboarding_openSettingsButton": { + "message": "Buka Tetapan" + }, + "onboarding_closeButton": { + "message": "Tutup" } } diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 5e865323..131434bf 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1304,5 +1304,71 @@ }, "hub_login": { "message": "Entrar no Hub" + }, + "onboarding_step0Title": { + "message": "Obrigado por instalar o Selection Command!" + }, + "onboarding_step0Body": { + "message": "Vamos experimentar por cerca de um minuto, usando na prática." + }, + "onboarding_startButton": { + "message": "Começar" + }, + "onboarding_skipButton": { + "message": "Pular" + }, + "onboarding_step1Explain": { + "message": "Selecione o texto abaixo para testar uma pesquisa agora mesmo." + }, + "onboarding_step1TargetText": { + "message": "Com o Selection Command, você pode pesquisar, perguntar à IA ou pré-visualizar links com um clique." + }, + "onboarding_step1Callout": { + "message": "Clique neste comando para pesquisar no Google." + }, + "onboarding_step1ValueMessage": { + "message": "Pronto — agora você pode pesquisar instantaneamente qualquer coisa que selecionar." + }, + "onboarding_nextButton": { + "message": "Próximo" + }, + "onboarding_step2Explain": { + "message": "A seguir, vamos perguntar à IA sobre o texto abaixo." + }, + "onboarding_step2TargetText": { + "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página da web em um modelo de prompt e executá-lo. Pense em algumas ideias do que você poderia fazer com ela." + }, + "onboarding_step2Callout": { + "message": "Clique neste comando para enviar sua seleção para a IA." + }, + "onboarding_step2ValueMessage": { + "message": "Agora você pode perguntar à IA sobre qualquer coisa que selecionar, direto da página que está lendo." + }, + "onboarding_step3Explain": { + "message": "Por fim, vamos pré-visualizar um link sem abrir uma nova aba. Clique no link abaixo segurando a tecla Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Link de exemplo" + }, + "onboarding_step3ValueMessage": { + "message": "Você pode pré-visualizar links sem sair da página." + }, + "onboarding_step4Explain": { + "message": "Os comandos que você experimentou podem ser editados, removidos ou adicionados a qualquer momento nas configurações." + }, + "onboarding_finishButton": { + "message": "Concluir" + }, + "onboarding_completeTitle": { + "message": "Tudo pronto!" + }, + "onboarding_completeBody": { + "message": "O Selection Command está pronto para uso. Basta selecionar um texto em qualquer página para começar." + }, + "onboarding_openSettingsButton": { + "message": "Abrir configurações" + }, + "onboarding_closeButton": { + "message": "Fechar" } } diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index c57367cc..02f47cbd 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1304,5 +1304,71 @@ }, "hub_login": { "message": "Iniciar sessão no Hub" + }, + "onboarding_step0Title": { + "message": "Obrigado por instalar o Selection Command!" + }, + "onboarding_step0Body": { + "message": "Vamos experimentá-lo durante cerca de um minuto, na prática." + }, + "onboarding_startButton": { + "message": "Começar" + }, + "onboarding_skipButton": { + "message": "Ignorar" + }, + "onboarding_step1Explain": { + "message": "Selecione o texto abaixo para experimentar uma pesquisa de imediato." + }, + "onboarding_step1TargetText": { + "message": "Com o Selection Command, pode pesquisar, perguntar à IA ou pré-visualizar links com um único clique." + }, + "onboarding_step1Callout": { + "message": "Clique neste comando para pesquisar no Google." + }, + "onboarding_step1ValueMessage": { + "message": "Pronto — agora já pode pesquisar instantaneamente tudo o que selecionar." + }, + "onboarding_nextButton": { + "message": "Seguinte" + }, + "onboarding_step2Explain": { + "message": "De seguida, vamos perguntar à IA sobre o texto abaixo." + }, + "onboarding_step2TargetText": { + "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página web num modelo de prompt e executá-lo. Pense em algumas ideias para o que poderia usá-la." + }, + "onboarding_step2Callout": { + "message": "Clique neste comando para enviar a sua seleção para a IA." + }, + "onboarding_step2ValueMessage": { + "message": "Agora já pode perguntar à IA sobre o que selecionar, diretamente a partir da página que está a ler." + }, + "onboarding_step3Explain": { + "message": "Por fim, vamos pré-visualizar uma ligação sem abrir um novo separador. Clique na ligação abaixo enquanto mantém premida a tecla Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Ligação de exemplo" + }, + "onboarding_step3ValueMessage": { + "message": "Pode pré-visualizar ligações sem sair da página." + }, + "onboarding_step4Explain": { + "message": "Os comandos que acabou de experimentar podem ser editados, removidos ou adicionados a qualquer momento nas definições." + }, + "onboarding_finishButton": { + "message": "Concluir" + }, + "onboarding_completeTitle": { + "message": "Tudo pronto!" + }, + "onboarding_completeBody": { + "message": "O Selection Command está pronto a usar. Basta selecionar texto em qualquer página para começar." + }, + "onboarding_openSettingsButton": { + "message": "Abrir definições" + }, + "onboarding_closeButton": { + "message": "Fechar" } } diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 6c25f653..47b3f523 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "Войти в Hub" + }, + "onboarding_step0Title": { + "message": "Спасибо за установку Selection Command!" + }, + "onboarding_step0Body": { + "message": "Давайте потратим примерно минуту, чтобы попробовать его в деле." + }, + "onboarding_startButton": { + "message": "Начать" + }, + "onboarding_skipButton": { + "message": "Пропустить" + }, + "onboarding_step1Explain": { + "message": "Выделите текст ниже, чтобы сразу попробовать поиск." + }, + "onboarding_step1TargetText": { + "message": "С Selection Command вы можете искать, спрашивать ИИ или просматривать ссылки одним щелчком." + }, + "onboarding_step1Callout": { + "message": "Нажмите эту команду, чтобы выполнить поиск в Google." + }, + "onboarding_step1ValueMessage": { + "message": "Готово — теперь вы можете мгновенно искать всё, что выделите." + }, + "onboarding_nextButton": { + "message": "Далее" + }, + "onboarding_step2Explain": { + "message": "Далее давайте спросим ИИ о тексте ниже." + }, + "onboarding_step2TargetText": { + "message": "Это расширение позволяет вставлять выделенный текст с веб-страницы в шаблон запроса и выполнять его. Подумайте, для чего это можно использовать." + }, + "onboarding_step2Callout": { + "message": "Нажмите эту команду, чтобы отправить выделенный текст в ИИ." + }, + "onboarding_step2ValueMessage": { + "message": "Теперь вы можете спрашивать ИИ о чём угодно прямо на странице, которую читаете." + }, + "onboarding_step3Explain": { + "message": "Наконец, давайте просмотрим ссылку без открытия новой вкладки. Щёлкните по ссылке ниже, удерживая Shift." + }, + "onboarding_step3LinkLabel": { + "message": "Пример ссылки" + }, + "onboarding_step3ValueMessage": { + "message": "Вы можете просматривать ссылки, не покидая страницу." + }, + "onboarding_step4Explain": { + "message": "Команды, которые вы только что попробовали, можно в любой момент изменить, удалить или добавить новые в настройках." + }, + "onboarding_finishButton": { + "message": "Готово" + }, + "onboarding_completeTitle": { + "message": "Всё готово!" + }, + "onboarding_completeBody": { + "message": "Selection Command готов к использованию — просто выделите текст на любой странице, чтобы начать." + }, + "onboarding_openSettingsButton": { + "message": "Открыть настройки" + }, + "onboarding_closeButton": { + "message": "Закрыть" } } diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index f9006916..41b5c958 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1301,5 +1301,71 @@ }, "hub_login": { "message": "登录 Hub" + }, + "onboarding_step0Title": { + "message": "感谢您安装 Selection Command!" + }, + "onboarding_step0Body": { + "message": "让我们花一分钟时间,边操作边体验一下吧。" + }, + "onboarding_startButton": { + "message": "开始体验" + }, + "onboarding_skipButton": { + "message": "跳过" + }, + "onboarding_step1Explain": { + "message": "选择下面的文本,立即试试搜索功能。" + }, + "onboarding_step1TargetText": { + "message": "使用 Selection Command,只需一键即可搜索、询问 AI 或预览链接。" + }, + "onboarding_step1Callout": { + "message": "点击此命令即可使用 Google 搜索。" + }, + "onboarding_step1ValueMessage": { + "message": "就是这样——现在您可以立即搜索所选的任何内容。" + }, + "onboarding_nextButton": { + "message": "下一步" + }, + "onboarding_step2Explain": { + "message": "接下来,让我们向 AI 询问下面这段文字。" + }, + "onboarding_step2TargetText": { + "message": "这是一个可以将网页上选中的文字嵌入提示词模板并执行的扩展程序,想想它能帮你做些什么。" + }, + "onboarding_step2Callout": { + "message": "点击此命令即可将所选文字发送给 AI。" + }, + "onboarding_step2ValueMessage": { + "message": "现在,您可以在阅读网页的同时,就您选择的任何内容向 AI 提问。" + }, + "onboarding_step3Explain": { + "message": "最后,让我们在不打开新标签页的情况下预览链接。请按住 Shift 键点击下面的链接。" + }, + "onboarding_step3LinkLabel": { + "message": "示例链接" + }, + "onboarding_step3ValueMessage": { + "message": "您可以在不离开当前页面的情况下预览链接。" + }, + "onboarding_step4Explain": { + "message": "您刚刚体验的命令,随时可以在设置页面中编辑、删除或添加新的命令。" + }, + "onboarding_finishButton": { + "message": "完成" + }, + "onboarding_completeTitle": { + "message": "一切就绪!" + }, + "onboarding_completeBody": { + "message": "Selection Command 已准备就绪。选中页面上的任意文本即可开始使用。" + }, + "onboarding_openSettingsButton": { + "message": "打开设置" + }, + "onboarding_closeButton": { + "message": "关闭" } } diff --git a/packages/extension/src/action/executor.test.ts b/packages/extension/src/action/executor.test.ts index faf04849..9880af77 100644 --- a/packages/extension/src/action/executor.test.ts +++ b/packages/extension/src/action/executor.test.ts @@ -69,4 +69,26 @@ describe("executeAction", () => { }), ) }) + + it("dispatches an onboarding:command-executed window event", async () => { + const execute = vi.fn().mockResolvedValue("ok") + const actions = { + [OPEN_MODE.TAB]: { execute }, + } as Record Promise }> + const dispatchSpy = vi.spyOn(window, "dispatchEvent") + + await executeAction({ + actions, + command: { id: "cmd-3", openMode: OPEN_MODE.TAB } as any, + position: null, + selectionText: "text", + }) + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "onboarding:command-executed", + detail: { commandId: "cmd-3", commandType: OPEN_MODE.TAB }, + }), + ) + }) }) diff --git a/packages/extension/src/action/executor.ts b/packages/extension/src/action/executor.ts index 52fcddac..d64a2eaa 100644 --- a/packages/extension/src/action/executor.ts +++ b/packages/extension/src/action/executor.ts @@ -2,6 +2,7 @@ import type { ExecuteCommandParams } from "@/types" import { OPEN_MODE } from "@/const" import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" import { resolveCommandSource } from "@/services/commandSource" +import { dispatchCommandExecuted } from "@/components/onboarding/onboardingEvents" export async function executeAction({ actions, @@ -43,5 +44,7 @@ export async function executeAction({ source_id: sourceId, }) + dispatchCommandExecuted({ commandId: command.id, commandType: mode }) + return res } diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 8d0d4a86..8384d7ba 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -622,6 +622,52 @@ describe("onInstalled: installed analytics event", () => { expect.anything(), ) }) + + it("IN-03: opens the onboarding page tab when reason is install", async () => { + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: vi.fn(), + getOrCreateClientId: vi.fn().mockResolvedValue("test-client-id"), + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.INSTALL, + }) + + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: "src/onboarding_page.html", + }) + }) + + it("IN-04: does not open the onboarding page tab when reason is update", async () => { + vi.doMock("@/services/analytics", () => ({ + ANALYTICS_EVENTS: { INSTALLED: "installed" }, + sendEvent: vi.fn(), + getOrCreateClientId: vi.fn().mockResolvedValue("test-client-id"), + })) + + vi.resetModules() + await import("./background_script") + + const listenerCalls = (chrome.runtime.onInstalled.addListener as any).mock + .calls + const onInstalledListener = listenerCalls[0][0] + + await onInstalledListener({ + reason: chrome.runtime.OnInstalledReason.UPDATE, + }) + + expect(chrome.tabs.create).not.toHaveBeenCalledWith( + expect.objectContaining({ url: "src/onboarding_page.html" }), + ) + }) }) describe("Uninstall URL (onInstalled)", () => { diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 6994bbfa..5e3d0bb7 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -1,6 +1,7 @@ import { isDebug, OPTION_PAGE_PATH, + ONBOARDING_PAGE_PATH, SHORTCUT_NO_SELECTION_BEHAVIOR, NEW_HUB_URL, SCREEN, @@ -445,6 +446,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { await Settings.reset() sendEvent(ANALYTICS_EVENTS.INSTALLED, {}, SCREEN.SERVICE_WORKER) + chrome.tabs.create({ url: ONBOARDING_PAGE_PATH }) } await ContextMenu.init() diff --git a/packages/extension/src/components/menu/MenuItem.tsx b/packages/extension/src/components/menu/MenuItem.tsx index 070acba1..0c64df92 100644 --- a/packages/extension/src/components/menu/MenuItem.tsx +++ b/packages/extension/src/components/menu/MenuItem.tsx @@ -66,6 +66,7 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { )} role="menuitem" aria-label={title} + data-command-id={props.command.id} ref={buttonRef} onClick={handleClick} disabled={!enabled} diff --git a/packages/extension/src/components/onboarding/Onboarding.module.css b/packages/extension/src/components/onboarding/Onboarding.module.css new file mode 100644 index 00000000..00ba6685 --- /dev/null +++ b/packages/extension/src/components/onboarding/Onboarding.module.css @@ -0,0 +1,32 @@ +.fadeIn { + animation: 0.35s onboardingFadeInUp forwards; +} + +@keyframes onboardingFadeInUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.spotlight { + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55); + border-radius: 6px; + animation: 1.8s onboardingPulse ease-in-out infinite; +} + +@keyframes onboardingPulse { + 0%, + 100% { + outline: 2px solid rgba(255, 255, 255, 0.9); + outline-offset: 2px; + } + 50% { + outline: 2px solid rgba(255, 255, 255, 0.35); + outline-offset: 4px; + } +} diff --git a/packages/extension/src/components/onboarding/OnboardingCallout.tsx b/packages/extension/src/components/onboarding/OnboardingCallout.tsx new file mode 100644 index 00000000..da3825a2 --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingCallout.tsx @@ -0,0 +1,49 @@ +import { ReactNode } from "react" +import { + Popover, + PopoverContent, + PopoverAnchor, + PopoverArrow, +} from "@/components/ui/popover" +import { SIDE, ALIGN } from "@/const" + +type Props = { + // The element the callout should point at (e.g. a specific command button + // found via `[data-command-id]`, see MenuItem.tsx). + targetElm: Element | null + open: boolean + side?: SIDE + align?: ALIGN + children: ReactNode +} + +// A speech-bubble style callout anchored to an arbitrary DOM element, +// externally controlled via `open` (unlike Tooltip.tsx, which is +// hover-triggered and used throughout the regular popup menu - reusing it +// here would risk regressing that shared, frequently-exercised component). +export function OnboardingCallout({ + targetElm, + open, + side = SIDE.bottom, + align = ALIGN.center, + children, +}: Props) { + if (!targetElm) return null + + return ( + + + {open && ( + +

{children}

+ +
+ )} +
+ ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx new file mode 100644 index 00000000..e096b19a --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react" +import clsx from "clsx" +import css from "./Onboarding.module.css" + +type Props = { + children: ReactNode + className?: string +} + +// Wraps a block of onboarding explanation text so it fades in from slightly +// below, per the PRD's "説明テキストは少し下からフェードイン" rule. Uses a +// `key`-less CSS animation (re-triggered by React remounting the element, +// e.g. when the step/phase changes) rather than a JS animation library. +export function OnboardingFadeIn({ children, className }: Props) { + return
{children}
+} diff --git a/packages/extension/src/components/onboarding/OnboardingOverlay.tsx b/packages/extension/src/components/onboarding/OnboardingOverlay.tsx new file mode 100644 index 00000000..14938fad --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingOverlay.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState, RefObject } from "react" +import css from "./Onboarding.module.css" + +type Props = { + // The element to highlight. When null/undefined, nothing is rendered. + targetRef: RefObject | null +} + +// Dims the rest of the page and slowly pulses an outline around the target +// element, per the PRD's "背景を少し暗くする / ゆっくり明滅させる" rule for +// the text/link the user is meant to select next. +export function OnboardingOverlay({ targetRef }: Props) { + const [rect, setRect] = useState(null) + + useEffect(() => { + const target = targetRef?.current + if (!target) { + setRect(null) + return + } + + const update = () => setRect(target.getBoundingClientRect()) + update() + + // Layout can shift from page content loading/reflowing; a lightweight + // poll is simpler and safer here than a ResizeObserver + MutationObserver + // combo for a short-lived onboarding overlay. + const interval = window.setInterval(update, 300) + window.addEventListener("scroll", update, true) + window.addEventListener("resize", update) + return () => { + window.clearInterval(interval) + window.removeEventListener("scroll", update, true) + window.removeEventListener("resize", update) + } + }, [targetRef]) + + if (!rect) return null + + return ( +
+ ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingPage.tsx b/packages/extension/src/components/onboarding/OnboardingPage.tsx new file mode 100644 index 00000000..8e30bd32 --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingPage.tsx @@ -0,0 +1,50 @@ +import { useState } from "react" +import { SelectContextProvider } from "@/providers/SelectContextProvider" +import { SelectAnchor } from "@/components/SelectAnchor" +import { Popup } from "@/components/Popup" +import { LinkSelector } from "@/components/LinkSelector" +import { useOnboardingState } from "./useOnboardingState" +import { StepIntro } from "./steps/StepIntro" +import { StepSearchCommand } from "./steps/StepSearchCommand" +import { StepAiPromptCommand } from "./steps/StepAiPromptCommand" +import { StepLinkPreview } from "./steps/StepLinkPreview" +import { StepCustomize } from "./steps/StepCustomize" +import { StepComplete } from "./steps/StepComplete" +import { OnboardingStep } from "@/types/onboarding" + +import "@/components/App.css" + +export function OnboardingPage() { + const onboarding = useOnboardingState() + const [positionElm, setPositionElm] = useState(null) + const [isHover, setIsHover] = useState(false) + + return ( + +
+ {onboarding.step === OnboardingStep.INTRO && ( + + )} + {onboarding.step === OnboardingStep.SEARCH && ( + + )} + {onboarding.step === OnboardingStep.AI_PROMPT && ( + + )} + {onboarding.step === OnboardingStep.LINK_PREVIEW && ( + + )} + {onboarding.step === OnboardingStep.CUSTOMIZE && ( + + )} + {onboarding.step === OnboardingStep.COMPLETE && ( + + )} +
+ + + + +
+ ) +} diff --git a/packages/extension/src/components/onboarding/onboardingAnalytics.ts b/packages/extension/src/components/onboarding/onboardingAnalytics.ts new file mode 100644 index 00000000..d9ed34e9 --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingAnalytics.ts @@ -0,0 +1,12 @@ +import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" +import { SCREEN } from "@/const" + +// Thin wrapper so step components never call sendEvent directly - keeps the +// SCREEN.ONBOARDING tag and event names centralized in one place. +export function sendOnboardingEvent( + name: (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any = {}, +) { + sendEvent(name, params, SCREEN.ONBOARDING) +} diff --git a/packages/extension/src/components/onboarding/onboardingCommand.ts b/packages/extension/src/components/onboarding/onboardingCommand.ts new file mode 100644 index 00000000..fcf916e5 --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingCommand.ts @@ -0,0 +1,36 @@ +import { OPEN_MODE, POPUP_OPTION } from "@/const" +import type { AiPromptCommand } from "@/types" + +// Fixed id for the onboarding's AiPrompt command, so the onboarding UI can +// reliably point a callout at this specific menu button +// (see MenuItem.tsx's `data-command-id` attribute) regardless of locale. +export const ONBOARDING_AI_PROMPT_COMMAND_ID = + "8b6f2e10-9a44-5c7d-8b3f-2e6a7c1d4f90" + +// Creates the AiPrompt command used in Step2 of the onboarding. It is added +// as a normal, permanent default command (not removed after onboarding +// finishes) so returning users can keep using it. The prompt simply forwards +// the selected text, so users can see their selection reflected back +// immediately without needing to understand prompt engineering. +export function createOnboardingAiPromptCommand( + title: string, + prompt: string, + iconUrl: string, +): AiPromptCommand { + return { + id: ONBOARDING_AI_PROMPT_COMMAND_ID, + revision: 0, + title, + iconUrl, + openMode: OPEN_MODE.AI_PROMPT, + aiPromptOption: { + serviceId: "gemini", + prompt, + openMode: OPEN_MODE.SIDE_PANEL, + }, + popupOption: { + width: POPUP_OPTION.width, + height: POPUP_OPTION.height, + }, + } +} diff --git a/packages/extension/src/components/onboarding/onboardingEvents.ts b/packages/extension/src/components/onboarding/onboardingEvents.ts new file mode 100644 index 00000000..ac32e8a6 --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingEvents.ts @@ -0,0 +1,42 @@ +import { OPEN_MODE, DRAG_OPEN_MODE } from "@/const" + +// Fired on `window` whenever a command (menu command or link preview) is +// executed, regardless of whether the onboarding page is open. This keeps +// the onboarding step-progression logic decoupled from the core execution +// code paths (executor.ts / useDetectLinkCommand.ts) - those callers only +// need to dispatch the event, they don't know or care who's listening. +export const ONBOARDING_EVENT = { + COMMAND_EXECUTED: "onboarding:command-executed", +} as const + +export type OnboardingCommandExecutedDetail = { + commandId: string + commandType: OPEN_MODE | DRAG_OPEN_MODE +} + +export function dispatchCommandExecuted( + detail: OnboardingCommandExecutedDetail, +): void { + // executeAction() (the sole caller) also runs in the service worker + // (background.ts), where `window` does not exist. No-op there; the + // onboarding page always runs in a normal window context. + if (typeof window === "undefined") return + + window.dispatchEvent( + new CustomEvent( + ONBOARDING_EVENT.COMMAND_EXECUTED, + { detail }, + ), + ) +} + +export function subscribeCommandExecuted( + listener: (detail: OnboardingCommandExecutedDetail) => void, +): () => void { + const handler = (e: Event) => { + listener((e as CustomEvent).detail) + } + window.addEventListener(ONBOARDING_EVENT.COMMAND_EXECUTED, handler) + return () => + window.removeEventListener(ONBOARDING_EVENT.COMMAND_EXECUTED, handler) +} diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx new file mode 100644 index 00000000..89f483d3 --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from "react" +import { t } from "@/services/i18n" +import { useSelectContext } from "@/hooks/useSelectContext" +import { isEmpty } from "@/lib/utils" +import { ONBOARDING_AI_PROMPT_COMMAND_ID } from "../onboardingCommand" +import { subscribeCommandExecuted } from "../onboardingEvents" +import { OnboardingOverlay } from "../OnboardingOverlay" +import { OnboardingCallout } from "../OnboardingCallout" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingStep, StepPhase } from "@/types/onboarding" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +// Step2: run the onboarding's dedicated AiPrompt command (see +// onboardingCommand.ts), which opens the side panel - unlike Step1's search, +// the onboarding page never loses visibility here, so there's no separate +// "wait for the user to come back" phase. +export function StepAiPromptCommand({ onboarding }: Props) { + const { phase, setPhase } = onboarding + const { selectionText } = useSelectContext() + const targetTextRef = useRef(null) + const [calloutElm, setCalloutElm] = useState(null) + + useEffect(() => { + if (phase !== StepPhase.EXPLAIN) return + if (isEmpty(selectionText)) return + onboarding.recordFirstSelection(OnboardingStep.AI_PROMPT) + setPhase(StepPhase.WAIT_EXECUTE) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, selectionText]) + + useEffect(() => { + if (phase !== StepPhase.WAIT_EXECUTE) { + setCalloutElm(null) + return + } + let cancelled = false + const find = () => { + if (cancelled) return + const elm = document.querySelector( + `[data-command-id="${ONBOARDING_AI_PROMPT_COMMAND_ID}"]`, + ) + if (elm) { + setCalloutElm(elm) + } else { + window.setTimeout(find, 150) + } + } + find() + return () => { + cancelled = true + } + }, [phase]) + + useEffect(() => { + if (phase !== StepPhase.WAIT_EXECUTE) return + return subscribeCommandExecuted(({ commandId, commandType }) => { + if (commandId !== ONBOARDING_AI_PROMPT_COMMAND_ID) return + onboarding.recordCommandExecuted(OnboardingStep.AI_PROMPT, commandType) + setPhase(StepPhase.VALUE_SHOWN) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]) + + return ( +
+ + {phase === StepPhase.VALUE_SHOWN ? ( +
+

+ {t("onboarding_step2ValueMessage")} +

+ +
+ ) : ( +

+ {t("onboarding_step2Explain")} +

+ )} +
+ + {phase !== StepPhase.VALUE_SHOWN && ( + + {t("onboarding_step2TargetText")} + + )} + + {phase === StepPhase.EXPLAIN && ( + + )} + + + {t("onboarding_step2Callout")} + + + +
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepComplete.tsx b/packages/extension/src/components/onboarding/steps/StepComplete.tsx new file mode 100644 index 00000000..cde074e2 --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepComplete.tsx @@ -0,0 +1,44 @@ +import { useEffect } from "react" +import { t } from "@/services/i18n" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +// Step5: the final, EXPLAIN-only screen. Marks the onboarding as complete +// (hasShownOnboarding=true, ONBOARDING_COMPLETE analytics event) as soon as +// it's reached, since there's no further step to skip out of. +export function StepComplete({ onboarding }: Props) { + const { complete } = onboarding + + useEffect(() => { + complete() + }, [complete]) + + return ( + +

{t("onboarding_completeTitle")}

+

+ {t("onboarding_completeBody")} +

+
+ + +
+
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepCustomize.tsx b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx new file mode 100644 index 00000000..64da23e8 --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx @@ -0,0 +1,36 @@ +import { t } from "@/services/i18n" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingStep } from "@/types/onboarding" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +// Step4: EXPLAIN-only wrap-up telling the user the sample commands seen in +// Steps 1-3 can be edited, removed, or added to from the settings page. +export function StepCustomize({ onboarding }: Props) { + return ( + +

+ {t("onboarding_step4Explain")} +

+
+ + +
+
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepIntro.tsx b/packages/extension/src/components/onboarding/steps/StepIntro.tsx new file mode 100644 index 00000000..bb8f928f --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepIntro.tsx @@ -0,0 +1,36 @@ +import { t } from "@/services/i18n" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingStep } from "@/types/onboarding" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +// Step0: the landing screen shown right after install. +export function StepIntro({ onboarding }: Props) { + return ( + +

{t("onboarding_step0Title")}

+

+ {t("onboarding_step0Body")} +

+
+ + +
+
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx new file mode 100644 index 00000000..453ebc2c --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react" +import { t } from "@/services/i18n" +import { DRAG_OPEN_MODE } from "@/const" +import { subscribeCommandExecuted } from "../onboardingEvents" +import { OnboardingOverlay } from "../OnboardingOverlay" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingStep, StepPhase } from "@/types/onboarding" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +const DRAG_OPEN_MODES: readonly string[] = Object.values(DRAG_OPEN_MODE) + +// Step3: Shift+click the sample link to trigger Link Preview. Unlike +// Steps 1-2 this isn't triggered via text selection, so it only ever uses +// the EXPLAIN and VALUE_SHOWN phases. +export function StepLinkPreview({ onboarding }: Props) { + const { phase, setPhase } = onboarding + const linkRef = useRef(null) + + useEffect(() => { + if (phase !== StepPhase.EXPLAIN) return + return subscribeCommandExecuted(({ commandType }) => { + if (!DRAG_OPEN_MODES.includes(commandType)) return + onboarding.recordCommandExecuted(OnboardingStep.LINK_PREVIEW, commandType) + setPhase(StepPhase.VALUE_SHOWN) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]) + + return ( +
+ + {phase === StepPhase.VALUE_SHOWN ? ( +
+

+ {t("onboarding_step3ValueMessage")} +

+ +
+ ) : ( +

+ {t("onboarding_step3Explain")} +

+ )} +
+ + {phase !== StepPhase.VALUE_SHOWN && ( + e.preventDefault()} + > + {t("onboarding_step3LinkLabel")} + + )} + + {phase === StepPhase.EXPLAIN && } + + +
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx new file mode 100644 index 00000000..7822f5b2 --- /dev/null +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react" +import { t } from "@/services/i18n" +import { useSelectContext } from "@/hooks/useSelectContext" +import { useSection } from "@/hooks/useSettings" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { isEmpty } from "@/lib/utils" +import { OPEN_MODE_TYPE_MAP, COMMAND_TYPE } from "@/const" +import { subscribeCommandExecuted } from "../onboardingEvents" +import { OnboardingOverlay } from "../OnboardingOverlay" +import { OnboardingCallout } from "../OnboardingCallout" +import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingStep, StepPhase } from "@/types/onboarding" +import type { UseOnboardingState } from "../useOnboardingState" + +type Props = { + onboarding: UseOnboardingState +} + +// Step1: the user's first hands-on experience - select the sample text, +// see the real popup menu, run the (default) Google search command, and +// come back to see the value message. This is the PRD's "First Value". +export function StepSearchCommand({ onboarding }: Props) { + const { phase, setPhase } = onboarding + const { selectionText } = useSelectContext() + const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) + const targetTextRef = useRef(null) + const [calloutElm, setCalloutElm] = useState(null) + + const googleCommand = commands?.find((c) => c.title === "Google") + + // EXPLAIN -> WAIT_EXECUTE: once the user selects the sample text, the real + // popup menu opens on its own (default startup method is text selection). + useEffect(() => { + if (phase !== StepPhase.EXPLAIN) return + if (isEmpty(selectionText)) return + onboarding.recordFirstSelection(OnboardingStep.SEARCH) + setPhase(StepPhase.WAIT_EXECUTE) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, selectionText]) + + // Find the rendered Google command button (see MenuItem.tsx's + // `data-command-id`) to point the callout at, once the menu has had a + // chance to render. + useEffect(() => { + if (phase !== StepPhase.WAIT_EXECUTE || !googleCommand) { + setCalloutElm(null) + return + } + let cancelled = false + const find = () => { + if (cancelled) return + const elm = document.querySelector( + `[data-command-id="${googleCommand.id}"]`, + ) + if (elm) { + setCalloutElm(elm) + } else { + window.setTimeout(find, 150) + } + } + find() + return () => { + cancelled = true + } + }, [phase, googleCommand]) + + // WAIT_EXECUTE -> WAIT_RETURN: any search-type command counts (the + // callout points at Google specifically, but the default set has several + // search commands - being lenient here matches the PRD's "選択→コマンド→ + // 検索" mental model rather than requiring one exact command). + useEffect(() => { + if (phase !== StepPhase.WAIT_EXECUTE) return + return subscribeCommandExecuted(({ commandType }) => { + onboarding.recordCommandExecuted(OnboardingStep.SEARCH, commandType) + if ( + OPEN_MODE_TYPE_MAP[commandType as keyof typeof OPEN_MODE_TYPE_MAP] === + COMMAND_TYPE.SEARCH + ) { + onboarding.recordFirstValue() + setPhase(StepPhase.WAIT_RETURN) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]) + + // WAIT_RETURN -> VALUE_SHOWN: the search command opens a separate + // popup/tab, so "coming back" is detected via the onboarding tab + // regaining visibility. + useEffect(() => { + if (phase !== StepPhase.WAIT_RETURN) return + const onVisible = () => { + if (document.visibilityState === "visible") { + setPhase(StepPhase.VALUE_SHOWN) + } + } + document.addEventListener("visibilitychange", onVisible) + return () => document.removeEventListener("visibilitychange", onVisible) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]) + + return ( +
+ + {phase === StepPhase.VALUE_SHOWN ? ( +
+

+ {t("onboarding_step1ValueMessage")} +

+ +
+ ) : ( +

+ {t("onboarding_step1Explain")} +

+ )} +
+ + {phase !== StepPhase.VALUE_SHOWN && ( + + {t("onboarding_step1TargetText")} + + )} + + {phase === StepPhase.EXPLAIN && ( + + )} + + + {t("onboarding_step1Callout")} + + + +
+ ) +} diff --git a/packages/extension/src/components/onboarding/useOnboardingState.ts b/packages/extension/src/components/onboarding/useOnboardingState.ts new file mode 100644 index 00000000..def34411 --- /dev/null +++ b/packages/extension/src/components/onboarding/useOnboardingState.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { OnboardingStep, StepPhase } from "@/types/onboarding" +import { ANALYTICS_EVENTS } from "@/services/analytics" +import { sendOnboardingEvent } from "./onboardingAnalytics" +import { Settings } from "@/services/settings/settings" +import { getCurrentLocale } from "@/services/i18n" +import { VERSION } from "@/const" + +export type UseOnboardingState = ReturnType + +export function useOnboardingState() { + const [step, setStep] = useState(OnboardingStep.INTRO) + const [phase, setPhase] = useState(StepPhase.EXPLAIN) + const startedAtRef = useRef(Date.now()) + const firstValueSentRef = useRef(false) + const seenSelectionStepsRef = useRef>(new Set()) + const finishedRef = useRef(false) + + useEffect(() => { + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_START, { + locale: getCurrentLocale(), + extension_version: VERSION, + }) + }, []) + + const goToStep = useCallback((next: OnboardingStep) => { + setStep(next) + setPhase(StepPhase.EXPLAIN) + }, []) + + // Records the first text/link selection observed within a given step. + // No-ops on subsequent selections within the same step. + const recordFirstSelection = useCallback((forStep: OnboardingStep) => { + if (seenSelectionStepsRef.current.has(forStep)) return + seenSelectionStepsRef.current.add(forStep) + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_FIRST_SELECTION, { + step: forStep, + }) + }, []) + + const recordCommandExecuted = useCallback( + (forStep: OnboardingStep, commandType: string) => { + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_COMMAND_EXECUTE, { + command_type: commandType, + step: forStep, + }) + }, + [], + ) + + // The first successful Search command execution (Step1) is treated as + // "First Value" per the PRD, regardless of what happens in later steps. + const recordFirstValue = useCallback(() => { + if (firstValueSentRef.current) return + firstValueSentRef.current = true + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_FIRST_VALUE) + }, []) + + const markFinished = useCallback(() => { + if (finishedRef.current) return + finishedRef.current = true + // Never show the onboarding tab again after this run, whether the user + // completed it or skipped partway through. + Settings.update("hasShownOnboarding", () => true) + }, []) + + const skip = useCallback(() => { + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_SKIP, { step }) + markFinished() + }, [step, markFinished]) + + const complete = useCallback(() => { + sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_COMPLETE, { + completion_time: Date.now() - startedAtRef.current, + }) + markFinished() + }, [markFinished]) + + return { + step, + phase, + setPhase, + goToStep, + skip, + complete, + recordFirstSelection, + recordCommandExecuted, + recordFirstValue, + } +} diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index c894f5d1..3b9b218a 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -270,6 +270,7 @@ export function ImportExport() { hasShownReviewRequest = false, hasDismissedPromptHistoryBanner = false, hasShownHubShareToast = false, + hasShownOnboarding = false, } = await enhancedSettings.get() const data = await migrate({ ...importJson, @@ -277,6 +278,7 @@ export function ImportExport() { hasShownReviewRequest, hasDismissedPromptHistoryBanner, hasShownHubShareToast, + hasShownOnboarding, stars: [], }) await Settings.set(data) diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 3fc9ffd7..799bdba4 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -271,6 +271,7 @@ export enum SCREEN { SERVICE_WORKER = "ServiceWorker", COMMAND_HUB = "CommandHub", COMMAND_FORM = "CommandForm", + ONBOARDING = "Onboarding", } export const POPUP_OPTION = { @@ -384,6 +385,8 @@ export const PAGE_HTML_MAX_CHARS = 1_000_000 export const OPTION_PAGE_PATH = "src/options_page.html" +export const ONBOARDING_PAGE_PATH = "src/onboarding_page.html" + export const COMMAND_USAGE = { REVIEW_THRESHOLD: 100, REVIEW_INTERVAL: 50, diff --git a/packages/extension/src/hooks/useDetectLinkCommand.ts b/packages/extension/src/hooks/useDetectLinkCommand.ts index 9ca6965a..0e2f2774 100644 --- a/packages/extension/src/hooks/useDetectLinkCommand.ts +++ b/packages/extension/src/hooks/useDetectLinkCommand.ts @@ -21,6 +21,7 @@ import { } from "@/services/dom" import { getScreenSize } from "@/services/screen" import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" +import { dispatchCommandExecuted } from "@/components/onboarding/onboardingEvents" const isTargetEvent = (e: MouseEvent): boolean => { return ( @@ -78,6 +79,10 @@ export function useDetectLinkCommand(): DetectLinkCommandReturn { target, }) sendEvent(ANALYTICS_EVENTS.LINK_COMMAND, { event_label: "link_preview" }) + dispatchCommandExecuted({ + commandId: command.id, + commandType: command.openMode, + }) }, [command], ) diff --git a/packages/extension/src/hooks/useSettings.test.tsx b/packages/extension/src/hooks/useSettings.test.tsx index 15888080..a8d8d46b 100644 --- a/packages/extension/src/hooks/useSettings.test.tsx +++ b/packages/extension/src/hooks/useSettings.test.tsx @@ -376,6 +376,7 @@ describe("useSettings hooks", () => { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) @@ -696,6 +697,7 @@ describe("useSettings hooks", () => { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, } as SettingsType mockEnhancedSettings.getSection.mockResolvedValueOnce(mockSettings) diff --git a/packages/extension/src/onboarding_page.html b/packages/extension/src/onboarding_page.html new file mode 100644 index 00000000..44054244 --- /dev/null +++ b/packages/extension/src/onboarding_page.html @@ -0,0 +1,15 @@ + + + + + + + Onboarding + + + + +
+ + + diff --git a/packages/extension/src/onboarding_page.tsx b/packages/extension/src/onboarding_page.tsx new file mode 100644 index 00000000..65c8c593 --- /dev/null +++ b/packages/extension/src/onboarding_page.tsx @@ -0,0 +1,32 @@ +import React from "react" +import ReactDOM from "react-dom/client" +import { OnboardingPage } from "@/components/onboarding/OnboardingPage" +import { getCurrentLocale } from "@/services/i18n" +import { initSentry, Sentry, ErrorBoundary } from "@/lib/sentry" + +import "@/components/global.css" +import "@/components/Animation.css" + +// Initialize Sentry for the onboarding page +initSentry().catch((error) => { + console.error("Failed to initialize Sentry in onboarding page:", error) +}) + +// Set the document language to the current locale +document.documentElement.lang = getCurrentLocale() + +const root = document.getElementById("root") +if (root) { + try { + ReactDOM.createRoot(root).render( + + + + + , + ) + } catch (error) { + console.error("Failed to render onboarding page:", error) + Sentry.captureException(error as Error) + } +} diff --git a/packages/extension/src/services/analytics.test.ts b/packages/extension/src/services/analytics.test.ts index 74040f23..7910aa4a 100644 --- a/packages/extension/src/services/analytics.test.ts +++ b/packages/extension/src/services/analytics.test.ts @@ -26,6 +26,23 @@ describe("getCommandCreateEvent", () => { }) }) +describe("ANALYTICS_EVENTS onboarding events", () => { + it("defines the event names used by the onboarding flow", () => { + expect(ANALYTICS_EVENTS.ONBOARDING_START).toBe("onboarding_start") + expect(ANALYTICS_EVENTS.ONBOARDING_SKIP).toBe("onboarding_skip") + expect(ANALYTICS_EVENTS.ONBOARDING_FIRST_SELECTION).toBe( + "onboarding_first_selection", + ) + expect(ANALYTICS_EVENTS.ONBOARDING_COMMAND_EXECUTE).toBe( + "onboarding_command_execute", + ) + expect(ANALYTICS_EVENTS.ONBOARDING_FIRST_VALUE).toBe( + "onboarding_first_value", + ) + expect(ANALYTICS_EVENTS.ONBOARDING_COMPLETE).toBe("onboarding_complete") + }) +}) + describe("getHubAddEvent", () => { it.each([ [OPEN_MODE.POPUP, ANALYTICS_EVENTS.HUB_ADD_SEARCH], diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index 58c92607..738e22b2 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -48,6 +48,12 @@ export const ANALYTICS_EVENTS = { COMMAND_EDIT: "command_edit", COMMAND_REMOVE: "command_remove", COMMAND_SHARE: "command_share", + ONBOARDING_START: "onboarding_start", + ONBOARDING_SKIP: "onboarding_skip", + ONBOARDING_FIRST_SELECTION: "onboarding_first_selection", + ONBOARDING_COMMAND_EXECUTE: "onboarding_command_execute", + ONBOARDING_FIRST_VALUE: "onboarding_first_value", + ONBOARDING_COMPLETE: "onboarding_complete", } as const export type AnalyticsEventName = diff --git a/packages/extension/src/services/option/defaultSettings.test.ts b/packages/extension/src/services/option/defaultSettings.test.ts index afc48198..a7bb1f2d 100644 --- a/packages/extension/src/services/option/defaultSettings.test.ts +++ b/packages/extension/src/services/option/defaultSettings.test.ts @@ -8,6 +8,7 @@ import { isLinkCommand } from "@/lib/utils" import { INSERT, toInsertTemplate } from "@/services/pageAction" import { getAiServicesFallback } from "@/services/aiPrompt" import { COMMAND_SOURCE_TYPE, COMMAND_SOURCE_ID } from "@/const" +import { ONBOARDING_AI_PROMPT_COMMAND_ID } from "@/components/onboarding/onboardingCommand" const SYM_SELECTED_TEXT = toInsertTemplate(INSERT.SELECTED_TEXT) const SYM_URL = toInsertTemplate(INSERT.URL) @@ -296,4 +297,18 @@ describe("getDefaultCommands", () => { expect((cmd as any).searchUrl).toContain("%pageUrl") } }) + + it("DS-25: all locale command sets should include the onboarding AiPrompt command", () => { + for (const locale of ALL_LOCALES) { + const commands = getDefaultCommands(locale) + const cmd = commands.find((c) => c.id === ONBOARDING_AI_PROMPT_COMMAND_ID) + expect( + cmd, + `Missing onboarding AiPrompt command for locale: ${locale}`, + ).toBeDefined() + expect((cmd as any).openMode).toBe("aiPrompt") + expect((cmd as any).aiPromptOption.prompt).toContain(SYM_SELECTED_TEXT) + expect((cmd as any).parentFolderId).toBeUndefined() + } + }) }) diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index aa50c24f..0f9d937f 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -19,6 +19,7 @@ import { COMMAND_SOURCE_ID, } from "@/const" import { getAiServicesFallback } from "@/services/aiPromptFallback" +import { createOnboardingAiPromptCommand } from "@/components/onboarding/onboardingCommand" // Derive icon URLs from ai-services.json (single source of truth) const _aiServices = getAiServicesFallback() @@ -67,6 +68,7 @@ export const emptySettings: SettingsType = { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, shortcuts: { shortcuts: [] }, windowOption: { sidePanelAutoHide: false, @@ -204,6 +206,82 @@ export const PopupOption = { height: 700, } +// Onboarding's Step2 AiPrompt command (see src/components/onboarding/). +// Kept as a normal, permanent default command per locale rather than being +// injected/removed around the onboarding flow. Declared here (before +// DefaultCommands/LOCALE_COMMANDS use them below) since `const` bindings +// are not hoisted. +const CMD_ONBOARDING_AI_EN = createOnboardingAiPromptCommand( + "Ask AI", + "Please execute the following prompt.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_JA = createOnboardingAiPromptCommand( + "AIに質問", + "以下のプロンプトを実行してください。\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_ZH = createOnboardingAiPromptCommand( + "询问AI", + "请执行以下提示词。\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_KO = createOnboardingAiPromptCommand( + "AI에게 질문", + "다음 프롬프트를 실행해 주세요.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_RU = createOnboardingAiPromptCommand( + "Спросить ИИ", + "Пожалуйста, выполните следующий запрос.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_DE = createOnboardingAiPromptCommand( + "KI fragen", + "Bitte führen Sie den folgenden Prompt aus.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_FR = createOnboardingAiPromptCommand( + "Demander à l'IA", + "Veuillez exécuter le prompt suivant.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_ES = createOnboardingAiPromptCommand( + "Preguntar a la IA", + "Por favor, ejecuta el siguiente prompt.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_PT_BR = createOnboardingAiPromptCommand( + "Perguntar à IA", + "Por favor, execute o seguinte prompt.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_PT = createOnboardingAiPromptCommand( + "Perguntar à IA", + "Por favor, execute o seguinte prompt.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_HI = createOnboardingAiPromptCommand( + "AI से पूछें", + "कृपया निम्नलिखित प्रॉम्प्ट को निष्पादित करें।\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_ID = createOnboardingAiPromptCommand( + "Tanya AI", + "Silakan jalankan prompt berikut.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_MS = createOnboardingAiPromptCommand( + "Tanya AI", + "Sila jalankan gesaan berikut.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) +const CMD_ONBOARDING_AI_IT = createOnboardingAiPromptCommand( + "Chiedi all'IA", + "Esegui il seguente prompt.\n\n{{SelectedText}}", + GEMINI_ICON_URL, +) + export const DefaultCommands = [ { id: "$$drag-1", @@ -245,6 +323,7 @@ export const DefaultCommands = [ }, spaceEncoding: SPACE_ENCODING.PLUS, }, + CMD_ONBOARDING_AI_EN, { id: "c0d05ae1-f007-5bd0-8fa6-e3bc0b79ca97", revision: 0, @@ -1819,6 +1898,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_JA, CMD_AMAZON_JP, CMD_YAHOO_JAPAN, CMD_YOUTUBE, @@ -1837,6 +1917,7 @@ export const LOCALE_COMMANDS = { CMD_BAIDU, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_ZH, CMD_JD, CMD_GEMINI_ZH, CMD_PAGE_SUMMARY_ZH, @@ -1853,6 +1934,7 @@ export const LOCALE_COMMANDS = { CMD_NAVER, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_KO, CMD_COUPANG, CMD_GEMINI_KO, CMD_PAGE_SUMMARY_KO, @@ -1869,6 +1951,7 @@ export const LOCALE_COMMANDS = { CMD_YANDEX, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_RU, CMD_OZON, CMD_WILDBERRIES, CMD_GEMINI_RU, @@ -1885,6 +1968,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_DE, CMD_AMAZON_DE, CMD_EBAY_DE, CMD_GEMINI_DE, @@ -1901,6 +1985,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_FR, CMD_AMAZON_FR, CMD_LEBONCOIN, CMD_GEMINI_FR, @@ -1917,6 +2002,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_ES, CMD_AMAZON_ES, CMD_EBAY_ES, CMD_EL_CORTE_INGLES, @@ -1935,6 +2021,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_PT_BR, CMD_AMAZON_BR, CMD_MERCADO_LIVRE_BR, CMD_GEMINI_PT, @@ -1951,6 +2038,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_PT, CMD_AMAZON_ES, CMD_OLX_PT, CMD_GEMINI_PT, @@ -1967,6 +2055,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_HI, CMD_AMAZON_IN, CMD_FLIPKART, CMD_GEMINI_HI, @@ -1983,6 +2072,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_ID, CMD_TOKOPEDIA, CMD_SHOPEE_ID, CMD_GEMINI_ID, @@ -1999,6 +2089,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_MS, CMD_SHOPEE_MY, CMD_LAZADA_MY, CMD_GEMINI_MS, @@ -2015,6 +2106,7 @@ export const LOCALE_COMMANDS = { CMD_LINK_PREVIEW, CMD_GOOGLE, CMD_GOOGLE_IMAGE, + CMD_ONBOARDING_AI_IT, CMD_AMAZON_IT, CMD_EBAY_IT, CMD_ZALANDO_IT, diff --git a/packages/extension/src/services/settings/enhancedSettings.ts b/packages/extension/src/services/settings/enhancedSettings.ts index 0a5b1fe2..3e26f36f 100644 --- a/packages/extension/src/services/settings/enhancedSettings.ts +++ b/packages/extension/src/services/settings/enhancedSettings.ts @@ -67,6 +67,7 @@ export class EnhancedSettings { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, }), ]) @@ -98,6 +99,7 @@ export class EnhancedSettings { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, } // Merge settings @@ -185,6 +187,7 @@ export class EnhancedSettings { hasDismissedPromptHistoryBanner: data.userStats.hasDismissedPromptHistoryBanner, hasShownHubShareToast: data.userStats.hasShownHubShareToast, + hasShownOnboarding: data.userStats.hasShownOnboarding, } as SettingsType } diff --git a/packages/extension/src/services/settings/settings.test.ts b/packages/extension/src/services/settings/settings.test.ts index 39102bd3..561d8c60 100644 --- a/packages/extension/src/services/settings/settings.test.ts +++ b/packages/extension/src/services/settings/settings.test.ts @@ -194,6 +194,7 @@ describe("Settings", () => { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, stars: [] as Star[], } @@ -215,6 +216,7 @@ describe("Settings", () => { hasDismissedPromptHistoryBanner: mockSettings.hasDismissedPromptHistoryBanner, hasShownHubShareToast: mockSettings.hasShownHubShareToast, + hasShownOnboarding: mockSettings.hasShownOnboarding, }) expect(mockStorage.set).toHaveBeenCalledWith( STORAGE_KEY.SHORTCUTS, @@ -635,6 +637,7 @@ describe("migrate function", () => { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, stars: [] as Star[], } diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index e70f8d69..40d5208a 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -138,6 +138,7 @@ export const Settings = { hasShownReviewRequest, hasDismissedPromptHistoryBanner, hasShownHubShareToast, + hasShownOnboarding, stars, ...restData } = data @@ -147,6 +148,7 @@ export const Settings = { hasShownReviewRequest, hasDismissedPromptHistoryBanner, hasShownHubShareToast, + hasShownOnboarding, } ps.push(Storage.set(STORAGE_KEY.USER_STATS, userStats)) diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 713d05c7..1e3c4e87 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -58,6 +58,7 @@ const DEFAULTS = { hasShownReviewRequest: false, hasDismissedPromptHistoryBanner: false, hasShownHubShareToast: false, + hasShownOnboarding: false, }, [STORAGE_KEY.SHORTCUTS]: { shortcuts: [], diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index e2ee4498..d325766a 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -181,6 +181,7 @@ export type UserStats = { hasShownReviewRequest: boolean hasDismissedPromptHistoryBanner: boolean hasShownHubShareToast: boolean + hasShownOnboarding: boolean } export type ShortcutCommand = { diff --git a/packages/extension/src/types/onboarding.ts b/packages/extension/src/types/onboarding.ts new file mode 100644 index 00000000..5fe7a1e3 --- /dev/null +++ b/packages/extension/src/types/onboarding.ts @@ -0,0 +1,21 @@ +export enum OnboardingStep { + INTRO = 0, + SEARCH = 1, + AI_PROMPT = 2, + LINK_PREVIEW = 3, + CUSTOMIZE = 4, + COMPLETE = 5, +} + +// Sub-state within a single step: shown its explanation, waiting for the +// user to select text, waiting for them to run the command, waiting for +// them to come back from the popup/tab it opened, then showing the value +// message. Not every step uses every phase (e.g. INTRO/CUSTOMIZE/COMPLETE +// only ever use EXPLAIN). +export enum StepPhase { + EXPLAIN = "explain", + WAIT_SELECTION = "wait_selection", + WAIT_EXECUTE = "wait_execute", + WAIT_RETURN = "wait_return", + VALUE_SHOWN = "value_shown", +} diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 99e83a00..69022bec 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -27,23 +27,23 @@ export default defineConfig(({ mode }) => { const activeManifest = isProduction ? { - ...manifest, - ...(extensionKey ? { key: extensionKey } : {}), - content_scripts: manifest.content_scripts.map((cs) => ({ - ...cs, - matches: cs.matches.filter((m) => !m.includes("localhost")), - })), - externally_connectable: { - ...manifest.externally_connectable, - matches: manifest.externally_connectable.matches.filter( - (m) => !m.includes("localhost"), - ), - }, - } + ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), + content_scripts: manifest.content_scripts.map((cs) => ({ + ...cs, + matches: cs.matches.filter((m) => !m.includes("localhost")), + })), + externally_connectable: { + ...manifest.externally_connectable, + matches: manifest.externally_connectable.matches.filter( + (m) => !m.includes("localhost"), + ), + }, + } : { - ...manifest, - ...(extensionKey ? { key: extensionKey } : {}), - } + ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), + } const plugins = [ react(), @@ -144,20 +144,20 @@ export default defineConfig(({ mode }) => { pure: mode === "production" ? [ - "console.log", - "console.debug", - "console.info", - "console.trace", - "console.dir", - "console.count", - "console.countReset", - "console.group", - "console.groupCollapsed", - "console.groupEnd", - "console.time", - "console.timeEnd", - "console.timeLog", - ] + "console.log", + "console.debug", + "console.info", + "console.trace", + "console.dir", + "console.count", + "console.countReset", + "console.group", + "console.groupCollapsed", + "console.groupEnd", + "console.time", + "console.timeEnd", + "console.timeLog", + ] : [], }, build: { @@ -166,6 +166,7 @@ export default defineConfig(({ mode }) => { rollupOptions: { input: { clipboard: "src/clipboard.html", + onboarding: "src/onboarding_page.html", }, output: { // Group all CSS module files into a named chunk so that the From b62b6c653483ad95c1a1d27c54c358508bb5d5ab Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 24 Aug 2026 23:57:52 +0900 Subject: [PATCH 02/21] Update: Refine onboarding flow with return hint, pattern captions, and Hub link Add a slow-blink hint on Step1 while waiting for the user to return from the search command's tab, short pattern captions on Steps 1-3 to reinforce the select-command-result mental model, and a Command Hub link on Step4. Also route onboarding tab close through the background script (closeTab) so the settings write completes before the tab closes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017QRxn2pRzMCWvf9nK2w7Kq --- .../public/_locales/de/messages.json | 17 +++++++++++- .../public/_locales/en/messages.json | 22 ++++++++++++++- .../public/_locales/es/messages.json | 17 +++++++++++- .../public/_locales/fr/messages.json | 17 +++++++++++- .../public/_locales/hi/messages.json | 17 +++++++++++- .../public/_locales/id/messages.json | 17 +++++++++++- .../public/_locales/it/messages.json | 17 +++++++++++- .../public/_locales/ja/messages.json | 17 +++++++++++- .../public/_locales/ko/messages.json | 17 +++++++++++- .../public/_locales/ms/messages.json | 17 +++++++++++- .../public/_locales/pt_BR/messages.json | 17 +++++++++++- .../public/_locales/pt_PT/messages.json | 17 +++++++++++- .../public/_locales/ru/messages.json | 17 +++++++++++- .../public/_locales/zh_CN/messages.json | 17 +++++++++++- packages/extension/src/background_script.ts | 25 +++++++++++++++++ .../onboarding/Onboarding.module.css | 21 +++++++++++++++ .../components/onboarding/onboardingWindow.ts | 18 +++++++++++++ .../onboarding/steps/StepAiPromptCommand.tsx | 3 +++ .../onboarding/steps/StepComplete.tsx | 3 ++- .../onboarding/steps/StepCustomize.tsx | 27 ++++++++++++++++++- .../onboarding/steps/StepLinkPreview.tsx | 3 +++ .../onboarding/steps/StepSearchCommand.tsx | 8 ++++++ .../onboarding/useOnboardingState.ts | 17 ++++++++---- packages/extension/src/services/ipc.ts | 1 + packages/shared/src/constants/utm.ts | 1 + 25 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 packages/extension/src/components/onboarding/onboardingWindow.ts diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 85f9b1d3..bee873f1 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1336,7 +1336,7 @@ "message": "Dies ist eine Erweiterung, mit der du markierten Text von einer Webseite in eine Prompt-Vorlage einfügen und ausführen kannst. Überlege dir ein paar Ideen, wofür du sie nutzen könntest." }, "onboarding_step2Callout": { - "message": "Klicke auf diesen Befehl, um deine Auswahl an die KI zu senden." + "message": "Führe diesen Befehl aus, um deine Auswahl über einen voreingestellten KI-Prompt zu senden." }, "onboarding_step2ValueMessage": { "message": "Jetzt kannst du die KI direkt auf der Seite, die du liest, zu allem fragen, was du markierst." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "Schließen" + }, + "onboarding_step1ReturnHint": { + "message": "Wechsle zurück zu diesem Tab, dann verschwindet diese Meldung automatisch." + }, + "onboarding_step1Pattern": { + "message": "Auswählen → Befehl → Suchen" + }, + "onboarding_step2Pattern": { + "message": "Auswählen → Befehl → KI fragen" + }, + "onboarding_step3Pattern": { + "message": "Auswählen → Befehl → Vorschau" + }, + "onboarding_step4HubLink": { + "message": "Command Hub durchsuchen" } } diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 02925497..79d04b62 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1365,7 +1365,7 @@ "description": "Onboarding Step2 sample text the user is asked to select to trigger the AiPrompt command." }, "onboarding_step2Callout": { - "message": "Click this command to send your selection to AI.", + "message": "Click this command to send your selection through a pre-set AI prompt.", "description": "Onboarding Step2 callout/speech-bubble text pointing at the onboarding's dedicated AiPrompt command in the popup menu." }, "onboarding_step2ValueMessage": { @@ -1407,5 +1407,25 @@ "onboarding_closeButton": { "message": "Close", "description": "Onboarding Step5 button that closes the onboarding tab." + }, + "onboarding_step1ReturnHint": { + "message": "Switch back to this tab and this message disappears automatically.", + "description": "Onboarding Step1 hint shown while waiting for the user to return from the search command's popup/tab." + }, + "onboarding_step1Pattern": { + "message": "Select → Command → Search", + "description": "Onboarding Step1 short pattern caption reinforcing the select-command-result mental model." + }, + "onboarding_step2Pattern": { + "message": "Select → Command → Ask AI", + "description": "Onboarding Step2 short pattern caption reinforcing the select-command-result mental model." + }, + "onboarding_step3Pattern": { + "message": "Select → Command → Preview", + "description": "Onboarding Step3 short pattern caption reinforcing the select-command-result mental model." + }, + "onboarding_step4HubLink": { + "message": "Browse the Command Hub", + "description": "Onboarding Step4 link label pointing to the Command Hub to discover more commands." } } diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 581ec011..02d0cb25 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1336,7 +1336,7 @@ "message": "Esta es una extensión que te permite insertar el texto seleccionado de una página web en una plantilla de prompt y ejecutarlo. Piensa en algunas ideas de para qué podrías usarla." }, "onboarding_step2Callout": { - "message": "Haz clic en este comando para enviar tu selección a la IA." + "message": "Haz clic en este comando para enviar tu selección a través de un prompt de IA preconfigurado." }, "onboarding_step2ValueMessage": { "message": "Ahora puedes preguntarle a la IA sobre cualquier cosa que selecciones, sin salir de la página que estás leyendo." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "Cerrar" + }, + "onboarding_step1ReturnHint": { + "message": "Vuelve a esta pestaña y este mensaje desaparecerá automáticamente." + }, + "onboarding_step1Pattern": { + "message": "Selección → Comando → Búsqueda" + }, + "onboarding_step2Pattern": { + "message": "Selección → Comando → IA" + }, + "onboarding_step3Pattern": { + "message": "Selección → Comando → Vista previa" + }, + "onboarding_step4HubLink": { + "message": "Explorar el Command Hub" } } diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index daec358c..333fbc9c 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1336,7 +1336,7 @@ "message": "Il s'agit d'une extension qui permet d'intégrer le texte sélectionné d'une page web dans un modèle de prompt et de l'exécuter. Réfléchissez à quelques idées d'utilisation." }, "onboarding_step2Callout": { - "message": "Cliquez sur cette commande pour envoyer votre sélection à l'IA." + "message": "Cliquez sur cette commande pour envoyer votre sélection via un prompt IA préconfiguré." }, "onboarding_step2ValueMessage": { "message": "Vous pouvez désormais interroger l'IA sur tout ce que vous sélectionnez, directement depuis la page que vous lisez." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "Fermer" + }, + "onboarding_step1ReturnHint": { + "message": "Revenez à cet onglet et ce message disparaîtra automatiquement." + }, + "onboarding_step1Pattern": { + "message": "Sélection → Commande → Recherche" + }, + "onboarding_step2Pattern": { + "message": "Sélection → Commande → IA" + }, + "onboarding_step3Pattern": { + "message": "Sélection → Commande → Aperçu" + }, + "onboarding_step4HubLink": { + "message": "Parcourir le Command Hub" } } diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index e7702e1a..ddbd64cd 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1336,7 +1336,7 @@ "message": "यह एक ऐसा एक्सटेंशन है जो वेब पेज पर चुने गए टेक्स्ट को एक प्रॉम्प्ट टेम्पलेट में डालकर चला सकता है। सोचिए, इससे आप क्या-क्या कर सकते हैं।" }, "onboarding_step2Callout": { - "message": "इस कमांड को चलाने पर आपकी चुनी हुई टेक्स्ट AI को भेजी जाएगी।" + "message": "इस कमांड को चलाने पर आपका चयन पहले से सेट किए गए प्रॉम्प्ट के ज़रिए AI को भेजा जाता है।" }, "onboarding_step2ValueMessage": { "message": "अब आप जो पेज पढ़ रहे हैं, वहीं से चुनी हुई किसी भी चीज़ के बारे में AI से पूछ सकते हैं।" @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "बंद करें" + }, + "onboarding_step1ReturnHint": { + "message": "इस टैब पर वापस आएं और यह संदेश अपने आप गायब हो जाएगा।" + }, + "onboarding_step1Pattern": { + "message": "चयन → कमांड → खोज" + }, + "onboarding_step2Pattern": { + "message": "चयन → कमांड → AI" + }, + "onboarding_step3Pattern": { + "message": "चयन → कमांड → पूर्वावलोकन" + }, + "onboarding_step4HubLink": { + "message": "Command Hub देखें" } } diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index f661b163..e10a6a54 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1339,7 +1339,7 @@ "message": "Ini adalah ekstensi yang memungkinkan Anda menyematkan teks yang dipilih dari halaman web ke dalam templat prompt dan menjalankannya. Pikirkan beberapa ide tentang apa yang bisa Anda lakukan dengannya." }, "onboarding_step2Callout": { - "message": "Klik perintah ini untuk mengirim teks yang dipilih ke AI." + "message": "Klik perintah ini untuk mengirim teks yang dipilih melalui prompt AI yang sudah diatur sebelumnya." }, "onboarding_step2ValueMessage": { "message": "Sekarang Anda bisa bertanya ke AI tentang apa pun yang Anda pilih, langsung dari halaman yang sedang Anda baca." @@ -1370,5 +1370,20 @@ }, "onboarding_closeButton": { "message": "Tutup" + }, + "onboarding_step1ReturnHint": { + "message": "Kembali ke tab ini dan pesan ini akan hilang secara otomatis." + }, + "onboarding_step1Pattern": { + "message": "Pilih → Perintah → Cari" + }, + "onboarding_step2Pattern": { + "message": "Pilih → Perintah → AI" + }, + "onboarding_step3Pattern": { + "message": "Pilih → Perintah → Pratinjau" + }, + "onboarding_step4HubLink": { + "message": "Jelajahi Command Hub" } } diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index c827008f..f13172ee 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1336,7 +1336,7 @@ "message": "Questa è un'estensione che ti permette di inserire il testo selezionato da una pagina web in un modello di prompt ed eseguirlo. Pensa a qualche idea su come potresti usarla." }, "onboarding_step2Callout": { - "message": "Fai clic su questo comando per inviare la tua selezione all'IA." + "message": "Fai clic su questo comando per inviare la selezione tramite un prompt IA preimpostato." }, "onboarding_step2ValueMessage": { "message": "Ora puoi chiedere all'IA qualsiasi cosa selezioni, direttamente dalla pagina che stai leggendo." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "Chiudi" + }, + "onboarding_step1ReturnHint": { + "message": "Torna a questa scheda e il messaggio scomparirà automaticamente." + }, + "onboarding_step1Pattern": { + "message": "Selezione → Comando → Ricerca" + }, + "onboarding_step2Pattern": { + "message": "Selezione → Comando → IA" + }, + "onboarding_step3Pattern": { + "message": "Selezione → Comando → Anteprima" + }, + "onboarding_step4HubLink": { + "message": "Esplora il Command Hub" } } diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 486371d2..c3a02525 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1333,7 +1333,7 @@ "message": "Webページ上のテキストをプロンプトテンプレートに埋め込んで実行できる拡張機能で、どんなことが出来るか、アイデアを考えてください。" }, "onboarding_step2Callout": { - "message": "このコマンドを実行すると、選択したテキストをAIに送れます。" + "message": "このコマンドを実行すると、選択したテキストをあらかじめ設定したプロンプトでAIに送れます。" }, "onboarding_step2ValueMessage": { "message": "Webページを読みながら、その場でAIに質問できました。" @@ -1364,5 +1364,20 @@ }, "onboarding_closeButton": { "message": "閉じる" + }, + "onboarding_step1ReturnHint": { + "message": "もとの画面を選択すると、自動的に消えます" + }, + "onboarding_step1Pattern": { + "message": "選択 → コマンド → 検索" + }, + "onboarding_step2Pattern": { + "message": "選択 → コマンド → AI" + }, + "onboarding_step3Pattern": { + "message": "選択 → コマンド → プレビュー" + }, + "onboarding_step4HubLink": { + "message": "Command Hubを見る" } } diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index a8922fa8..cc32d58a 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1336,7 +1336,7 @@ "message": "웹페이지의 텍스트를 프롬프트 템플릿에 넣어 실행할 수 있는 확장 프로그램으로 무엇을 할 수 있을지 아이디어를 생각해 보세요." }, "onboarding_step2Callout": { - "message": "이 명령을 실행하면 선택한 텍스트를 AI로 보낼 수 있습니다." + "message": "이 명령을 실행하면 선택한 텍스트를 미리 설정된 프롬프트로 AI에 보낼 수 있습니다." }, "onboarding_step2ValueMessage": { "message": "이제 페이지를 읽으면서 바로 AI에게 무엇이든 물어볼 수 있습니다." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "닫기" + }, + "onboarding_step1ReturnHint": { + "message": "이 탭으로 돌아오면 이 메시지는 자동으로 사라집니다." + }, + "onboarding_step1Pattern": { + "message": "선택 → 명령 → 검색" + }, + "onboarding_step2Pattern": { + "message": "선택 → 명령 → AI" + }, + "onboarding_step3Pattern": { + "message": "선택 → 명령 → 미리보기" + }, + "onboarding_step4HubLink": { + "message": "Command Hub 둘러보기" } } diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index f58c0377..ca1ab6e5 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1339,7 +1339,7 @@ "message": "Ini adalah sambungan yang membolehkan anda menyisipkan teks yang dipilih daripada halaman web ke dalam templat gesaan dan menjalankannya. Fikirkan beberapa idea tentang apa yang boleh anda lakukan dengannya." }, "onboarding_step2Callout": { - "message": "Klik arahan ini untuk menghantar pilihan anda kepada AI." + "message": "Klik perintah ini untuk menghantar pilihan anda melalui gesaan AI yang telah ditetapkan." }, "onboarding_step2ValueMessage": { "message": "Kini anda boleh bertanya kepada AI tentang apa sahaja yang anda pilih, terus daripada halaman yang sedang anda baca." @@ -1370,5 +1370,20 @@ }, "onboarding_closeButton": { "message": "Tutup" + }, + "onboarding_step1ReturnHint": { + "message": "Kembali ke tab ini dan mesej ini akan hilang secara automatik." + }, + "onboarding_step1Pattern": { + "message": "Pilih → Perintah → Cari" + }, + "onboarding_step2Pattern": { + "message": "Pilih → Perintah → AI" + }, + "onboarding_step3Pattern": { + "message": "Pilih → Perintah → Pratonton" + }, + "onboarding_step4HubLink": { + "message": "Terokai Command Hub" } } diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 131434bf..907928a1 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1339,7 +1339,7 @@ "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página da web em um modelo de prompt e executá-lo. Pense em algumas ideias do que você poderia fazer com ela." }, "onboarding_step2Callout": { - "message": "Clique neste comando para enviar sua seleção para a IA." + "message": "Clique neste comando para enviar sua seleção por um prompt de IA pré-configurado." }, "onboarding_step2ValueMessage": { "message": "Agora você pode perguntar à IA sobre qualquer coisa que selecionar, direto da página que está lendo." @@ -1370,5 +1370,20 @@ }, "onboarding_closeButton": { "message": "Fechar" + }, + "onboarding_step1ReturnHint": { + "message": "Volte para esta aba e esta mensagem desaparece automaticamente." + }, + "onboarding_step1Pattern": { + "message": "Seleção → Comando → Busca" + }, + "onboarding_step2Pattern": { + "message": "Seleção → Comando → IA" + }, + "onboarding_step3Pattern": { + "message": "Seleção → Comando → Pré-visualização" + }, + "onboarding_step4HubLink": { + "message": "Explorar o Command Hub" } } diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 02f47cbd..f952c5e7 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1339,7 +1339,7 @@ "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página web num modelo de prompt e executá-lo. Pense em algumas ideias para o que poderia usá-la." }, "onboarding_step2Callout": { - "message": "Clique neste comando para enviar a sua seleção para a IA." + "message": "Clique neste comando para enviar a sua seleção através de um prompt de IA pré-configurado." }, "onboarding_step2ValueMessage": { "message": "Agora já pode perguntar à IA sobre o que selecionar, diretamente a partir da página que está a ler." @@ -1370,5 +1370,20 @@ }, "onboarding_closeButton": { "message": "Fechar" + }, + "onboarding_step1ReturnHint": { + "message": "Volte a este separador e esta mensagem desaparece automaticamente." + }, + "onboarding_step1Pattern": { + "message": "Seleção → Comando → Pesquisa" + }, + "onboarding_step2Pattern": { + "message": "Seleção → Comando → IA" + }, + "onboarding_step3Pattern": { + "message": "Seleção → Comando → Pré-visualização" + }, + "onboarding_step4HubLink": { + "message": "Explorar o Command Hub" } } diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 47b3f523..ff5e1705 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1336,7 +1336,7 @@ "message": "Это расширение позволяет вставлять выделенный текст с веб-страницы в шаблон запроса и выполнять его. Подумайте, для чего это можно использовать." }, "onboarding_step2Callout": { - "message": "Нажмите эту команду, чтобы отправить выделенный текст в ИИ." + "message": "Нажмите эту команду, чтобы отправить выделенный текст через заранее заданный запрос к ИИ." }, "onboarding_step2ValueMessage": { "message": "Теперь вы можете спрашивать ИИ о чём угодно прямо на странице, которую читаете." @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "Закрыть" + }, + "onboarding_step1ReturnHint": { + "message": "Вернитесь на эту вкладку, и сообщение исчезнет автоматически." + }, + "onboarding_step1Pattern": { + "message": "Выделение → Команда → Поиск" + }, + "onboarding_step2Pattern": { + "message": "Выделение → Команда → ИИ" + }, + "onboarding_step3Pattern": { + "message": "Выделение → Команда → Просмотр" + }, + "onboarding_step4HubLink": { + "message": "Открыть Command Hub" } } diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 41b5c958..1330d1aa 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1336,7 +1336,7 @@ "message": "这是一个可以将网页上选中的文字嵌入提示词模板并执行的扩展程序,想想它能帮你做些什么。" }, "onboarding_step2Callout": { - "message": "点击此命令即可将所选文字发送给 AI。" + "message": "点击此命令,即可通过预设的提示词将选中内容发送给 AI。" }, "onboarding_step2ValueMessage": { "message": "现在,您可以在阅读网页的同时,就您选择的任何内容向 AI 提问。" @@ -1367,5 +1367,20 @@ }, "onboarding_closeButton": { "message": "关闭" + }, + "onboarding_step1ReturnHint": { + "message": "切回此标签页,该提示会自动消失。" + }, + "onboarding_step1Pattern": { + "message": "选择 → 命令 → 搜索" + }, + "onboarding_step2Pattern": { + "message": "选择 → 命令 → AI" + }, + "onboarding_step3Pattern": { + "message": "选择 → 命令 → 预览" + }, + "onboarding_step4HubLink": { + "message": "浏览 Command Hub" } } diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 5e3d0bb7..2f0c52b1 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -59,6 +59,30 @@ const getActiveTabId = ( return true } +// Closes the sender's own tab. Routed through the background script (rather +// than the page calling chrome.tabs.remove() on itself) since a page closing +// its own tab is the more robust pattern - it keeps tab lifecycle decisions +// in one place alongside the rest of the extension's tab management. +// +// Responds *before* removing the tab, not after: the sender is awaiting this +// response in the very tab we're about to close, so if the response only +// arrived once chrome.tabs.remove() had resolved, that await would be racing +// its own tab's teardown. Acknowledging first lets the sender's promise +// settle cleanly while its tab is still fully alive; the removal itself +// follows as a fire-and-forget side effect. +const closeTab = ( + _: unknown, + sender: Sender, + response: (res: unknown) => void, +) => { + const tabId = sender.tab?.id + response(tabId != null) + if (tabId != null) { + chrome.tabs.remove(tabId) + } + return false +} + const onConnect = async function (port: chrome.runtime.Port) { if (port.name !== CONNECTION_APP) return port.onDisconnect.addListener(() => onDisconnect(port)) @@ -288,6 +312,7 @@ const commandFuncs = { [BgCommand.getTabId]: getTabId, [BgCommand.getActiveTabId]: getActiveTabId, + [BgCommand.closeTab]: closeTab, // // Hub diff --git a/packages/extension/src/components/onboarding/Onboarding.module.css b/packages/extension/src/components/onboarding/Onboarding.module.css index 00ba6685..34dbc29f 100644 --- a/packages/extension/src/components/onboarding/Onboarding.module.css +++ b/packages/extension/src/components/onboarding/Onboarding.module.css @@ -30,3 +30,24 @@ outline-offset: 4px; } } + +/* Shown while waiting for the user to switch back from a command opened in + another window/tab (e.g. Step1's search). Per the PRD: slight drop shadow, + text slowly blinking. */ +.returnHint { + display: inline-block; + border-radius: 8px; + padding: 8px 14px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + animation: 2s onboardingSlowBlink ease-in-out infinite; +} + +@keyframes onboardingSlowBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} diff --git a/packages/extension/src/components/onboarding/onboardingWindow.ts b/packages/extension/src/components/onboarding/onboardingWindow.ts new file mode 100644 index 00000000..6705d400 --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingWindow.ts @@ -0,0 +1,18 @@ +import { Ipc, BgCommand } from "@/services/ipc" + +// Closes the onboarding tab. The tab is opened via chrome.tabs.create() from +// the background script (not window.open() from the page itself), so a bare +// window.close() call is not guaranteed to work per the Window.close() spec +// ("scripts may close only the windows that were opened by them"). Routing +// the close through the background script (BgCommand.closeTab) rather than +// calling chrome.tabs.remove() on the page's own tab id keeps tab lifecycle +// decisions in the background script alongside the rest of tab management. +export async function closeOnboardingTab(): Promise { + try { + await Ipc.send(BgCommand.closeTab) + return + } catch { + // Fall through to window.close() below. + } + window.close() +} diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index 89f483d3..0a1e9eaa 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -73,6 +73,9 @@ export function StepAiPromptCommand({ onboarding }: Props) {

{t("onboarding_step2ValueMessage")}

+

+ {t("onboarding_step2Pattern")} +

diff --git a/packages/extension/src/components/onboarding/steps/StepCustomize.tsx b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx index 64da23e8..70cc7d99 100644 --- a/packages/extension/src/components/onboarding/steps/StepCustomize.tsx +++ b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx @@ -1,6 +1,11 @@ +import { ExternalLink } from "lucide-react" import { t } from "@/services/i18n" import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingStep } from "@/types/onboarding" +import { NEW_HUB_URL } from "@/const" +import { getHubLocale } from "@/services/hubShare" +import { useHubUser } from "@/hooks/option/useHubUser" +import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" import type { UseOnboardingState } from "../useOnboardingState" type Props = { @@ -8,13 +13,33 @@ type Props = { } // Step4: EXPLAIN-only wrap-up telling the user the sample commands seen in -// Steps 1-3 can be edited, removed, or added to from the settings page. +// Steps 1-3 can be edited, removed, or added to from the settings page, plus +// a link to the Command Hub to discover more (per the PRD's Step4 - only a +// lightweight pointer here, not a how-to-build-commands explainer). export function StepCustomize({ onboarding }: Props) { + const hubUser = useHubUser() + const locale = getHubLocale() + const hubLink = withUtmParams( + hubUser + ? `${NEW_HUB_URL}/${locale}/dashboard/commands` + : `${NEW_HUB_URL}/${locale}`, + { source: UTM_SOURCE.ONBOARDING, medium: UTM_MEDIUM.LINK }, + ) + return (

{t("onboarding_step4Explain")}

+ + {t("onboarding_step4HubLink")} + +
+ ) : phase === StepPhase.WAIT_RETURN ? ( +

+ {t("onboarding_step1ReturnHint")} +

) : (

{t("onboarding_step1Explain")} diff --git a/packages/extension/src/components/onboarding/useOnboardingState.ts b/packages/extension/src/components/onboarding/useOnboardingState.ts index def34411..3ef12cbf 100644 --- a/packages/extension/src/components/onboarding/useOnboardingState.ts +++ b/packages/extension/src/components/onboarding/useOnboardingState.ts @@ -5,6 +5,7 @@ import { sendOnboardingEvent } from "./onboardingAnalytics" import { Settings } from "@/services/settings/settings" import { getCurrentLocale } from "@/services/i18n" import { VERSION } from "@/const" +import { closeOnboardingTab } from "./onboardingWindow" export type UseOnboardingState = ReturnType @@ -56,17 +57,23 @@ export function useOnboardingState() { sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_FIRST_VALUE) }, []) - const markFinished = useCallback(() => { + const markFinished = useCallback(async () => { if (finishedRef.current) return finishedRef.current = true // Never show the onboarding tab again after this run, whether the user - // completed it or skipped partway through. - Settings.update("hasShownOnboarding", () => true) + // completed it or skipped partway through. Awaited by callers so the + // setting is persisted before the tab closes. + await Settings.update("hasShownOnboarding", () => true) }, []) - const skip = useCallback(() => { + // Unlike Complete's "Close" button (a deliberate final step the user + // reaches after reading the summary), Skip fires immediately on click, so + // there's no natural delay for the settings write to land - explicitly + // await it before closing the tab. + const skip = useCallback(async () => { sendOnboardingEvent(ANALYTICS_EVENTS.ONBOARDING_SKIP, { step }) - markFinished() + await markFinished() + await closeOnboardingTab() }, [step, markFinished]) const complete = useCallback(() => { diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index ee859f60..e2819ea1 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -30,6 +30,7 @@ export enum BgCommand { getTabId = "getTabId", getActiveTabId = "getActiveTabId", setClipboard = "setClipboard", + closeTab = "closeTab", // Hub shareCommandToHub = "shareCommandToHub", editCommandToHub = "editCommandToHub", diff --git a/packages/shared/src/constants/utm.ts b/packages/shared/src/constants/utm.ts index 340ec1f8..e70c9acd 100644 --- a/packages/shared/src/constants/utm.ts +++ b/packages/shared/src/constants/utm.ts @@ -6,6 +6,7 @@ export const UTM_SOURCE = { OPTION_PAGE: "option-page", EXTENSION: "extension", + ONBOARDING: "onboarding", } as const export type UtmSource = (typeof UTM_SOURCE)[keyof typeof UTM_SOURCE] From 6438e1687d6ae70ea59ff55c0d8c72a47232b7af Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 25 Aug 2026 13:09:41 +0900 Subject: [PATCH 03/21] Update: Redesign onboarding chrome for clarity and mental-model reinforcement Replace the per-step spotlight overlay with a constant vignette, add a persistent select-command-result progress rail, demonstrate the drag-to- select gesture at runtime (locale-proof via Range.getClientRects), and distinguish actionable buttons from decorative state with fill/tint. Move CSS off Onboarding.module.css onto Tailwind, and add a canvas-confetti celebration on the completion screen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WaFfYLgE9pcwo3wSwEeG8D --- .../extension/e2e/onboarding-shots.spec.ts | 109 ++++++++++ packages/extension/package.json | 2 + .../public/_locales/de/messages.json | 59 ++++-- .../public/_locales/en/messages.json | 82 +++++--- .../public/_locales/es/messages.json | 59 ++++-- .../public/_locales/fr/messages.json | 59 ++++-- .../public/_locales/hi/messages.json | 59 ++++-- .../public/_locales/id/messages.json | 59 ++++-- .../public/_locales/it/messages.json | 59 ++++-- .../public/_locales/ja/messages.json | 59 ++++-- .../public/_locales/ko/messages.json | 59 ++++-- .../public/_locales/ms/messages.json | 59 ++++-- .../public/_locales/pt_BR/messages.json | 59 ++++-- .../public/_locales/pt_PT/messages.json | 59 ++++-- .../public/_locales/ru/messages.json | 59 ++++-- .../public/_locales/zh_CN/messages.json | 59 ++++-- .../onboarding/Onboarding.module.css | 53 ----- .../onboarding/OnboardingConfetti.tsx | 69 +++++++ .../onboarding/OnboardingFadeIn.tsx | 12 +- .../onboarding/OnboardingLayout.tsx | 74 +++++++ .../onboarding/OnboardingOverlay.tsx | 54 ----- .../components/onboarding/OnboardingPage.tsx | 5 +- .../components/onboarding/OnboardingRail.tsx | 108 ++++++++++ .../onboarding/OnboardingTargetText.tsx | 75 +++++++ .../onboarding/OnboardingValueShown.tsx | 43 ++++ .../onboarding/onboardingProgress.test.ts | 46 +++++ .../onboarding/onboardingProgress.ts | 39 ++++ .../onboarding/steps/StepAiPromptCommand.tsx | 87 ++++---- .../onboarding/steps/StepComplete.tsx | 60 ++++-- .../onboarding/steps/StepCustomize.tsx | 164 +++++++++++---- .../components/onboarding/steps/StepIntro.tsx | 34 ++-- .../onboarding/steps/StepLinkPreview.tsx | 80 ++++---- .../onboarding/steps/StepSearchCommand.tsx | 93 +++++---- .../onboarding/useOnboardingState.ts | 32 ++- .../onboarding/useSelectionDemo.test.ts | 188 ++++++++++++++++++ .../components/onboarding/useSelectionDemo.ts | 186 +++++++++++++++++ .../src/hooks/usePrefersReducedMotion.ts | 28 +++ packages/extension/tailwind.config.js | 35 ++++ packages/shared/src/constants/utm.ts | 1 - yarn.lock | 41 ++-- 40 files changed, 1900 insertions(+), 667 deletions(-) create mode 100644 packages/extension/e2e/onboarding-shots.spec.ts delete mode 100644 packages/extension/src/components/onboarding/Onboarding.module.css create mode 100644 packages/extension/src/components/onboarding/OnboardingConfetti.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingLayout.tsx delete mode 100644 packages/extension/src/components/onboarding/OnboardingOverlay.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingRail.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingTargetText.tsx create mode 100644 packages/extension/src/components/onboarding/OnboardingValueShown.tsx create mode 100644 packages/extension/src/components/onboarding/onboardingProgress.test.ts create mode 100644 packages/extension/src/components/onboarding/onboardingProgress.ts create mode 100644 packages/extension/src/components/onboarding/useSelectionDemo.test.ts create mode 100644 packages/extension/src/components/onboarding/useSelectionDemo.ts create mode 100644 packages/extension/src/hooks/usePrefersReducedMotion.ts diff --git a/packages/extension/e2e/onboarding-shots.spec.ts b/packages/extension/e2e/onboarding-shots.spec.ts new file mode 100644 index 00000000..c7beb8c3 --- /dev/null +++ b/packages/extension/e2e/onboarding-shots.spec.ts @@ -0,0 +1,109 @@ +import { test, chromium } from "@playwright/test" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" +import { OnboardingStep, StepPhase } from "@/types/onboarding" + +// Not part of the regular e2e suite - a design-review tool. Skipped unless +// explicitly requested (`CAPTURE=1 yarn test:e2e onboarding-shots`) so it +// never slows down or flakes the normal CI run. +// +// Uses `?step=..&phase=..` (see useOnboardingState.ts's readE2eOverride(), +// e2e-build only) to land directly on each screen instead of driving the +// real selection/command flow, and launches its own persistent context per +// locale (rather than the shared `fixtures.ts` one) so this file alone +// controls `--lang`. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const pathToExtension = path.join(__dirname, "../dist") + +type Shot = { + name: string + step: keyof typeof OnboardingStep + phase?: StepPhase +} + +// One shot per visually distinct screen - not one per StepPhase transition; +// e.g. Step2's WAIT_EXECUTE looks like Step1's, so only Step1 gets it. +const SHOTS: Shot[] = [ + { name: "00-intro", step: "INTRO" }, + { name: "01-search-explain", step: "SEARCH", phase: StepPhase.EXPLAIN }, + { + name: "02-search-wait-execute", + step: "SEARCH", + phase: StepPhase.WAIT_EXECUTE, + }, + { + name: "03-search-wait-return", + step: "SEARCH", + phase: StepPhase.WAIT_RETURN, + }, + { name: "04-search-value", step: "SEARCH", phase: StepPhase.VALUE_SHOWN }, + { name: "05-ai-explain", step: "AI_PROMPT", phase: StepPhase.EXPLAIN }, + { name: "06-ai-value", step: "AI_PROMPT", phase: StepPhase.VALUE_SHOWN }, + { + name: "07-preview-explain", + step: "LINK_PREVIEW", + phase: StepPhase.EXPLAIN, + }, + { + name: "08-preview-value", + step: "LINK_PREVIEW", + phase: StepPhase.VALUE_SHOWN, + }, + { name: "09-customize", step: "CUSTOMIZE" }, + { name: "10-complete", step: "COMPLETE" }, +] + +const LOCALES = ["ja", "en-US", "de"] + +test.describe("onboarding screenshots (design review)", () => { + test.skip( + !process.env.CAPTURE, + "set CAPTURE=1 to run onboarding screenshot capture", + ) + + for (const locale of LOCALES) { + test(`capture (${locale})`, async () => { + test.setTimeout(120_000) + + const context = await chromium.launchPersistentContext("", { + headless: false, + args: [ + "--headless=new", + `--lang=${locale}`, + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }) + + try { + let [sw] = context.serviceWorkers() + if (!sw) sw = await context.waitForEvent("serviceworker") + const extensionId = sw.url().split("/")[2] + + const page = await context.newPage() + const outDir = path.join( + __dirname, + "../test-results/onboarding", + locale, + ) + fs.mkdirSync(outDir, { recursive: true }) + + for (const shot of SHOTS) { + const params = new URLSearchParams({ step: shot.step }) + if (shot.phase) params.set("phase", shot.phase) + await page.goto( + `chrome-extension://${extensionId}/src/onboarding_page.html?${params}`, + ) + // Let entrance animations and the selection-demo cursor settle + // into a representative frame before capturing. + await page.waitForTimeout(700) + await page.screenshot({ path: path.join(outDir, `${shot.name}.png`) }) + } + } finally { + await context.close() + } + }) + } +}) diff --git a/packages/extension/package.json b/packages/extension/package.json index 08c5b87c..57a20659 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -41,6 +41,7 @@ "@supabase/supabase-js": "^2.105.4", "@testing-library/user-event": "^14.6.0", "bowser": "^2.14.1", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "colorthief": "^2.6.0", @@ -65,6 +66,7 @@ "@crxjs/vite-plugin": "^2.0.0-beta.30", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.0", + "@types/canvas-confetti": "^1.9.0", "@types/chrome": "^0.1.36", "@types/jest": "^29.5.11", "@types/platform": "^1.3.6", diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index bee873f1..ea0b61aa 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Mit Selection Command kannst du mit einem Klick suchen, die KI fragen oder Links in der Vorschau ansehen." }, + "onboarding_selectHint": { + "message": "Text unten durch Ziehen markieren" + }, + "onboarding_railSelect": { + "message": "Auswählen" + }, + "onboarding_railCommand": { + "message": "Befehl" + }, + "onboarding_railResultSearch": { + "message": "Suchen" + }, "onboarding_step1Callout": { "message": "Klicke auf diesen Befehl, um mit Google zu suchen." }, + "onboarding_step1ReturnHint": { + "message": "Wechsle zurück zu diesem Tab, dann verschwindet diese Meldung automatisch." + }, "onboarding_step1ValueMessage": { "message": "Fertig – jetzt kannst du alles, was du markierst, sofort durchsuchen." }, - "onboarding_nextButton": { - "message": "Weiter" - }, "onboarding_step2Explain": { "message": "Als Nächstes fragen wir die KI zum folgenden Text." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "Führe diesen Befehl aus, um deine Auswahl über einen voreingestellten KI-Prompt zu senden." }, + "onboarding_railResultAi": { + "message": "KI" + }, "onboarding_step2ValueMessage": { "message": "Jetzt kannst du die KI direkt auf der Seite, die du liest, zu allem fragen, was du markierst." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "Beispiel-Link" }, + "onboarding_clickKeycap": { + "message": "Klick" + }, + "onboarding_railResultPreview": { + "message": "Vorschau" + }, "onboarding_step3ValueMessage": { "message": "Du kannst Links in der Vorschau ansehen, ohne die Seite zu verlassen." }, + "onboarding_nextButton": { + "message": "Weiter" + }, "onboarding_step4Explain": { - "message": "Die gerade ausprobierten Befehle kannst du jederzeit in den Einstellungen bearbeiten, entfernen oder neue hinzufügen." + "message": "Die ausprobierten Befehle kannst du jederzeit in den Einstellungen oder im Selection Command Hub hinzufügen oder bearbeiten." }, - "onboarding_finishButton": { - "message": "Fertig" + "onboarding_step4SettingsHeading": { + "message": "In den Einstellungen bearbeiten" + }, + "onboarding_step4HubHeading": { + "message": "Mehr im Command Hub finden" + }, + "onboarding_step4HubAddChip": { + "message": "Hinzufügen" }, "onboarding_completeTitle": { "message": "Alles bereit!" @@ -1366,21 +1396,6 @@ "message": "Einstellungen öffnen" }, "onboarding_closeButton": { - "message": "Schließen" - }, - "onboarding_step1ReturnHint": { - "message": "Wechsle zurück zu diesem Tab, dann verschwindet diese Meldung automatisch." - }, - "onboarding_step1Pattern": { - "message": "Auswählen → Befehl → Suchen" - }, - "onboarding_step2Pattern": { - "message": "Auswählen → Befehl → KI fragen" - }, - "onboarding_step3Pattern": { - "message": "Auswählen → Befehl → Vorschau" - }, - "onboarding_step4HubLink": { - "message": "Command Hub durchsuchen" + "message": "Schließen und ausprobieren" } } diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 79d04b62..885f6cfa 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1344,18 +1344,34 @@ "message": "Selection Command lets you search, ask AI, or preview links in a single click.", "description": "Onboarding Step1 sample text the user is asked to select to trigger the real popup menu." }, + "onboarding_selectHint": { + "message": "Drag to select the text below", + "description": "Onboarding Step1/2 caption instructing the user to drag-select the sample text, shown above the target text card during the selection demo." + }, + "onboarding_railSelect": { + "message": "Select", + "description": "Onboarding progress rail label for the 'select' beat of the select -> command -> result pattern (Steps 1-3)." + }, + "onboarding_railCommand": { + "message": "Command", + "description": "Onboarding progress rail label for the 'command' beat." + }, + "onboarding_railResultSearch": { + "message": "Search", + "description": "Onboarding progress rail label for Step1's result beat (running a search command)." + }, "onboarding_step1Callout": { "message": "Click this command to search Google.", "description": "Onboarding Step1 callout/speech-bubble text pointing at the default Google search command in the popup menu." }, + "onboarding_step1ReturnHint": { + "message": "Switch back to this tab and this message disappears automatically.", + "description": "Onboarding Step1 hint shown while waiting for the user to return from the search command's popup/tab." + }, "onboarding_step1ValueMessage": { "message": "That's it - you can now search for anything you select in an instant.", "description": "Onboarding Step1 value message shown after the user runs the search command and returns to the onboarding tab." }, - "onboarding_nextButton": { - "message": "Next", - "description": "Button that advances from a step's value message to the next onboarding step." - }, "onboarding_step2Explain": { "message": "Next, let's ask AI about the text below.", "description": "Onboarding Step2 (AiPrompt command) instruction shown before the user selects the sample text." @@ -1368,6 +1384,10 @@ "message": "Click this command to send your selection through a pre-set AI prompt.", "description": "Onboarding Step2 callout/speech-bubble text pointing at the onboarding's dedicated AiPrompt command in the popup menu." }, + "onboarding_railResultAi": { + "message": "AI", + "description": "Onboarding progress rail label for Step2's result beat (asking AI)." + }, "onboarding_step2ValueMessage": { "message": "You can now ask AI about anything you select, right from the page you're reading.", "description": "Onboarding Step2 value message shown after the user runs the AiPrompt command." @@ -1380,17 +1400,37 @@ "message": "Sample Link", "description": "Label of the sample link shown in Onboarding Step3 for the user to Shift+click." }, + "onboarding_clickKeycap": { + "message": "Click", + "description": "Onboarding Step3 keycap label for the mouse click half of the Shift+click gesture, shown next to the Shift keycap." + }, + "onboarding_railResultPreview": { + "message": "Preview", + "description": "Onboarding progress rail label for Step3's result beat (previewing a link)." + }, "onboarding_step3ValueMessage": { "message": "You can preview links without leaving the page.", "description": "Onboarding Step3 value message shown after the user triggers Link Preview." }, + "onboarding_nextButton": { + "message": "Next", + "description": "Button that advances from a step's value message to the next onboarding step." + }, "onboarding_step4Explain": { - "message": "The commands you tried can be edited, removed, or added to anytime from Settings.", - "description": "Onboarding Step4 (Customize) explanation shown after Link Preview, before the final Complete screen." + "message": "The commands you tried can always be added to or edited from the settings screen or the Selection Command Hub.", + "description": "Onboarding Step4 (Customize) explanation shown after Link Preview, before the final Complete screen. Now covers both the settings screen and the Command Hub." }, - "onboarding_finishButton": { - "message": "Finish", - "description": "Onboarding Step4 button that advances to the final Complete screen." + "onboarding_step4SettingsHeading": { + "message": "Edit in Settings", + "description": "Onboarding Step4 heading above the settings-panel mockup, pointing to the extension's own command settings screen." + }, + "onboarding_step4HubHeading": { + "message": "Find more on the Command Hub", + "description": "Onboarding Step4 heading above the Command Hub mockup, pointing to where users can discover more commands." + }, + "onboarding_step4HubAddChip": { + "message": "Add", + "description": "Onboarding Step4 chip label on each Command Hub item, indicating it can be added." }, "onboarding_completeTitle": { "message": "You're all set!", @@ -1405,27 +1445,7 @@ "description": "Onboarding Step5 button that opens the extension's options page." }, "onboarding_closeButton": { - "message": "Close", - "description": "Onboarding Step5 button that closes the onboarding tab." - }, - "onboarding_step1ReturnHint": { - "message": "Switch back to this tab and this message disappears automatically.", - "description": "Onboarding Step1 hint shown while waiting for the user to return from the search command's popup/tab." - }, - "onboarding_step1Pattern": { - "message": "Select → Command → Search", - "description": "Onboarding Step1 short pattern caption reinforcing the select-command-result mental model." - }, - "onboarding_step2Pattern": { - "message": "Select → Command → Ask AI", - "description": "Onboarding Step2 short pattern caption reinforcing the select-command-result mental model." - }, - "onboarding_step3Pattern": { - "message": "Select → Command → Preview", - "description": "Onboarding Step3 short pattern caption reinforcing the select-command-result mental model." - }, - "onboarding_step4HubLink": { - "message": "Browse the Command Hub", - "description": "Onboarding Step4 link label pointing to the Command Hub to discover more commands." + "message": "Close and try it out", + "description": "Onboarding Step5 button that closes the onboarding tab and returns the user to the page they were on - doubles as the step's main call to action." } } diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 02d0cb25..ab8ebc56 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Con Selection Command puedes buscar, preguntar a la IA o previsualizar enlaces con un solo clic." }, + "onboarding_selectHint": { + "message": "Arrastra para seleccionar el texto de abajo" + }, + "onboarding_railSelect": { + "message": "Seleccionar" + }, + "onboarding_railCommand": { + "message": "Comando" + }, + "onboarding_railResultSearch": { + "message": "Buscar" + }, "onboarding_step1Callout": { "message": "Haz clic en este comando para buscar en Google." }, + "onboarding_step1ReturnHint": { + "message": "Vuelve a esta pestaña y este mensaje desaparecerá automáticamente." + }, "onboarding_step1ValueMessage": { "message": "Listo: ahora puedes buscar al instante cualquier texto que selecciones." }, - "onboarding_nextButton": { - "message": "Siguiente" - }, "onboarding_step2Explain": { "message": "A continuación, preguntemos a la IA sobre el siguiente texto." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "Haz clic en este comando para enviar tu selección a través de un prompt de IA preconfigurado." }, + "onboarding_railResultAi": { + "message": "IA" + }, "onboarding_step2ValueMessage": { "message": "Ahora puedes preguntarle a la IA sobre cualquier cosa que selecciones, sin salir de la página que estás leyendo." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "Enlace de ejemplo" }, + "onboarding_clickKeycap": { + "message": "Clic" + }, + "onboarding_railResultPreview": { + "message": "Vista previa" + }, "onboarding_step3ValueMessage": { "message": "Puedes previsualizar enlaces sin salir de la página." }, + "onboarding_nextButton": { + "message": "Siguiente" + }, "onboarding_step4Explain": { - "message": "Los comandos que acabas de probar se pueden editar, eliminar o añadir en cualquier momento desde la configuración." + "message": "Los comandos que probaste se pueden añadir o editar en cualquier momento desde la configuración o el Selection Command Hub." + }, + "onboarding_step4SettingsHeading": { + "message": "Editar en Configuración" }, - "onboarding_finishButton": { - "message": "Finalizar" + "onboarding_step4HubHeading": { + "message": "Descubre más en el Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Añadir" }, "onboarding_completeTitle": { "message": "¡Todo listo!" @@ -1366,21 +1396,6 @@ "message": "Abrir configuración" }, "onboarding_closeButton": { - "message": "Cerrar" - }, - "onboarding_step1ReturnHint": { - "message": "Vuelve a esta pestaña y este mensaje desaparecerá automáticamente." - }, - "onboarding_step1Pattern": { - "message": "Selección → Comando → Búsqueda" - }, - "onboarding_step2Pattern": { - "message": "Selección → Comando → IA" - }, - "onboarding_step3Pattern": { - "message": "Selección → Comando → Vista previa" - }, - "onboarding_step4HubLink": { - "message": "Explorar el Command Hub" + "message": "Cerrar y probarlo" } } diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 333fbc9c..949b8ac8 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Avec Selection Command, vous pouvez rechercher, interroger l'IA ou prévisualiser des liens en un clic." }, + "onboarding_selectHint": { + "message": "Faites glisser pour sélectionner le texte ci-dessous" + }, + "onboarding_railSelect": { + "message": "Sélection" + }, + "onboarding_railCommand": { + "message": "Commande" + }, + "onboarding_railResultSearch": { + "message": "Recherche" + }, "onboarding_step1Callout": { "message": "Cliquez sur cette commande pour rechercher sur Google." }, + "onboarding_step1ReturnHint": { + "message": "Revenez à cet onglet et ce message disparaîtra automatiquement." + }, "onboarding_step1ValueMessage": { "message": "Et voilà — vous pouvez désormais rechercher instantanément tout ce que vous sélectionnez." }, - "onboarding_nextButton": { - "message": "Suivant" - }, "onboarding_step2Explain": { "message": "Ensuite, interrogeons l'IA à propos du texte ci-dessous." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "Cliquez sur cette commande pour envoyer votre sélection via un prompt IA préconfiguré." }, + "onboarding_railResultAi": { + "message": "IA" + }, "onboarding_step2ValueMessage": { "message": "Vous pouvez désormais interroger l'IA sur tout ce que vous sélectionnez, directement depuis la page que vous lisez." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "Lien d'exemple" }, + "onboarding_clickKeycap": { + "message": "Clic" + }, + "onboarding_railResultPreview": { + "message": "Aperçu" + }, "onboarding_step3ValueMessage": { "message": "Vous pouvez prévisualiser des liens sans quitter la page." }, + "onboarding_nextButton": { + "message": "Suivant" + }, "onboarding_step4Explain": { - "message": "Les commandes que vous venez d'essayer peuvent être modifiées, supprimées ou complétées à tout moment depuis les paramètres." + "message": "Les commandes que vous avez essayées peuvent être ajoutées ou modifiées à tout moment depuis les paramètres ou le Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Terminer" + "onboarding_step4SettingsHeading": { + "message": "Modifier dans les paramètres" + }, + "onboarding_step4HubHeading": { + "message": "Découvrir plus sur le Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Ajouter" }, "onboarding_completeTitle": { "message": "Tout est prêt !" @@ -1366,21 +1396,6 @@ "message": "Ouvrir les paramètres" }, "onboarding_closeButton": { - "message": "Fermer" - }, - "onboarding_step1ReturnHint": { - "message": "Revenez à cet onglet et ce message disparaîtra automatiquement." - }, - "onboarding_step1Pattern": { - "message": "Sélection → Commande → Recherche" - }, - "onboarding_step2Pattern": { - "message": "Sélection → Commande → IA" - }, - "onboarding_step3Pattern": { - "message": "Sélection → Commande → Aperçu" - }, - "onboarding_step4HubLink": { - "message": "Parcourir le Command Hub" + "message": "Fermer et essayer" } } diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index ddbd64cd..ef8b47d1 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Selection Command से आप एक क्लिक में सर्च कर सकते हैं, AI से पूछ सकते हैं, या लिंक का पूर्वावलोकन देख सकते हैं।" }, + "onboarding_selectHint": { + "message": "नीचे दिए गए टेक्स्ट को खींचकर चुनें" + }, + "onboarding_railSelect": { + "message": "चयन" + }, + "onboarding_railCommand": { + "message": "कमांड" + }, + "onboarding_railResultSearch": { + "message": "खोजें" + }, "onboarding_step1Callout": { "message": "इस कमांड को चलाने पर Google पर सर्च होगा।" }, + "onboarding_step1ReturnHint": { + "message": "इस टैब पर वापस आएं और यह संदेश अपने आप गायब हो जाएगा।" + }, "onboarding_step1ValueMessage": { "message": "बस इतना ही — अब आप जो भी टेक्स्ट चुनें, उसे तुरंत सर्च कर सकते हैं।" }, - "onboarding_nextButton": { - "message": "आगे" - }, "onboarding_step2Explain": { "message": "अब, नीचे दिए गए टेक्स्ट के बारे में AI से पूछते हैं।" }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "इस कमांड को चलाने पर आपका चयन पहले से सेट किए गए प्रॉम्प्ट के ज़रिए AI को भेजा जाता है।" }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "अब आप जो पेज पढ़ रहे हैं, वहीं से चुनी हुई किसी भी चीज़ के बारे में AI से पूछ सकते हैं।" }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "नमूना लिंक" }, + "onboarding_clickKeycap": { + "message": "क्लिक" + }, + "onboarding_railResultPreview": { + "message": "पूर्वावलोकन" + }, "onboarding_step3ValueMessage": { "message": "अब आप पेज छोड़े बिना लिंक का पूर्वावलोकन कर सकते हैं।" }, + "onboarding_nextButton": { + "message": "आगे" + }, "onboarding_step4Explain": { - "message": "अभी आज़माए गए कमांड को आप सेटिंग्स में कभी भी संपादित, हटा या नए जोड़ सकते हैं।" + "message": "आपने जो कमांड आज़माए, उन्हें आप सेटिंग्स या Selection Command Hub से कभी भी जोड़ या संपादित कर सकते हैं।" + }, + "onboarding_step4SettingsHeading": { + "message": "सेटिंग्स में संपादित करें" }, - "onboarding_finishButton": { - "message": "समाप्त करें" + "onboarding_step4HubHeading": { + "message": "Command Hub पर और खोजें" + }, + "onboarding_step4HubAddChip": { + "message": "जोड़ें" }, "onboarding_completeTitle": { "message": "सब तैयार है!" @@ -1366,21 +1396,6 @@ "message": "सेटिंग्स खोलें" }, "onboarding_closeButton": { - "message": "बंद करें" - }, - "onboarding_step1ReturnHint": { - "message": "इस टैब पर वापस आएं और यह संदेश अपने आप गायब हो जाएगा।" - }, - "onboarding_step1Pattern": { - "message": "चयन → कमांड → खोज" - }, - "onboarding_step2Pattern": { - "message": "चयन → कमांड → AI" - }, - "onboarding_step3Pattern": { - "message": "चयन → कमांड → पूर्वावलोकन" - }, - "onboarding_step4HubLink": { - "message": "Command Hub देखें" + "message": "बंद करें और आज़माएं" } } diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index e10a6a54..e1a1482c 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1323,15 +1323,27 @@ "onboarding_step1TargetText": { "message": "Dengan Selection Command, Anda bisa mencari, bertanya ke AI, atau melihat pratinjau tautan hanya dengan satu klik." }, + "onboarding_selectHint": { + "message": "Seret untuk memilih teks di bawah" + }, + "onboarding_railSelect": { + "message": "Pilih" + }, + "onboarding_railCommand": { + "message": "Perintah" + }, + "onboarding_railResultSearch": { + "message": "Cari" + }, "onboarding_step1Callout": { "message": "Klik perintah ini untuk mencari di Google." }, + "onboarding_step1ReturnHint": { + "message": "Kembali ke tab ini dan pesan ini akan hilang secara otomatis." + }, "onboarding_step1ValueMessage": { "message": "Selesai — sekarang Anda bisa langsung mencari apa pun yang Anda pilih." }, - "onboarding_nextButton": { - "message": "Berikutnya" - }, "onboarding_step2Explain": { "message": "Selanjutnya, mari tanyakan ke AI tentang teks di bawah ini." }, @@ -1341,6 +1353,9 @@ "onboarding_step2Callout": { "message": "Klik perintah ini untuk mengirim teks yang dipilih melalui prompt AI yang sudah diatur sebelumnya." }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "Sekarang Anda bisa bertanya ke AI tentang apa pun yang Anda pilih, langsung dari halaman yang sedang Anda baca." }, @@ -1350,14 +1365,29 @@ "onboarding_step3LinkLabel": { "message": "Tautan Contoh" }, + "onboarding_clickKeycap": { + "message": "Klik" + }, + "onboarding_railResultPreview": { + "message": "Pratinjau" + }, "onboarding_step3ValueMessage": { "message": "Anda bisa melihat pratinjau tautan tanpa meninggalkan halaman." }, + "onboarding_nextButton": { + "message": "Berikutnya" + }, "onboarding_step4Explain": { - "message": "Perintah yang baru saja Anda coba dapat diedit, dihapus, atau ditambahkan kapan saja dari Pengaturan." + "message": "Perintah yang Anda coba dapat ditambahkan atau diedit kapan saja dari Pengaturan atau Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Selesai" + "onboarding_step4SettingsHeading": { + "message": "Edit di Pengaturan" + }, + "onboarding_step4HubHeading": { + "message": "Temukan lebih banyak di Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Tambah" }, "onboarding_completeTitle": { "message": "Semua sudah siap!" @@ -1369,21 +1399,6 @@ "message": "Buka Pengaturan" }, "onboarding_closeButton": { - "message": "Tutup" - }, - "onboarding_step1ReturnHint": { - "message": "Kembali ke tab ini dan pesan ini akan hilang secara otomatis." - }, - "onboarding_step1Pattern": { - "message": "Pilih → Perintah → Cari" - }, - "onboarding_step2Pattern": { - "message": "Pilih → Perintah → AI" - }, - "onboarding_step3Pattern": { - "message": "Pilih → Perintah → Pratinjau" - }, - "onboarding_step4HubLink": { - "message": "Jelajahi Command Hub" + "message": "Tutup dan coba sekarang" } } diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index f13172ee..6d36b2ef 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Con Selection Command puoi cercare, chiedere all'IA o visualizzare l'anteprima dei link con un solo clic." }, + "onboarding_selectHint": { + "message": "Trascina per selezionare il testo qui sotto" + }, + "onboarding_railSelect": { + "message": "Seleziona" + }, + "onboarding_railCommand": { + "message": "Comando" + }, + "onboarding_railResultSearch": { + "message": "Cerca" + }, "onboarding_step1Callout": { "message": "Fai clic su questo comando per cercare su Google." }, + "onboarding_step1ReturnHint": { + "message": "Torna a questa scheda e il messaggio scomparirà automaticamente." + }, "onboarding_step1ValueMessage": { "message": "Fatto: ora puoi cercare all'istante qualsiasi cosa selezioni." }, - "onboarding_nextButton": { - "message": "Avanti" - }, "onboarding_step2Explain": { "message": "Ora chiediamo all'IA il testo qui sotto." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "Fai clic su questo comando per inviare la selezione tramite un prompt IA preimpostato." }, + "onboarding_railResultAi": { + "message": "IA" + }, "onboarding_step2ValueMessage": { "message": "Ora puoi chiedere all'IA qualsiasi cosa selezioni, direttamente dalla pagina che stai leggendo." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "Link di esempio" }, + "onboarding_clickKeycap": { + "message": "Clic" + }, + "onboarding_railResultPreview": { + "message": "Anteprima" + }, "onboarding_step3ValueMessage": { "message": "Puoi visualizzare l'anteprima dei link senza lasciare la pagina." }, + "onboarding_nextButton": { + "message": "Avanti" + }, "onboarding_step4Explain": { - "message": "I comandi che hai appena provato possono essere modificati, rimossi o aggiunti in qualsiasi momento dalle impostazioni." + "message": "I comandi che hai provato possono essere aggiunti o modificati in qualsiasi momento dalle impostazioni o dal Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Fine" + "onboarding_step4SettingsHeading": { + "message": "Modifica nelle impostazioni" + }, + "onboarding_step4HubHeading": { + "message": "Scopri altro nel Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Aggiungi" }, "onboarding_completeTitle": { "message": "Tutto pronto!" @@ -1366,21 +1396,6 @@ "message": "Apri impostazioni" }, "onboarding_closeButton": { - "message": "Chiudi" - }, - "onboarding_step1ReturnHint": { - "message": "Torna a questa scheda e il messaggio scomparirà automaticamente." - }, - "onboarding_step1Pattern": { - "message": "Selezione → Comando → Ricerca" - }, - "onboarding_step2Pattern": { - "message": "Selezione → Comando → IA" - }, - "onboarding_step3Pattern": { - "message": "Selezione → Comando → Anteprima" - }, - "onboarding_step4HubLink": { - "message": "Esplora il Command Hub" + "message": "Chiudi e provalo" } } diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index c3a02525..580466ee 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1317,15 +1317,27 @@ "onboarding_step1TargetText": { "message": "Selection Commandを使うと、検索・AIへの質問・リンクのプレビューをワンクリックで実行できます。" }, + "onboarding_selectHint": { + "message": "下のテキストをドラッグして選択" + }, + "onboarding_railSelect": { + "message": "選択" + }, + "onboarding_railCommand": { + "message": "コマンド" + }, + "onboarding_railResultSearch": { + "message": "検索" + }, "onboarding_step1Callout": { "message": "このコマンドを実行すると、Googleで検索できます。" }, + "onboarding_step1ReturnHint": { + "message": "もとの画面を選択すると、自動的に消えます" + }, "onboarding_step1ValueMessage": { "message": "これで、気になった情報をすぐに検索できました。" }, - "onboarding_nextButton": { - "message": "次へ" - }, "onboarding_step2Explain": { "message": "次は、選択したテキストをAIに聞いてみましょう。" }, @@ -1335,6 +1347,9 @@ "onboarding_step2Callout": { "message": "このコマンドを実行すると、選択したテキストをあらかじめ設定したプロンプトでAIに送れます。" }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "Webページを読みながら、その場でAIに質問できました。" }, @@ -1344,14 +1359,29 @@ "onboarding_step3LinkLabel": { "message": "サンプルリンク" }, + "onboarding_clickKeycap": { + "message": "クリック" + }, + "onboarding_railResultPreview": { + "message": "プレビュー" + }, "onboarding_step3ValueMessage": { "message": "リンク先をその場で確認できました。" }, + "onboarding_nextButton": { + "message": "次へ" + }, "onboarding_step4Explain": { - "message": "ここまで試したコマンドは、設定画面からいつでも編集・削除・追加できます。" + "message": "ここまで試したコマンドは、設定画面またはSelection Command Hubから、いつでも追加・編集できます。" }, - "onboarding_finishButton": { - "message": "完了" + "onboarding_step4SettingsHeading": { + "message": "設定画面で編集する" + }, + "onboarding_step4HubHeading": { + "message": "Command Hubで探す" + }, + "onboarding_step4HubAddChip": { + "message": "追加" }, "onboarding_completeTitle": { "message": "準備は完了です!" @@ -1363,21 +1393,6 @@ "message": "設定を開く" }, "onboarding_closeButton": { - "message": "閉じる" - }, - "onboarding_step1ReturnHint": { - "message": "もとの画面を選択すると、自動的に消えます" - }, - "onboarding_step1Pattern": { - "message": "選択 → コマンド → 検索" - }, - "onboarding_step2Pattern": { - "message": "選択 → コマンド → AI" - }, - "onboarding_step3Pattern": { - "message": "選択 → コマンド → プレビュー" - }, - "onboarding_step4HubLink": { - "message": "Command Hubを見る" + "message": "閉じて使ってみる" } } diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index cc32d58a..01b05f96 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "Selection Command를 사용하면 검색, AI에게 질문, 링크 미리보기를 클릭 한 번으로 실행할 수 있습니다." }, + "onboarding_selectHint": { + "message": "아래 텍스트를 드래그하여 선택하세요" + }, + "onboarding_railSelect": { + "message": "선택" + }, + "onboarding_railCommand": { + "message": "명령" + }, + "onboarding_railResultSearch": { + "message": "검색" + }, "onboarding_step1Callout": { "message": "이 명령을 실행하면 Google로 검색할 수 있습니다." }, + "onboarding_step1ReturnHint": { + "message": "이 탭으로 돌아오면 이 메시지는 자동으로 사라집니다." + }, "onboarding_step1ValueMessage": { "message": "이제 궁금했던 정보를 바로 검색할 수 있게 되었습니다." }, - "onboarding_nextButton": { - "message": "다음" - }, "onboarding_step2Explain": { "message": "다음으로 아래 텍스트에 대해 AI에게 물어봅시다." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "이 명령을 실행하면 선택한 텍스트를 미리 설정된 프롬프트로 AI에 보낼 수 있습니다." }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "이제 페이지를 읽으면서 바로 AI에게 무엇이든 물어볼 수 있습니다." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "샘플 링크" }, + "onboarding_clickKeycap": { + "message": "클릭" + }, + "onboarding_railResultPreview": { + "message": "미리보기" + }, "onboarding_step3ValueMessage": { "message": "페이지를 벗어나지 않고 링크를 미리 볼 수 있습니다." }, + "onboarding_nextButton": { + "message": "다음" + }, "onboarding_step4Explain": { - "message": "지금까지 사용해본 명령어는 설정 화면에서 언제든지 편집, 삭제하거나 새로 추가할 수 있습니다." + "message": "지금까지 사용해본 명령어는 설정 화면이나 Selection Command Hub에서 언제든지 추가하거나 편집할 수 있습니다." }, - "onboarding_finishButton": { - "message": "완료" + "onboarding_step4SettingsHeading": { + "message": "설정에서 편집하기" + }, + "onboarding_step4HubHeading": { + "message": "Command Hub에서 더 찾아보기" + }, + "onboarding_step4HubAddChip": { + "message": "추가" }, "onboarding_completeTitle": { "message": "준비가 완료되었습니다!" @@ -1366,21 +1396,6 @@ "message": "설정 열기" }, "onboarding_closeButton": { - "message": "닫기" - }, - "onboarding_step1ReturnHint": { - "message": "이 탭으로 돌아오면 이 메시지는 자동으로 사라집니다." - }, - "onboarding_step1Pattern": { - "message": "선택 → 명령 → 검색" - }, - "onboarding_step2Pattern": { - "message": "선택 → 명령 → AI" - }, - "onboarding_step3Pattern": { - "message": "선택 → 명령 → 미리보기" - }, - "onboarding_step4HubLink": { - "message": "Command Hub 둘러보기" + "message": "닫고 사용해보기" } } diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index ca1ab6e5..8885be47 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1323,15 +1323,27 @@ "onboarding_step1TargetText": { "message": "Dengan Selection Command, anda boleh mencari, bertanya kepada AI, atau pratonton pautan hanya dengan satu klik." }, + "onboarding_selectHint": { + "message": "Seret untuk pilih teks di bawah" + }, + "onboarding_railSelect": { + "message": "Pilih" + }, + "onboarding_railCommand": { + "message": "Arahan" + }, + "onboarding_railResultSearch": { + "message": "Cari" + }, "onboarding_step1Callout": { "message": "Klik arahan ini untuk mencari di Google." }, + "onboarding_step1ReturnHint": { + "message": "Kembali ke tab ini dan mesej ini akan hilang secara automatik." + }, "onboarding_step1ValueMessage": { "message": "Selesai — kini anda boleh mencari dengan segera apa sahaja yang anda pilih." }, - "onboarding_nextButton": { - "message": "Seterusnya" - }, "onboarding_step2Explain": { "message": "Seterusnya, mari tanya AI tentang teks di bawah ini." }, @@ -1341,6 +1353,9 @@ "onboarding_step2Callout": { "message": "Klik perintah ini untuk menghantar pilihan anda melalui gesaan AI yang telah ditetapkan." }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "Kini anda boleh bertanya kepada AI tentang apa sahaja yang anda pilih, terus daripada halaman yang sedang anda baca." }, @@ -1350,14 +1365,29 @@ "onboarding_step3LinkLabel": { "message": "Pautan Contoh" }, + "onboarding_clickKeycap": { + "message": "Klik" + }, + "onboarding_railResultPreview": { + "message": "Pratonton" + }, "onboarding_step3ValueMessage": { "message": "Anda boleh pratonton pautan tanpa meninggalkan halaman." }, + "onboarding_nextButton": { + "message": "Seterusnya" + }, "onboarding_step4Explain": { - "message": "Perintah yang baru anda cuba boleh diedit, dibuang atau ditambah pada bila-bila masa dari Tetapan." + "message": "Arahan yang anda cuba boleh ditambah atau diedit pada bila-bila masa dari Tetapan atau Selection Command Hub." + }, + "onboarding_step4SettingsHeading": { + "message": "Edit dalam Tetapan" }, - "onboarding_finishButton": { - "message": "Selesai" + "onboarding_step4HubHeading": { + "message": "Cari lagi di Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Tambah" }, "onboarding_completeTitle": { "message": "Semuanya sedia!" @@ -1369,21 +1399,6 @@ "message": "Buka Tetapan" }, "onboarding_closeButton": { - "message": "Tutup" - }, - "onboarding_step1ReturnHint": { - "message": "Kembali ke tab ini dan mesej ini akan hilang secara automatik." - }, - "onboarding_step1Pattern": { - "message": "Pilih → Perintah → Cari" - }, - "onboarding_step2Pattern": { - "message": "Pilih → Perintah → AI" - }, - "onboarding_step3Pattern": { - "message": "Pilih → Perintah → Pratonton" - }, - "onboarding_step4HubLink": { - "message": "Terokai Command Hub" + "message": "Tutup dan cuba sekarang" } } diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 907928a1..0b0fe1f4 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1323,15 +1323,27 @@ "onboarding_step1TargetText": { "message": "Com o Selection Command, você pode pesquisar, perguntar à IA ou pré-visualizar links com um clique." }, + "onboarding_selectHint": { + "message": "Arraste para selecionar o texto abaixo" + }, + "onboarding_railSelect": { + "message": "Selecionar" + }, + "onboarding_railCommand": { + "message": "Comando" + }, + "onboarding_railResultSearch": { + "message": "Pesquisar" + }, "onboarding_step1Callout": { "message": "Clique neste comando para pesquisar no Google." }, + "onboarding_step1ReturnHint": { + "message": "Volte para esta aba e esta mensagem desaparece automaticamente." + }, "onboarding_step1ValueMessage": { "message": "Pronto — agora você pode pesquisar instantaneamente qualquer coisa que selecionar." }, - "onboarding_nextButton": { - "message": "Próximo" - }, "onboarding_step2Explain": { "message": "A seguir, vamos perguntar à IA sobre o texto abaixo." }, @@ -1341,6 +1353,9 @@ "onboarding_step2Callout": { "message": "Clique neste comando para enviar sua seleção por um prompt de IA pré-configurado." }, + "onboarding_railResultAi": { + "message": "IA" + }, "onboarding_step2ValueMessage": { "message": "Agora você pode perguntar à IA sobre qualquer coisa que selecionar, direto da página que está lendo." }, @@ -1350,14 +1365,29 @@ "onboarding_step3LinkLabel": { "message": "Link de exemplo" }, + "onboarding_clickKeycap": { + "message": "Clique" + }, + "onboarding_railResultPreview": { + "message": "Pré-visualização" + }, "onboarding_step3ValueMessage": { "message": "Você pode pré-visualizar links sem sair da página." }, + "onboarding_nextButton": { + "message": "Próximo" + }, "onboarding_step4Explain": { - "message": "Os comandos que você experimentou podem ser editados, removidos ou adicionados a qualquer momento nas configurações." + "message": "Os comandos que você experimentou podem ser adicionados ou editados a qualquer momento nas configurações ou no Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Concluir" + "onboarding_step4SettingsHeading": { + "message": "Editar nas Configurações" + }, + "onboarding_step4HubHeading": { + "message": "Encontre mais no Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Adicionar" }, "onboarding_completeTitle": { "message": "Tudo pronto!" @@ -1369,21 +1399,6 @@ "message": "Abrir configurações" }, "onboarding_closeButton": { - "message": "Fechar" - }, - "onboarding_step1ReturnHint": { - "message": "Volte para esta aba e esta mensagem desaparece automaticamente." - }, - "onboarding_step1Pattern": { - "message": "Seleção → Comando → Busca" - }, - "onboarding_step2Pattern": { - "message": "Seleção → Comando → IA" - }, - "onboarding_step3Pattern": { - "message": "Seleção → Comando → Pré-visualização" - }, - "onboarding_step4HubLink": { - "message": "Explorar o Command Hub" + "message": "Fechar e experimentar" } } diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index f952c5e7..ae3f2b91 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1323,15 +1323,27 @@ "onboarding_step1TargetText": { "message": "Com o Selection Command, pode pesquisar, perguntar à IA ou pré-visualizar links com um único clique." }, + "onboarding_selectHint": { + "message": "Arraste para selecionar o texto abaixo" + }, + "onboarding_railSelect": { + "message": "Selecionar" + }, + "onboarding_railCommand": { + "message": "Comando" + }, + "onboarding_railResultSearch": { + "message": "Pesquisar" + }, "onboarding_step1Callout": { "message": "Clique neste comando para pesquisar no Google." }, + "onboarding_step1ReturnHint": { + "message": "Volte a este separador e esta mensagem desaparece automaticamente." + }, "onboarding_step1ValueMessage": { "message": "Pronto — agora já pode pesquisar instantaneamente tudo o que selecionar." }, - "onboarding_nextButton": { - "message": "Seguinte" - }, "onboarding_step2Explain": { "message": "De seguida, vamos perguntar à IA sobre o texto abaixo." }, @@ -1341,6 +1353,9 @@ "onboarding_step2Callout": { "message": "Clique neste comando para enviar a sua seleção através de um prompt de IA pré-configurado." }, + "onboarding_railResultAi": { + "message": "IA" + }, "onboarding_step2ValueMessage": { "message": "Agora já pode perguntar à IA sobre o que selecionar, diretamente a partir da página que está a ler." }, @@ -1350,14 +1365,29 @@ "onboarding_step3LinkLabel": { "message": "Ligação de exemplo" }, + "onboarding_clickKeycap": { + "message": "Clique" + }, + "onboarding_railResultPreview": { + "message": "Pré-visualização" + }, "onboarding_step3ValueMessage": { "message": "Pode pré-visualizar ligações sem sair da página." }, + "onboarding_nextButton": { + "message": "Seguinte" + }, "onboarding_step4Explain": { - "message": "Os comandos que acabou de experimentar podem ser editados, removidos ou adicionados a qualquer momento nas definições." + "message": "Os comandos que acabou de experimentar podem ser adicionados ou editados a qualquer momento nas definições ou no Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Concluir" + "onboarding_step4SettingsHeading": { + "message": "Editar nas Definições" + }, + "onboarding_step4HubHeading": { + "message": "Descubra mais no Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Adicionar" }, "onboarding_completeTitle": { "message": "Tudo pronto!" @@ -1369,21 +1399,6 @@ "message": "Abrir definições" }, "onboarding_closeButton": { - "message": "Fechar" - }, - "onboarding_step1ReturnHint": { - "message": "Volte a este separador e esta mensagem desaparece automaticamente." - }, - "onboarding_step1Pattern": { - "message": "Seleção → Comando → Pesquisa" - }, - "onboarding_step2Pattern": { - "message": "Seleção → Comando → IA" - }, - "onboarding_step3Pattern": { - "message": "Seleção → Comando → Pré-visualização" - }, - "onboarding_step4HubLink": { - "message": "Explorar o Command Hub" + "message": "Fechar e experimentar" } } diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index ff5e1705..bbc6b8ef 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "С Selection Command вы можете искать, спрашивать ИИ или просматривать ссылки одним щелчком." }, + "onboarding_selectHint": { + "message": "Перетащите, чтобы выделить текст ниже" + }, + "onboarding_railSelect": { + "message": "Выделение" + }, + "onboarding_railCommand": { + "message": "Команда" + }, + "onboarding_railResultSearch": { + "message": "Поиск" + }, "onboarding_step1Callout": { "message": "Нажмите эту команду, чтобы выполнить поиск в Google." }, + "onboarding_step1ReturnHint": { + "message": "Вернитесь на эту вкладку, и сообщение исчезнет автоматически." + }, "onboarding_step1ValueMessage": { "message": "Готово — теперь вы можете мгновенно искать всё, что выделите." }, - "onboarding_nextButton": { - "message": "Далее" - }, "onboarding_step2Explain": { "message": "Далее давайте спросим ИИ о тексте ниже." }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "Нажмите эту команду, чтобы отправить выделенный текст через заранее заданный запрос к ИИ." }, + "onboarding_railResultAi": { + "message": "ИИ" + }, "onboarding_step2ValueMessage": { "message": "Теперь вы можете спрашивать ИИ о чём угодно прямо на странице, которую читаете." }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "Пример ссылки" }, + "onboarding_clickKeycap": { + "message": "Клик" + }, + "onboarding_railResultPreview": { + "message": "Просмотр" + }, "onboarding_step3ValueMessage": { "message": "Вы можете просматривать ссылки, не покидая страницу." }, + "onboarding_nextButton": { + "message": "Далее" + }, "onboarding_step4Explain": { - "message": "Команды, которые вы только что попробовали, можно в любой момент изменить, удалить или добавить новые в настройках." + "message": "Команды, которые вы попробовали, можно в любой момент добавить или изменить в настройках или в Selection Command Hub." }, - "onboarding_finishButton": { - "message": "Готово" + "onboarding_step4SettingsHeading": { + "message": "Изменить в настройках" + }, + "onboarding_step4HubHeading": { + "message": "Найти больше в Command Hub" + }, + "onboarding_step4HubAddChip": { + "message": "Добавить" }, "onboarding_completeTitle": { "message": "Всё готово!" @@ -1366,21 +1396,6 @@ "message": "Открыть настройки" }, "onboarding_closeButton": { - "message": "Закрыть" - }, - "onboarding_step1ReturnHint": { - "message": "Вернитесь на эту вкладку, и сообщение исчезнет автоматически." - }, - "onboarding_step1Pattern": { - "message": "Выделение → Команда → Поиск" - }, - "onboarding_step2Pattern": { - "message": "Выделение → Команда → ИИ" - }, - "onboarding_step3Pattern": { - "message": "Выделение → Команда → Просмотр" - }, - "onboarding_step4HubLink": { - "message": "Открыть Command Hub" + "message": "Закрыть и попробовать" } } diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 1330d1aa..942652a5 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1320,15 +1320,27 @@ "onboarding_step1TargetText": { "message": "使用 Selection Command,只需一键即可搜索、询问 AI 或预览链接。" }, + "onboarding_selectHint": { + "message": "拖动以选择下方文本" + }, + "onboarding_railSelect": { + "message": "选择" + }, + "onboarding_railCommand": { + "message": "命令" + }, + "onboarding_railResultSearch": { + "message": "搜索" + }, "onboarding_step1Callout": { "message": "点击此命令即可使用 Google 搜索。" }, + "onboarding_step1ReturnHint": { + "message": "切回此标签页,该提示会自动消失。" + }, "onboarding_step1ValueMessage": { "message": "就是这样——现在您可以立即搜索所选的任何内容。" }, - "onboarding_nextButton": { - "message": "下一步" - }, "onboarding_step2Explain": { "message": "接下来,让我们向 AI 询问下面这段文字。" }, @@ -1338,6 +1350,9 @@ "onboarding_step2Callout": { "message": "点击此命令,即可通过预设的提示词将选中内容发送给 AI。" }, + "onboarding_railResultAi": { + "message": "AI" + }, "onboarding_step2ValueMessage": { "message": "现在,您可以在阅读网页的同时,就您选择的任何内容向 AI 提问。" }, @@ -1347,14 +1362,29 @@ "onboarding_step3LinkLabel": { "message": "示例链接" }, + "onboarding_clickKeycap": { + "message": "点击" + }, + "onboarding_railResultPreview": { + "message": "预览" + }, "onboarding_step3ValueMessage": { "message": "您可以在不离开当前页面的情况下预览链接。" }, + "onboarding_nextButton": { + "message": "下一步" + }, "onboarding_step4Explain": { - "message": "您刚刚体验的命令,随时可以在设置页面中编辑、删除或添加新的命令。" + "message": "您刚刚体验的命令,随时可以在设置页面或 Selection Command Hub 中添加或编辑。" }, - "onboarding_finishButton": { - "message": "完成" + "onboarding_step4SettingsHeading": { + "message": "在设置中编辑" + }, + "onboarding_step4HubHeading": { + "message": "在 Command Hub 中查找更多" + }, + "onboarding_step4HubAddChip": { + "message": "添加" }, "onboarding_completeTitle": { "message": "一切就绪!" @@ -1366,21 +1396,6 @@ "message": "打开设置" }, "onboarding_closeButton": { - "message": "关闭" - }, - "onboarding_step1ReturnHint": { - "message": "切回此标签页,该提示会自动消失。" - }, - "onboarding_step1Pattern": { - "message": "选择 → 命令 → 搜索" - }, - "onboarding_step2Pattern": { - "message": "选择 → 命令 → AI" - }, - "onboarding_step3Pattern": { - "message": "选择 → 命令 → 预览" - }, - "onboarding_step4HubLink": { - "message": "浏览 Command Hub" + "message": "关闭并试用" } } diff --git a/packages/extension/src/components/onboarding/Onboarding.module.css b/packages/extension/src/components/onboarding/Onboarding.module.css deleted file mode 100644 index 34dbc29f..00000000 --- a/packages/extension/src/components/onboarding/Onboarding.module.css +++ /dev/null @@ -1,53 +0,0 @@ -.fadeIn { - animation: 0.35s onboardingFadeInUp forwards; -} - -@keyframes onboardingFadeInUp { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.spotlight { - box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55); - border-radius: 6px; - animation: 1.8s onboardingPulse ease-in-out infinite; -} - -@keyframes onboardingPulse { - 0%, - 100% { - outline: 2px solid rgba(255, 255, 255, 0.9); - outline-offset: 2px; - } - 50% { - outline: 2px solid rgba(255, 255, 255, 0.35); - outline-offset: 4px; - } -} - -/* Shown while waiting for the user to switch back from a command opened in - another window/tab (e.g. Step1's search). Per the PRD: slight drop shadow, - text slowly blinking. */ -.returnHint { - display: inline-block; - border-radius: 8px; - padding: 8px 14px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); - animation: 2s onboardingSlowBlink ease-in-out infinite; -} - -@keyframes onboardingSlowBlink { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.4; - } -} diff --git a/packages/extension/src/components/onboarding/OnboardingConfetti.tsx b/packages/extension/src/components/onboarding/OnboardingConfetti.tsx new file mode 100644 index 00000000..0c271ded --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingConfetti.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react" +import confetti from "canvas-confetti" +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion" + +const COLORS = ["#082f49", "#0284c7", "#7dd3fc", "#f59e0b", "#cbd5e1"] + +// Step5's one-shot celebration burst. Fires once on mount and never loops - +// see the onboarding motion spec's "one thing moves at a time" rule; a +// looping confetti field would compete with the checkmark/heading fade-in. +// +// `useWorker: false` is required under the extension pages' MV3 CSP +// (script-src 'self'): canvas-confetti defaults to rendering in a blob-URL +// web worker, which that CSP blocks. +// +// Gating is done with our own usePrefersReducedMotion() rather than +// canvas-confetti's built-in `disableForReducedMotion` option: that option +// checks `matchMedia('(prefers-reduced-motion)')` with no value, which +// matches whenever the browser merely *supports* the media feature - true +// in effectively every modern browser - so it silently disables confetti +// unconditionally rather than honoring the user's actual OS setting. +export function OnboardingConfetti() { + const canvasRef = useRef(null) + const prefersReducedMotion = usePrefersReducedMotion() + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || prefersReducedMotion) return + + const instance = confetti.create(canvas, { + resize: true, + useWorker: false, + }) + + // angle: 270 shoots particles downward (canvas-confetti's angle 90, + // the default, shoots UP - a fountain/firework burst - which combined + // with origin.y near/above 0 sends everything off the top of the + // canvas and it never becomes visible). origin.y: 0 starts them right + // at the top edge so the fall reads immediately. + instance({ + particleCount: 120, + spread: 100, + angle: 270, + startVelocity: 32, + gravity: 1, + ticks: 300, + origin: { x: 0.5, y: 0 }, + colors: COLORS, + }) + + return () => instance.reset() + }, [prefersReducedMotion]) + + if (prefersReducedMotion) return null + + return ( + is a + // replaced element, so `inset-0` alone leaves its CSS box at the + // element's intrinsic 300x150 default (absolutely-positioned replaced + // elements size from their intrinsic dimensions, not from inset + // constraints, per CSS2.1 10.6.5) - canvas-confetti's `resize: true` + // then reads that tiny box via getBoundingClientRect() and everything + // renders squeezed into the top-left corner. + className="pointer-events-none fixed inset-0 z-20 size-full" + /> + ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx index e096b19a..84ccff7c 100644 --- a/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx +++ b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx @@ -1,6 +1,5 @@ import { ReactNode } from "react" import clsx from "clsx" -import css from "./Onboarding.module.css" type Props = { children: ReactNode @@ -12,5 +11,14 @@ type Props = { // `key`-less CSS animation (re-triggered by React remounting the element, // e.g. when the step/phase changes) rather than a JS animation library. export function OnboardingFadeIn({ children, className }: Props) { - return

{children}
+ return ( +
+ {children} +
+ ) } diff --git a/packages/extension/src/components/onboarding/OnboardingLayout.tsx b/packages/extension/src/components/onboarding/OnboardingLayout.tsx new file mode 100644 index 00000000..0c39059e --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingLayout.tsx @@ -0,0 +1,74 @@ +import { ReactNode } from "react" +import { t } from "@/services/i18n" +import { getProgress, showsSkip } from "./onboardingProgress" +import type { OnboardingStep } from "@/types/onboarding" + +const ICON_URL = chrome.runtime.getURL("icon128.png") + +type Props = { + step: OnboardingStep + onSkip: () => void + children: ReactNode +} + +// Shared chrome around every onboarding step: a constant-strength vignette +// (unlike the old per-step OnboardingOverlay spotlight, this never changes +// between screens, so nothing about the background shifts on transition), +// a header with the brand mark and a 4-segment progress indicator, and a +// Skip button fixed to the bottom-right corner. Individual step components +// now render only their own content. +export function OnboardingLayout({ step, onSkip, children }: Props) { + const progress = getProgress(step) + + return ( +
+
+ +
+
+ + + Selection Command + +
+ + {progress && ( +
+
+ {progress.pills.map((pill, i) => ( + + ))} +
+ + {progress.current} / {progress.total} + +
+ )} +
+ +
+ {children} +
+ + {showsSkip(step) && ( + + )} +
+ ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingOverlay.tsx b/packages/extension/src/components/onboarding/OnboardingOverlay.tsx deleted file mode 100644 index 14938fad..00000000 --- a/packages/extension/src/components/onboarding/OnboardingOverlay.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState, RefObject } from "react" -import css from "./Onboarding.module.css" - -type Props = { - // The element to highlight. When null/undefined, nothing is rendered. - targetRef: RefObject | null -} - -// Dims the rest of the page and slowly pulses an outline around the target -// element, per the PRD's "背景を少し暗くする / ゆっくり明滅させる" rule for -// the text/link the user is meant to select next. -export function OnboardingOverlay({ targetRef }: Props) { - const [rect, setRect] = useState(null) - - useEffect(() => { - const target = targetRef?.current - if (!target) { - setRect(null) - return - } - - const update = () => setRect(target.getBoundingClientRect()) - update() - - // Layout can shift from page content loading/reflowing; a lightweight - // poll is simpler and safer here than a ResizeObserver + MutationObserver - // combo for a short-lived onboarding overlay. - const interval = window.setInterval(update, 300) - window.addEventListener("scroll", update, true) - window.addEventListener("resize", update) - return () => { - window.clearInterval(interval) - window.removeEventListener("scroll", update, true) - window.removeEventListener("resize", update) - } - }, [targetRef]) - - if (!rect) return null - - return ( -
- ) -} diff --git a/packages/extension/src/components/onboarding/OnboardingPage.tsx b/packages/extension/src/components/onboarding/OnboardingPage.tsx index 8e30bd32..63d4d87b 100644 --- a/packages/extension/src/components/onboarding/OnboardingPage.tsx +++ b/packages/extension/src/components/onboarding/OnboardingPage.tsx @@ -4,6 +4,7 @@ import { SelectAnchor } from "@/components/SelectAnchor" import { Popup } from "@/components/Popup" import { LinkSelector } from "@/components/LinkSelector" import { useOnboardingState } from "./useOnboardingState" +import { OnboardingLayout } from "./OnboardingLayout" import { StepIntro } from "./steps/StepIntro" import { StepSearchCommand } from "./steps/StepSearchCommand" import { StepAiPromptCommand } from "./steps/StepAiPromptCommand" @@ -21,7 +22,7 @@ export function OnboardingPage() { return ( -
+ {onboarding.step === OnboardingStep.INTRO && ( )} @@ -40,7 +41,7 @@ export function OnboardingPage() { {onboarding.step === OnboardingStep.COMPLETE && ( )} -
+ diff --git a/packages/extension/src/components/onboarding/OnboardingRail.tsx b/packages/extension/src/components/onboarding/OnboardingRail.tsx new file mode 100644 index 00000000..053ba84d --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingRail.tsx @@ -0,0 +1,108 @@ +import { Check, ChevronRight } from "lucide-react" +import { cn } from "@/lib/utils" + +// Reinforces the PRD's "select -> command -> result" mental model +// (https://github.com/ujiro99/selection-command/issues/452, section 6-2) as +// a persistent visual on Steps 1-3, replacing the plain uppercase caption +// text from the first cut. `activeBeat` is the 0-based index of the beat +// the current step is on (0 = selecting, 1 = running a command, 2 = seeing +// the result); beats before it render as done, after it as upcoming. +// Passing -1 renders all three beats as done, for the value-shown payoff +// screens where all three actions already happened. +type Props = { + selectLabel: string + commandLabel: string + resultLabel: string + activeBeat: 0 | 1 | 2 | -1 + // "sm" (default) is the compact form shown inline while a step is in + // progress; "lg" is the larger, staggered-reveal form used on the + // value-shown payoff screens. + size?: "sm" | "lg" +} + +type BeatState = "done" | "current" | "todo" + +function beatState(index: number, activeBeat: Props["activeBeat"]): BeatState { + if (activeBeat === -1 || index < activeBeat) return "done" + return index === activeBeat ? "current" : "todo" +} + +export function OnboardingRail({ + selectLabel, + commandLabel, + resultLabel, + activeBeat, + size = "sm", +}: Props) { + const beats = [selectLabel, commandLabel, resultLabel] + const lg = size === "lg" + + return ( +
+ {beats.map((label, i) => { + const state = beatState(i, activeBeat) + // The chevron before beat i is "walked" once the beat to its left + // (i - 1) is done. + const chevronWalked = i > 0 && beatState(i - 1, activeBeat) === "done" + // Each beat/chevron fades in a beat later than the last, so the + // sequence reads left-to-right - only used for the "lg" celebratory + // rail; the compact "sm" rail appears with the step's own fade-in. + const delayMs = lg ? 60 + i * 140 : 0 + + return ( +
+ {i > 0 && ( + + )} +
+ + {state === "done" && ( + + )} + {state === "current" && ( + + )} + + + {label} + +
+
+ ) + })} +
+ ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx new file mode 100644 index 00000000..b41cc3ae --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx @@ -0,0 +1,75 @@ +import { useRef } from "react" +import { TextCursor } from "lucide-react" +import { t } from "@/services/i18n" +import { cn } from "@/lib/utils" +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion" +import { useSelectionDemo } from "./useSelectionDemo" + +type Props = { + text: string + // True only pre-selection, while the step is still explaining what to do + // (StepPhase.EXPLAIN with no real selection yet) - drives both the + // "drag to select" caption and the animated demo. + demoActive: boolean + // True once the user has actually selected the text (any later phase), + // rendering it as a static, already-selected highlight instead. + selected: boolean +} + +// The sample text card shown on Steps 1-2. Demonstrates the "drag to +// select" gesture on a loop via useSelectionDemo() so first-time users +// recognize this as something to select rather than something to read - +// see onboarding_selectHint and the animated highlight/cursor below. +export function OnboardingTargetText({ text, demoActive, selected }: Props) { + const prefersReducedMotion = usePrefersReducedMotion() + const textRef = useRef(null) + const cursorRef = useRef(null) + const runDemo = demoActive && !prefersReducedMotion + + useSelectionDemo(textRef, cursorRef, runDemo) + + return ( +
+ {demoActive && ( + + + {t("onboarding_selectHint")} + + )} + +
+ + {text} + + + {runDemo && ( + + + + )} +
+
+ ) +} diff --git a/packages/extension/src/components/onboarding/OnboardingValueShown.tsx b/packages/extension/src/components/onboarding/OnboardingValueShown.tsx new file mode 100644 index 00000000..86823498 --- /dev/null +++ b/packages/extension/src/components/onboarding/OnboardingValueShown.tsx @@ -0,0 +1,43 @@ +import { Check } from "lucide-react" +import { t } from "@/services/i18n" +import { OnboardingRail } from "./OnboardingRail" + +type Props = { + message: string + resultLabel: string + onNext: () => void +} + +// The payoff screen shown once a step's VALUE_SHOWN phase is reached +// (Steps 1-3 all end here): a checkmark, the step's value message, and the +// select -> command -> result rail with every beat marked done - the +// PRD's "説明 → 操作 → 成功 → 理解" loop closing out. +export function OnboardingValueShown({ message, resultLabel, onNext }: Props) { + return ( +
+ + + + +

+ {message} +

+ + + + +
+ ) +} diff --git a/packages/extension/src/components/onboarding/onboardingProgress.test.ts b/packages/extension/src/components/onboarding/onboardingProgress.test.ts new file mode 100644 index 00000000..b97c9116 --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingProgress.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest" +import { getProgress, showsSkip } from "./onboardingProgress" +import { OnboardingStep } from "@/types/onboarding" + +describe("getProgress", () => { + it("returns null for the intro bookend", () => { + expect(getProgress(OnboardingStep.INTRO)).toBeNull() + }) + + it("returns null for the complete bookend", () => { + expect(getProgress(OnboardingStep.COMPLETE)).toBeNull() + }) + + it("marks the first tracked step as current with the rest todo", () => { + expect(getProgress(OnboardingStep.SEARCH)).toEqual({ + pills: ["current", "todo", "todo", "todo"], + current: 1, + total: 4, + }) + }) + + it("marks earlier steps done and later steps todo", () => { + expect(getProgress(OnboardingStep.LINK_PREVIEW)).toEqual({ + pills: ["done", "done", "current", "todo"], + current: 3, + total: 4, + }) + }) + + it("marks the last tracked step as current with none todo", () => { + expect(getProgress(OnboardingStep.CUSTOMIZE)).toEqual({ + pills: ["done", "done", "done", "current"], + current: 4, + total: 4, + }) + }) +}) + +describe("showsSkip", () => { + it("is true for every step except COMPLETE", () => { + expect(showsSkip(OnboardingStep.INTRO)).toBe(true) + expect(showsSkip(OnboardingStep.SEARCH)).toBe(true) + expect(showsSkip(OnboardingStep.CUSTOMIZE)).toBe(true) + expect(showsSkip(OnboardingStep.COMPLETE)).toBe(false) + }) +}) diff --git a/packages/extension/src/components/onboarding/onboardingProgress.ts b/packages/extension/src/components/onboarding/onboardingProgress.ts new file mode 100644 index 00000000..4c42617a --- /dev/null +++ b/packages/extension/src/components/onboarding/onboardingProgress.ts @@ -0,0 +1,39 @@ +import { OnboardingStep } from "@/types/onboarding" + +// The 4 steps the progress indicator tracks. INTRO and COMPLETE are +// bookends (no indicator shown for them) - see getProgress() below. +export const PROGRESS_STEPS = [ + OnboardingStep.SEARCH, + OnboardingStep.AI_PROMPT, + OnboardingStep.LINK_PREVIEW, + OnboardingStep.CUSTOMIZE, +] as const + +export type PillState = "done" | "current" | "todo" + +export type OnboardingProgress = { + pills: PillState[] + current: number + total: number +} + +// Derives the header progress indicator's state from the current step, so +// no other component needs to know the step count or index arithmetic. +// Returns null for INTRO/COMPLETE, which don't show an indicator. +export function getProgress(step: OnboardingStep): OnboardingProgress | null { + const index = PROGRESS_STEPS.indexOf(step as (typeof PROGRESS_STEPS)[number]) + if (index === -1) return null + + return { + pills: PROGRESS_STEPS.map((_, i) => + i < index ? "done" : i === index ? "current" : "todo", + ), + current: index + 1, + total: PROGRESS_STEPS.length, + } +} + +// The Skip button is available on every step except the final one - there's +// nothing left to skip once the user has reached it. +export const showsSkip = (step: OnboardingStep): boolean => + step !== OnboardingStep.COMPLETE diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index 0a1e9eaa..d319a9e0 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from "react" +import { useEffect, useState } from "react" import { t } from "@/services/i18n" import { useSelectContext } from "@/hooks/useSelectContext" import { isEmpty } from "@/lib/utils" import { ONBOARDING_AI_PROMPT_COMMAND_ID } from "../onboardingCommand" import { subscribeCommandExecuted } from "../onboardingEvents" -import { OnboardingOverlay } from "../OnboardingOverlay" import { OnboardingCallout } from "../OnboardingCallout" import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingTargetText } from "../OnboardingTargetText" +import { OnboardingRail } from "../OnboardingRail" +import { OnboardingValueShown } from "../OnboardingValueShown" import { OnboardingStep, StepPhase } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" @@ -17,11 +19,11 @@ type Props = { // Step2: run the onboarding's dedicated AiPrompt command (see // onboardingCommand.ts), which opens the side panel - unlike Step1's search, // the onboarding page never loses visibility here, so there's no separate -// "wait for the user to come back" phase. +// "wait for the user to come back" phase, and the rail only ever needs +// beats 0 (selecting) and 1 (running the command). export function StepAiPromptCommand({ onboarding }: Props) { const { phase, setPhase } = onboarding const { selectionText } = useSelectContext() - const targetTextRef = useRef(null) const [calloutElm, setCalloutElm] = useState(null) useEffect(() => { @@ -65,56 +67,49 @@ export function StepAiPromptCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - return ( -
- - {phase === StepPhase.VALUE_SHOWN ? ( -
-

- {t("onboarding_step2ValueMessage")} -

-

- {t("onboarding_step2Pattern")} -

- -
- ) : ( -

- {t("onboarding_step2Explain")} -

- )} -
+ if (phase === StepPhase.VALUE_SHOWN) { + return ( + onboarding.goToStep(OnboardingStep.LINK_PREVIEW)} + /> + ) + } - {phase !== StepPhase.VALUE_SHOWN && ( - + +

- {t("onboarding_step2TargetText")} - - )} + {t("onboarding_step2Explain")} +

+
- {phase === StepPhase.EXPLAIN && ( - - )} + {t("onboarding_step2Callout")} - +
) } diff --git a/packages/extension/src/components/onboarding/steps/StepComplete.tsx b/packages/extension/src/components/onboarding/steps/StepComplete.tsx index 3a09d212..79b1dc90 100644 --- a/packages/extension/src/components/onboarding/steps/StepComplete.tsx +++ b/packages/extension/src/components/onboarding/steps/StepComplete.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react" +import { PartyPopper } from "lucide-react" import { t } from "@/services/i18n" import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingConfetti } from "../OnboardingConfetti" import { closeOnboardingTab } from "../onboardingWindow" import type { UseOnboardingState } from "../useOnboardingState" @@ -11,6 +13,12 @@ type Props = { // Step5: the final, EXPLAIN-only screen. Marks the onboarding as complete // (hasShownOnboarding=true, ONBOARDING_COMPLETE analytics event) as soon as // it's reached, since there's no further step to skip out of. +// +// The primary button closes the tab and returns the user to the page they +// were on - the explanation text already tells them to "select text and +// try it", so the button that actually lets them do that is the one that +// gets top billing; "open settings" (something they can always do later, +// and which Step4 already introduced) is the secondary action. export function StepComplete({ onboarding }: Props) { const { complete } = onboarding @@ -19,27 +27,35 @@ export function StepComplete({ onboarding }: Props) { }, [complete]) return ( - -

{t("onboarding_completeTitle")}

-

- {t("onboarding_completeBody")} -

-
- - -
-
+ <> + + + + + +

+ {t("onboarding_completeTitle")} +

+

+ {t("onboarding_completeBody")} +

+
+ + +
+
+ ) } diff --git a/packages/extension/src/components/onboarding/steps/StepCustomize.tsx b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx index 70cc7d99..56fb1288 100644 --- a/packages/extension/src/components/onboarding/steps/StepCustomize.tsx +++ b/packages/extension/src/components/onboarding/steps/StepCustomize.tsx @@ -1,61 +1,141 @@ -import { ExternalLink } from "lucide-react" +import { + Settings, + Grip, + Plus, + Search, + Sparkles, + Eye, + ExternalLink, + Image, + Languages, + FileText, + Hash, +} from "lucide-react" import { t } from "@/services/i18n" +import { useSection } from "@/hooks/useSettings" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { ONBOARDING_AI_PROMPT_COMMAND_ID } from "../onboardingCommand" import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingStep } from "@/types/onboarding" -import { NEW_HUB_URL } from "@/const" -import { getHubLocale } from "@/services/hubShare" -import { useHubUser } from "@/hooks/option/useHubUser" -import { UTM_SOURCE, UTM_MEDIUM, withUtmParams } from "@shared" import type { UseOnboardingState } from "../useOnboardingState" type Props = { onboarding: UseOnboardingState } +const HUB_ROWS = [ + { icon: Image, title: "Google Image" }, + { icon: Languages, title: "Translation" }, + { icon: FileText, title: "Page Summary" }, + { icon: Hash, title: "Character Counter" }, +] as const + // Step4: EXPLAIN-only wrap-up telling the user the sample commands seen in -// Steps 1-3 can be edited, removed, or added to from the settings page, plus -// a link to the Command Hub to discover more (per the PRD's Step4 - only a -// lightweight pointer here, not a how-to-build-commands explainer). +// Steps 1-3 can be added to or edited from either the settings screen or +// the Command Hub. Both panels below are illustrative mockups of those real +// screens, not live links - per the PRD, Step4 is a lightweight pointer, +// not a place to start a real editing/browsing flow mid-onboarding. export function StepCustomize({ onboarding }: Props) { - const hubUser = useHubUser() - const locale = getHubLocale() - const hubLink = withUtmParams( - hubUser - ? `${NEW_HUB_URL}/${locale}/dashboard/commands` - : `${NEW_HUB_URL}/${locale}`, - { source: UTM_SOURCE.ONBOARDING, medium: UTM_MEDIUM.LINK }, - ) + const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) + // The AiPrompt command's title is locale-specific product data (see + // onboardingCommand.ts / defaultSettings.ts), not an i18n message - look + // it up by its fixed id rather than hardcoding a label that would drift + // from what Step2 actually showed. Google/Link Preview's titles are the + // same literal string in every locale, so those stay hardcoded. + const aiPromptTitle = + commands?.find((c) => c.id === ONBOARDING_AI_PROMPT_COMMAND_ID)?.title ?? + t("onboarding_railResultAi") return ( - -

+ +

{t("onboarding_step4Explain")}

- - {t("onboarding_step4HubLink")} - - -
- - + +
+
+
+ + + + {t("onboarding_step4SettingsHeading")} +
+
+
+ {t("Option_commands")} +
+
+ + + +
+ + + + {t("Option_Command_new")} +
+
+
+
+ +
+
+ + + + {t("onboarding_step4HubHeading")} +
+
+
+ Command Hub +
+
+ {HUB_ROWS.map(({ icon: Icon, title }) => ( +
+ + + + + {title} + + + {t("onboarding_step4HubAddChip")} + +
+ ))} +
+
+
+ + ) } + +function SettingsRow({ + icon: Icon, + title, +}: { + icon: typeof Search + title: string +}) { + return ( +
+ + + + + {title} +
+ ) +} diff --git a/packages/extension/src/components/onboarding/steps/StepIntro.tsx b/packages/extension/src/components/onboarding/steps/StepIntro.tsx index bb8f928f..3fe5d201 100644 --- a/packages/extension/src/components/onboarding/steps/StepIntro.tsx +++ b/packages/extension/src/components/onboarding/steps/StepIntro.tsx @@ -3,6 +3,8 @@ import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingStep } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" +const ICON_URL = chrome.runtime.getURL("icon128.png") + type Props = { onboarding: UseOnboardingState } @@ -10,27 +12,21 @@ type Props = { // Step0: the landing screen shown right after install. export function StepIntro({ onboarding }: Props) { return ( - -

{t("onboarding_step0Title")}

-

+ + +

+ {t("onboarding_step0Title")} +

+

{t("onboarding_step0Body")}

-
- - -
+
) } diff --git a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx index 20c2b207..b77b5b8e 100644 --- a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx +++ b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx @@ -1,9 +1,11 @@ import { useEffect, useRef } from "react" +import { ArrowBigUp, MousePointerClick } from "lucide-react" import { t } from "@/services/i18n" import { DRAG_OPEN_MODE } from "@/const" import { subscribeCommandExecuted } from "../onboardingEvents" -import { OnboardingOverlay } from "../OnboardingOverlay" import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingRail } from "../OnboardingRail" +import { OnboardingValueShown } from "../OnboardingValueShown" import { OnboardingStep, StepPhase } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" @@ -15,7 +17,9 @@ const DRAG_OPEN_MODES: readonly string[] = Object.values(DRAG_OPEN_MODE) // Step3: Shift+click the sample link to trigger Link Preview. Unlike // Steps 1-2 this isn't triggered via text selection, so it only ever uses -// the EXPLAIN and VALUE_SHOWN phases. +// the EXPLAIN and VALUE_SHOWN phases - the rail stays on beat 0 the whole +// time it's visible, per the design note that "select" here stands in for +// "pick the target" even though the gesture is a click, not a drag. export function StepLinkPreview({ onboarding }: Props) { const { phase, setPhase } = onboarding const linkRef = useRef(null) @@ -30,52 +34,56 @@ export function StepLinkPreview({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) + if (phase === StepPhase.VALUE_SHOWN) { + return ( + onboarding.goToStep(OnboardingStep.CUSTOMIZE)} + /> + ) + } + return ( -
- - {phase === StepPhase.VALUE_SHOWN ? ( -
-

- {t("onboarding_step3ValueMessage")} -

-

- {t("onboarding_step3Pattern")} -

- -
- ) : ( -

- {t("onboarding_step3Explain")} -

- )} +
+ +

+ {t("onboarding_step3Explain")} +

- {phase !== StepPhase.VALUE_SHOWN && ( + - {phase === StepPhase.EXPLAIN && } +
+ + + Shift + + + + + + {t("onboarding_clickKeycap")} + +
- +
) } diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx index 10b1dea3..cf1f6d25 100644 --- a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react" +import { useEffect, useState } from "react" import { t } from "@/services/i18n" import { useSelectContext } from "@/hooks/useSelectContext" import { useSection } from "@/hooks/useSettings" @@ -6,17 +6,28 @@ import { CACHE_SECTIONS } from "@/services/settings/settingsCache" import { isEmpty } from "@/lib/utils" import { OPEN_MODE_TYPE_MAP, COMMAND_TYPE } from "@/const" import { subscribeCommandExecuted } from "../onboardingEvents" -import { OnboardingOverlay } from "../OnboardingOverlay" import { OnboardingCallout } from "../OnboardingCallout" import { OnboardingFadeIn } from "../OnboardingFadeIn" +import { OnboardingTargetText } from "../OnboardingTargetText" +import { OnboardingRail } from "../OnboardingRail" +import { OnboardingValueShown } from "../OnboardingValueShown" import { OnboardingStep, StepPhase } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" -import css from "../Onboarding.module.css" type Props = { onboarding: UseOnboardingState } +// Maps this step's phase onto the select -> command -> result rail's +// 0/1/2 beat index (see OnboardingRail). WAIT_RETURN still counts as the +// "result" beat - the search already ran, we're just waiting for the user +// to switch back. +function railBeat(phase: StepPhase): 0 | 1 | 2 { + if (phase === StepPhase.WAIT_EXECUTE) return 1 + if (phase === StepPhase.WAIT_RETURN) return 2 + return 0 +} + // Step1: the user's first hands-on experience - select the sample text, // see the real popup menu, run the (default) Google search command, and // come back to see the value message. This is the PRD's "First Value". @@ -24,7 +35,6 @@ export function StepSearchCommand({ onboarding }: Props) { const { phase, setPhase } = onboarding const { selectionText } = useSelectContext() const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) - const targetTextRef = useRef(null) const [calloutElm, setCalloutElm] = useState(null) const googleCommand = commands?.find((c) => c.title === "Google") @@ -99,60 +109,57 @@ export function StepSearchCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) + if (phase === StepPhase.VALUE_SHOWN) { + return ( + onboarding.goToStep(OnboardingStep.AI_PROMPT)} + /> + ) + } + return ( -
- - {phase === StepPhase.VALUE_SHOWN ? ( -
-

- {t("onboarding_step1ValueMessage")} -

-

- {t("onboarding_step1Pattern")} -

- -
- ) : phase === StepPhase.WAIT_RETURN ? ( -

+

+ + {phase === StepPhase.WAIT_RETURN ? ( +

{t("onboarding_step1ReturnHint")}

) : ( -

+

{t("onboarding_step1Explain")}

)}
- {phase !== StepPhase.VALUE_SHOWN && ( - - {t("onboarding_step1TargetText")} - - )} - - {phase === StepPhase.EXPLAIN && ( - + {phase !== StepPhase.WAIT_RETURN && ( + )} {t("onboarding_step1Callout")} - +
) } diff --git a/packages/extension/src/components/onboarding/useOnboardingState.ts b/packages/extension/src/components/onboarding/useOnboardingState.ts index 3ef12cbf..93a82d8a 100644 --- a/packages/extension/src/components/onboarding/useOnboardingState.ts +++ b/packages/extension/src/components/onboarding/useOnboardingState.ts @@ -9,9 +9,37 @@ import { closeOnboardingTab } from "./onboardingWindow" export type UseOnboardingState = ReturnType +// e2e-build-only escape hatch (`?step=SEARCH&phase=value_shown`) so +// screenshot/manual-QA tooling can land directly on any step/phase without +// driving the whole real flow. `import.meta.env.MODE` is only "e2e" for +// `vite build --mode e2e` (see package.json's build:e2e), so this is +// entirely inert in production and dev builds. +function readE2eOverride(): { step: OnboardingStep; phase: StepPhase } | null { + if (import.meta.env.MODE !== "e2e") return null + const params = new URLSearchParams(window.location.search) + const stepParam = params.get("step") + if (!stepParam) return null + + const step = OnboardingStep[stepParam as keyof typeof OnboardingStep] + if (step === undefined) return null + + const phaseParam = params.get("phase") + const phaseValues: string[] = Object.values(StepPhase) + const phase = + phaseParam && phaseValues.includes(phaseParam) + ? (phaseParam as StepPhase) + : StepPhase.EXPLAIN + + return { step, phase } +} + export function useOnboardingState() { - const [step, setStep] = useState(OnboardingStep.INTRO) - const [phase, setPhase] = useState(StepPhase.EXPLAIN) + const [step, setStep] = useState( + () => readE2eOverride()?.step ?? OnboardingStep.INTRO, + ) + const [phase, setPhase] = useState( + () => readE2eOverride()?.phase ?? StepPhase.EXPLAIN, + ) const startedAtRef = useRef(Date.now()) const firstValueSentRef = useRef(false) const seenSelectionStepsRef = useRef>(new Set()) diff --git a/packages/extension/src/components/onboarding/useSelectionDemo.test.ts b/packages/extension/src/components/onboarding/useSelectionDemo.test.ts new file mode 100644 index 00000000..049feb0d --- /dev/null +++ b/packages/extension/src/components/onboarding/useSelectionDemo.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi } from "vitest" +import { renderHook } from "@testing-library/react" +import { createRef } from "react" +import { + buildKeyframes, + useSelectionDemo, + type LineRect, +} from "./useSelectionDemo" + +describe("buildKeyframes", () => { + const host: LineRect = { x: 0, y: 0, width: 500, height: 200 } + + it("returns an empty array for no lines", () => { + expect(buildKeyframes([], host)).toEqual([]) + }) + + it("starts at the line's start and holds at its end for a single line", () => { + const line: LineRect = { x: 20, y: 40, width: 300, height: 24 } + const frames = buildKeyframes([line], host) + + expect(frames[0]).toEqual({ + offset: 0, + transform: "translate(20px, 40px)", + opacity: 1, + }) + const last = frames[frames.length - 1] + expect(last).toEqual({ + offset: 1, + transform: "translate(320px, 40px)", + opacity: 1, + }) + // Every keyframe stays fully opaque - nothing to hide with one line. + expect(frames.every((f) => f.opacity === 1)).toBe(true) + }) + + it("subtracts the host origin from line coordinates", () => { + const shiftedHost: LineRect = { x: 100, y: 50, width: 500, height: 200 } + const line: LineRect = { x: 120, y: 90, width: 200, height: 24 } + const frames = buildKeyframes([line], shiftedHost) + + expect(frames[0].transform).toBe("translate(20px, 40px)") + }) + + it("dips opacity to 0 and jumps position between two lines", () => { + const lines: LineRect[] = [ + { x: 0, y: 0, width: 300, height: 24 }, + { x: 0, y: 30, width: 150, height: 24 }, + ] + const frames = buildKeyframes(lines, host) + + const zeroOpacityFrames = frames.filter((f) => f.opacity === 0) + // Two frames dip to opacity 0: end-of-line-1 position, then + // start-of-line-2 position, both invisible. + expect(zeroOpacityFrames).toHaveLength(2) + expect(zeroOpacityFrames[0].transform).toBe("translate(300px, 0px)") + expect(zeroOpacityFrames[1].transform).toBe("translate(0px, 30px)") + + // Offsets are non-decreasing, as WAAPI requires. + for (let i = 1; i < frames.length; i++) { + expect(frames[i].offset as number).toBeGreaterThanOrEqual( + frames[i - 1].offset as number, + ) + } + + // Ends on the second line's end position, fully visible. + const last = frames[frames.length - 1] + expect(last).toEqual({ + offset: 1, + transform: "translate(150px, 30px)", + opacity: 1, + }) + }) + + it("gives a wider line proportionally more of the movement budget", () => { + const lines: LineRect[] = [ + { x: 0, y: 0, width: 400, height: 24 }, + { x: 0, y: 30, width: 100, height: 24 }, + ] + const frames = buildKeyframes(lines, host) + // First line (400/500 of the width) should reach its end-of-line + // keyframe at a larger offset than the short second line's span. + const line1End = frames.find( + (f) => f.transform === "translate(400px, 0px)", + )! + // moveFraction = 1 - holdFraction(1200/3000) - gapFraction(0.015) = + // 0.585; line1 gets 400/500 of it. + expect(line1End.offset as number).toBeCloseTo(0.468, 3) + }) +}) + +describe("useSelectionDemo", () => { + it("does nothing when disabled", () => { + const textRef = createRef() + const cursorRef = createRef() + expect(() => + renderHook(() => useSelectionDemo(textRef, cursorRef, false)), + ).not.toThrow() + }) + + it("no-ops when the refs aren't attached yet", () => { + const textRef = createRef() + const cursorRef = createRef() + expect(() => + renderHook(() => useSelectionDemo(textRef, cursorRef, true)), + ).not.toThrow() + }) + + it("no-ops in jsdom, which has no Element.animate, and cleans up without throwing", () => { + const text = document.createElement("span") + const cursor = document.createElement("span") + document.body.append(text, cursor) + const textRef = { current: text } + const cursorRef = { current: cursor } + + // jsdom does not implement the Web Animations API. + expect(typeof cursor.animate).not.toBe("function") + + const { unmount } = renderHook(() => + useSelectionDemo(textRef, cursorRef, true), + ) + expect(() => unmount()).not.toThrow() + + text.remove() + cursor.remove() + }) + + it("measures and animates when Element.animate is available", () => { + const text = document.createElement("span") + text.textContent = "hello" + const cursor = document.createElement("span") + const host = document.createElement("div") + host.append(text, cursor) + document.body.append(host) + + const animateSpy = vi.fn().mockReturnValue({ cancel: vi.fn() }) + // jsdom has no WAAPI; stub it directly on the element under test. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(cursor as any).animate = animateSpy + Object.defineProperty(cursor, "offsetParent", { + value: host, + configurable: true, + }) + vi.spyOn(host, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 40, + top: 0, + left: 0, + right: 100, + bottom: 40, + toJSON: () => ({}), + }) + // jsdom doesn't implement Range.prototype.getClientRects at all (not + // just a stub - the property is absent), so vi.spyOn has nothing to + // wrap. Assign it directly instead. + const originalGetClientRects = Range.prototype.getClientRects + Range.prototype.getClientRects = vi.fn().mockReturnValue([ + { + x: 0, + y: 0, + width: 60, + height: 20, + top: 0, + left: 0, + right: 60, + bottom: 20, + toJSON: () => ({}), + }, + ] as unknown as DOMRectList) + + const textRef = { current: text } + const cursorRef = { current: cursor } + const { unmount } = renderHook(() => + useSelectionDemo(textRef, cursorRef, true), + ) + + expect(animateSpy).toHaveBeenCalledTimes(1) + const [keyframes, options] = animateSpy.mock.calls[0] + expect(keyframes.length).toBeGreaterThan(0) + expect(options).toMatchObject({ iterations: Infinity, easing: "linear" }) + + unmount() + host.remove() + Range.prototype.getClientRects = originalGetClientRects + vi.restoreAllMocks() + }) +}) diff --git a/packages/extension/src/components/onboarding/useSelectionDemo.ts b/packages/extension/src/components/onboarding/useSelectionDemo.ts new file mode 100644 index 00000000..fa8ffbe9 --- /dev/null +++ b/packages/extension/src/components/onboarding/useSelectionDemo.ts @@ -0,0 +1,186 @@ +import { RefObject, useEffect, useRef } from "react" + +// A visual-line rect in the coordinate space needed to build keyframes. +// Deliberately a plain object (not DOMRect) so buildKeyframes() is testable +// without a DOM. +export type LineRect = { x: number; y: number; width: number; height: number } + +const LINE_MOVE_MS = 900 +const TAIL_HOLD_MS = 1200 +// Fraction of the total animation spent hidden while jumping between two +// lines - long enough to fully hide the jump, short enough to stay +// unnoticed as a "flicker" rather than a pause. +const GAP_FRACTION = 0.015 + +function clampOffset(offset: number): number { + return Math.min(1, Math.max(0, Number(offset.toFixed(4)))) +} + +// Builds Web Animations API keyframes that walk an absolutely-positioned +// cursor element left-to-right along each visual line in `lines` (both +// `lines` and `host` are in the same coordinate space, typically viewport +// coordinates from getBoundingClientRect/getClientRects - the caller +// subtracts nothing, this function does), hiding the jump between lines +// behind a brief opacity dip so it reads as "the cursor left the page and +// re-entered at the next line" rather than a teleport. +// +// Exported for direct unit testing - this is where the actual geometry +// logic lives; the hook around it is mostly measurement plumbing. +export function buildKeyframes(lines: LineRect[], host: LineRect): Keyframe[] { + if (lines.length === 0) return [] + + const sumWidth = lines.reduce((sum, l) => sum + l.width, 0) || 1 + const gapCount = lines.length - 1 + const gapFraction = gapCount > 0 ? GAP_FRACTION : 0 + const totalMoveMs = LINE_MOVE_MS * lines.length + const holdFraction = TAIL_HOLD_MS / (totalMoveMs + TAIL_HOLD_MS) + // Remaining budget after the gaps and the tail hold, distributed across + // lines proportional to their width so a long line takes longer to + // traverse than a short one. + const moveFraction = 1 - holdFraction - gapFraction * gapCount + + const frames: Keyframe[] = [] + let offset = 0 + let lastTransform = "" + + lines.forEach((line, i) => { + const startX = line.x - host.x + const endX = line.x + line.width - host.x + const y = line.y - host.y + const endTransform = `translate(${endX}px, ${y}px)` + + if (frames.length === 0) { + frames.push({ + offset: 0, + transform: `translate(${startX}px, ${y}px)`, + opacity: 1, + }) + } + + offset += moveFraction * (line.width / sumWidth) + frames.push({ + offset: clampOffset(offset), + transform: endTransform, + opacity: 1, + }) + lastTransform = endTransform + + const next = lines[i + 1] + if (next) { + const nextStart = `translate(${next.x - host.x}px, ${next.y - host.y}px)` + offset += gapFraction / 2 + frames.push({ + offset: clampOffset(offset), + transform: endTransform, + opacity: 0, + }) + offset += gapFraction / 2 + frames.push({ + offset: clampOffset(offset), + transform: nextStart, + opacity: 0, + }) + frames.push({ + offset: clampOffset(offset), + transform: nextStart, + opacity: 1, + }) + lastTransform = nextStart + } + }) + + frames.push({ offset: 1, transform: lastTransform, opacity: 1 }) + return frames +} + +function rectSignature(rects: DOMRect[]): string { + return rects.map((r) => `${r.x | 0},${r.y | 0},${r.width | 0}`).join("|") +} + +// Demonstrates "drag to select" on a loop by measuring the rendered lines of +// `textRef`'s content at runtime (via Range.getClientRects()) and animating +// `cursorRef` - an absolutely-positioned I-beam glyph, a sibling of the +// text, not a child - along them. Runtime measurement (rather than +// hardcoded pixel keyframes) is what makes this locale-proof: every one of +// the extension's 14 locales wraps this string differently, and the card is +// responsive. +// +// `cursorRef`'s nearest positioned ancestor (`offsetParent`) is used as the +// coordinate host, so the caller must give the card itself (or a wrapper +// around both the text and the cursor) `position: relative`. +export function useSelectionDemo( + textRef: RefObject, + cursorRef: RefObject, + enabled: boolean, +) { + const signatureRef = useRef("") + const animationRef = useRef(null) + + useEffect(() => { + const text = textRef.current + const cursor = cursorRef.current + // `cursor.animate` is absent in jsdom (no test environment implements + // WAAPI), which is exactly the no-op we want in tests - no separate + // "are we in a test" branch needed. + if (!enabled || !text || !cursor || typeof cursor.animate !== "function") { + return + } + + let cancelled = false + + const measure = () => { + if (cancelled) return + const host = cursor.offsetParent as HTMLElement | null + if (!host) return + + const range = document.createRange() + range.selectNodeContents(text) + const rects = Array.from(range.getClientRects()).filter( + (r) => r.width > 0.5 && r.height > 0.5, + ) + // Empty when the card is hidden or not laid out yet. No-op and wait + // for the ResizeObserver below to fire once a box appears - don't + // cancel any animation already running from a prior measurement. + if (rects.length === 0) return + + const signature = rectSignature(rects) + if (signature === signatureRef.current && animationRef.current) return + signatureRef.current = signature + + const hostRect = host.getBoundingClientRect() + const keyframes = buildKeyframes(rects, hostRect) + if (keyframes.length === 0) return + + animationRef.current?.cancel() + animationRef.current = cursor.animate(keyframes, { + duration: LINE_MOVE_MS * rects.length + TAIL_HOLD_MS, + iterations: Infinity, + easing: "linear", + }) + } + + const scheduleMeasure = () => { + requestAnimationFrame(measure) + } + + // Observe the card (the text's parent), not the cursor itself - the + // cursor is `position: absolute; pointer-events: none`, so it can never + // affect the card's box and therefore can't create a resize-observer + // feedback loop. + const ro = new ResizeObserver(scheduleMeasure) + ro.observe(text.parentElement ?? text) + + measure() + // Re-measure once webfonts finish loading - a late font swap can change + // line-wrap points after the first measurement. + document.fonts?.ready?.then(scheduleMeasure) + + return () => { + cancelled = true + ro.disconnect() + animationRef.current?.cancel() + animationRef.current = null + signatureRef.current = "" + } + }, [enabled, textRef, cursorRef]) +} diff --git a/packages/extension/src/hooks/usePrefersReducedMotion.ts b/packages/extension/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 00000000..6ea3451b --- /dev/null +++ b/packages/extension/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react" + +const QUERY = "(prefers-reduced-motion: reduce)" + +// Tracks the OS-level "reduce motion" preference. Tailwind's `motion-reduce:` +// variant already handles CSS-only animations, but JS-driven effects (the +// selection demo's Web Animations API keyframes, the confetti burst) need +// this to decide whether to run at all. +export function usePrefersReducedMotion(): boolean { + const [prefersReduced, setPrefersReduced] = useState( + () => typeof window !== "undefined" && window.matchMedia(QUERY).matches, + ) + + useEffect(() => { + const mql = window.matchMedia(QUERY) + const onChange = () => setPrefersReduced(mql.matches) + + if (mql.addEventListener) { + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + } + // Safari < 14 fallback. + mql.addListener(onChange) + return () => mql.removeListener(onChange) + }, []) + + return prefersReduced +} diff --git a/packages/extension/tailwind.config.js b/packages/extension/tailwind.config.js index 63dc22b7..418389a0 100644 --- a/packages/extension/tailwind.config.js +++ b/packages/extension/tailwind.config.js @@ -40,6 +40,34 @@ module.exports = { transform: "scale(1)", }, }, + // Onboarding-only keyframes. Namespaced with an `onboarding-` prefix + // (see also the `onboarding-` animation names below) so they read as + // a related set alongside the rest of this file's keyframes. + "onboarding-rise": { + from: { opacity: "0", transform: "translateY(10px)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "onboarding-ring": { + "0%, 100%": { boxShadow: "0 0 0 0 var(--onboarding-ring-color)" }, + "50%": { boxShadow: "0 0 0 6px var(--onboarding-ring-color)" }, + }, + "onboarding-blink": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.45" }, + }, + // Drives the selection-demo highlight span: fades a selection-blue + // background in, holds, then fades it out, looping. + "onboarding-highlight": { + "0%, 10%": { backgroundColor: "rgb(191 219 254 / 0)" }, + "44%, 70%": { backgroundColor: "rgb(191 219 254 / 1)" }, + "82%, 100%": { backgroundColor: "rgb(191 219 254 / 0)" }, + }, + // The one-shot scale-in used for the value-shown checkmark badge and + // the completion screen's party-popper icon. + "onboarding-pop": { + "0%": { opacity: "0", transform: "scale(0.72)" }, + "100%": { opacity: "1", transform: "scale(1)" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -48,6 +76,13 @@ module.exports = { marquee2: "marquee2 var(--marquee-duration) linear infinite", "spin-slow": "spin 3s linear infinite", popup: "popup 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both", + "onboarding-rise": + "onboarding-rise 0.38s cubic-bezier(0.16, 1, 0.3, 1) both", + "onboarding-ring": "onboarding-ring 2.4s ease-in-out 0.6s infinite", + "onboarding-blink": "onboarding-blink 2.4s ease-in-out 0.3s infinite", + "onboarding-highlight": "onboarding-highlight 4s ease-in-out infinite", + "onboarding-pop": + "onboarding-pop 0.36s cubic-bezier(0.34, 1.4, 0.64, 1) both", }, padding: { 0.5: "2px", diff --git a/packages/shared/src/constants/utm.ts b/packages/shared/src/constants/utm.ts index e70c9acd..340ec1f8 100644 --- a/packages/shared/src/constants/utm.ts +++ b/packages/shared/src/constants/utm.ts @@ -6,7 +6,6 @@ export const UTM_SOURCE = { OPTION_PAGE: "option-page", EXTENSION: "extension", - ONBOARDING: "onboarding", } as const export type UtmSource = (typeof UTM_SOURCE)[keyof typeof UTM_SOURCE] diff --git a/yarn.lock b/yarn.lock index 3cdb1bc6..80e06029 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2010,6 +2010,11 @@ dependencies: "@babel/types" "^7.28.2" +"@types/canvas-confetti@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz#d1077752e046413c9881fbb2ba34a70ebe3c1773" + integrity sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg== + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" @@ -2978,6 +2983,11 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001806, caniuse-lite@^1.0.300018 resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b" integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ== +canvas-confetti@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/canvas-confetti/-/canvas-confetti-1.9.4.tgz#4412a5daa96afd0b4d70ecd66b65ce8db3022b2b" + integrity sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw== + chai@^5.2.0: version "5.3.3" resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" @@ -6371,7 +6381,7 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6389,15 +6399,6 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -6483,7 +6484,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6497,13 +6498,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" @@ -7219,7 +7213,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7237,15 +7231,6 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 5cd3492749d788496a8f633556df2544b5ae7b72 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 25 Aug 2026 15:49:53 +0900 Subject: [PATCH 04/21] Update: Adjust styling. --- .../src/components/onboarding/OnboardingLayout.tsx | 2 +- .../src/components/onboarding/OnboardingTargetText.tsx | 9 ++++----- .../components/onboarding/steps/StepSearchCommand.tsx | 10 ++-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/components/onboarding/OnboardingLayout.tsx b/packages/extension/src/components/onboarding/OnboardingLayout.tsx index 0c39059e..cc2e2988 100644 --- a/packages/extension/src/components/onboarding/OnboardingLayout.tsx +++ b/packages/extension/src/components/onboarding/OnboardingLayout.tsx @@ -24,7 +24,7 @@ export function OnboardingLayout({ step, onSkip, children }: Props) {
diff --git a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx index b41cc3ae..6bdc5608 100644 --- a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx +++ b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx @@ -39,18 +39,17 @@ export function OnboardingTargetText({ text, demoActive, selected }: Props) {
+
) : ( -

+

{t("onboarding_step1Explain")}

)} From 76bdfb20553336e75c1ab4b675c4bc03fb505999 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 26 Aug 2026 13:05:27 +0900 Subject: [PATCH 05/21] Refactor: Extract popupContext into hooks/usePopupContext Moves the context definition out of Popup.tsx and into a dedicated hooks/usePopupContext.tsx, matching the pattern used by the other context providers, and switches consumers to the usePopupContext() hook instead of importing the raw context from Popup.tsx. Co-Authored-By: Claude Sonnet 5 --- packages/extension/src/components/Popup.tsx | 11 ++--------- packages/extension/src/components/Tooltip.tsx | 6 +++--- .../extension/src/components/menu/MenuFolder.tsx | 6 +++--- .../extension/src/components/menu/MenuItem.tsx | 6 +++--- packages/extension/src/hooks/usePopupContext.tsx | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 packages/extension/src/hooks/usePopupContext.tsx diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx index 247aa1a3..a8ea5f04 100644 --- a/packages/extension/src/components/Popup.tsx +++ b/packages/extension/src/components/Popup.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, createContext, forwardRef } from "react" +import { useState, useEffect, forwardRef } from "react" import { Popover, PopoverContent, PopoverAnchor } from "@/components/ui/popover" import { Menu } from "@/components/menu/Menu" import { useUserSettings } from "@/hooks/useSettings" @@ -6,6 +6,7 @@ import { useDetectStartup } from "@/hooks/useDetectStartup" import { useTabCommandReceiver } from "@/hooks/useTabCommandReceiver" import { useSidePanelNavigation } from "@/hooks/useSidePanelNavigation" import { useSidePanelAutoClose } from "@/hooks/useSidePanelAutoClose" +import { popupContext } from "@/hooks/usePopupContext" import { hexToHsl, isMac, onHover, cn } from "@/lib/utils" import { t } from "@/services/i18n" import { STYLE_VARIABLE, EXIT_DURATION, SIDE, ALIGN } from "@/const" @@ -18,14 +19,6 @@ export type PopupProps = { onHover?: (hover: boolean) => void } -type ContextType = { - isPreview?: boolean - inTransition?: boolean - side: SIDE - align: ALIGN -} -export const popupContext = createContext({} as ContextType) - export const Popup = forwardRef( (props: PopupProps, ref) => { useTabCommandReceiver() diff --git a/packages/extension/src/components/Tooltip.tsx b/packages/extension/src/components/Tooltip.tsx index a4473a43..ba6c6c06 100644 --- a/packages/extension/src/components/Tooltip.tsx +++ b/packages/extension/src/components/Tooltip.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react" +import { useEffect, useState } from "react" import clsx from "clsx" import { Popover, @@ -6,7 +6,7 @@ import { PopoverAnchor, PopoverArrow, } from "@/components/ui/popover" -import { popupContext } from "@/components/Popup" +import { usePopupContext } from "@/hooks/usePopupContext" import { SIDE } from "@/const" type PopupProps = { @@ -18,7 +18,7 @@ type PopupProps = { } export function Tooltip(props: PopupProps) { - const { side } = useContext(popupContext) + const { side } = usePopupContext() const delay = props.delay ?? 300 const [isOpen, setIsOpen] = useState(false) const [shouldRender, setShouldRender] = useState(false) diff --git a/packages/extension/src/components/menu/MenuFolder.tsx b/packages/extension/src/components/menu/MenuFolder.tsx index 35b97482..e4665852 100644 --- a/packages/extension/src/components/menu/MenuFolder.tsx +++ b/packages/extension/src/components/menu/MenuFolder.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useContext, useLayoutEffect } from "react" +import React, { useState, useRef, useLayoutEffect } from "react" import { Popover, PopoverContent, PopoverAnchor } from "@/components/ui/popover" import { ScrollAreaConditional } from "@/components/ui/scroll-area" @@ -7,7 +7,7 @@ import { MenuItem } from "./MenuItem" import { ChevronRight } from "lucide-react" import { HoverArea } from "@/components/menu/HoverArea" import { MenuImage } from "@/components/menu/MenuImage" -import { popupContext } from "@/components/Popup" +import { usePopupContext } from "@/hooks/usePopupContext" import css from "./Menu.module.css" import type { Command, CommandFolder } from "@/types" import { cn, onHover } from "@/lib/utils" @@ -77,7 +77,7 @@ export const MenuFolder = (props: { const [triggeredFolder, setTriggeredFolder] = useState("") const [hoveredFolder, setHoveredFolder] = useState("") const childActiveFolder = triggeredFolder || hoveredFolder - const { inTransition } = useContext(popupContext) + const { inTransition } = usePopupContext() // Resolve the effective style for this folder's content. // INHERIT: use parent style (isHorizontal), otherwise use folder's explicit style setting. diff --git a/packages/extension/src/components/menu/MenuItem.tsx b/packages/extension/src/components/menu/MenuItem.tsx index 0c64df92..b128b280 100644 --- a/packages/extension/src/components/menu/MenuItem.tsx +++ b/packages/extension/src/components/menu/MenuItem.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useContext } from "react" +import React, { useRef } from "react" import clsx from "clsx" -import { popupContext } from "@/components/Popup" +import { usePopupContext } from "@/hooks/usePopupContext" import { Tooltip } from "../Tooltip" import { RefreshCw, Check, AlertCircle } from "lucide-react" import { ResultPopup } from "@/components/result/ResultPopup" @@ -25,7 +25,7 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { const onlyIcon = props.onlyIcon const { iconUrl, title } = props.command const { selectionText, target } = useSelectContext() - const { isPreview, inTransition } = useContext(popupContext) + const { isPreview, inTransition } = usePopupContext() const { enabled, message: defaultMessage } = getCommandEnabled(props.command) const message = itemState.message || defaultMessage diff --git a/packages/extension/src/hooks/usePopupContext.tsx b/packages/extension/src/hooks/usePopupContext.tsx new file mode 100644 index 00000000..5294080b --- /dev/null +++ b/packages/extension/src/hooks/usePopupContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react" +import { SIDE, ALIGN } from "@/const" + +export type ContextType = { + isPreview?: boolean + inTransition?: boolean + side: SIDE + align: ALIGN +} + +export const popupContext = createContext({} as ContextType) + +export const usePopupContext = () => { + return useContext(popupContext) +} From 596e365bb671177d8843e8a64516aab651e54a12 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 31 Aug 2026 17:21:49 +0900 Subject: [PATCH 06/21] Update: Refine onboarding flow copy, callout motion, and styling Shorten onboarding intro copy, add a return-hint callout, translate the changes into every supported locale, give the callout bubble a gentle float animation, replace hardcoded #082f49 with the equivalent sky-950 token, and suppress the regular Tooltip while onboarding is active or the popup is mid-transition. Co-Authored-By: Claude Sonnet 5 --- .../extension/public/SelectionCommandLogo.png | Bin 0 -> 42635 bytes .../public/_locales/de/messages.json | 7 +- .../public/_locales/en/messages.json | 8 +- .../public/_locales/es/messages.json | 7 +- .../public/_locales/fr/messages.json | 7 +- .../public/_locales/hi/messages.json | 7 +- .../public/_locales/id/messages.json | 7 +- .../public/_locales/it/messages.json | 7 +- .../public/_locales/ja/messages.json | 7 +- .../public/_locales/ko/messages.json | 7 +- .../public/_locales/ms/messages.json | 7 +- .../public/_locales/pt_BR/messages.json | 7 +- .../public/_locales/pt_PT/messages.json | 7 +- .../public/_locales/ru/messages.json | 7 +- .../public/_locales/zh_CN/messages.json | 7 +- packages/extension/src/components/App.tsx | 8 +- packages/extension/src/components/Popup.tsx | 24 ++- packages/extension/src/components/Tooltip.tsx | 6 +- .../src/components/menu/MenuItem.tsx | 2 +- .../onboarding/OnboardingCallout.tsx | 44 +++++- .../onboarding/OnboardingFadeIn.tsx | 4 +- .../onboarding/OnboardingLayout.tsx | 27 ++-- .../components/onboarding/OnboardingPage.tsx | 5 +- .../components/onboarding/OnboardingRail.tsx | 24 +-- .../onboarding/OnboardingTargetText.tsx | 58 ++++--- .../onboarding/OnboardingValueShown.tsx | 46 +++--- .../onboarding/steps/StepAiPromptCommand.tsx | 38 +++-- .../onboarding/steps/StepComplete.tsx | 4 +- .../onboarding/steps/StepCustomize.tsx | 8 +- .../components/onboarding/steps/StepIntro.tsx | 52 ++++--- .../onboarding/steps/StepLinkPreview.tsx | 14 +- .../onboarding/steps/StepSearchCommand.tsx | 143 +++++++++++++----- .../onboarding/useOnboardingState.ts | 12 +- .../src/components/option/Option.tsx | 13 +- .../hooks/pageAction/usePageActionRunner.ts | 2 + .../extension/src/hooks/usePopupContext.tsx | 1 + .../extension/src/hooks/useSelectContext.tsx | 1 + .../src/providers/SelectContextProvider.tsx | 8 +- .../src/services/pageAction/dispatcher.ts | 1 + .../src/services/pageAction/elementWait.ts | 1 + packages/extension/tailwind.config.js | 15 +- 41 files changed, 443 insertions(+), 217 deletions(-) create mode 100644 packages/extension/public/SelectionCommandLogo.png diff --git a/packages/extension/public/SelectionCommandLogo.png b/packages/extension/public/SelectionCommandLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..b140bb1bd8669f1546d3fb49bb0cd431e2c8b29d GIT binary patch literal 42635 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 Jx%7(<{|^V5Y2g3> literal 0 HcmV?d00001 diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index ea0b61aa..0e4387cc 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1303,7 +1303,7 @@ "message": "Beim Hub anmelden" }, "onboarding_step0Title": { - "message": "Danke, dass du Selection Command installiert hast!" + "message": "Danke fürs Installieren!" }, "onboarding_step0Body": { "message": "Lass uns das Ganze in etwa einer Minute direkt ausprobieren." @@ -1318,7 +1318,7 @@ "message": "Markiere den folgenden Text, um sofort eine Suche auszuprobieren." }, "onboarding_step1TargetText": { - "message": "Mit Selection Command kannst du mit einem Klick suchen, die KI fragen oder Links in der Vorschau ansehen." + "message": "Eine Erweiterung, mit der du markierten Text mit einem Klick durchsuchen kannst." }, "onboarding_selectHint": { "message": "Text unten durch Ziehen markieren" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "Wechsle zurück zu diesem Tab, dann verschwindet diese Meldung automatisch." }, + "onboarding_step1Callout_2": { + "message": "Klicke irgendwo auf den Bildschirm" + }, "onboarding_step1ValueMessage": { "message": "Fertig – jetzt kannst du alles, was du markierst, sofort durchsuchen." }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 885f6cfa..a8bad7a4 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1321,7 +1321,7 @@ "message": "Login to Hub" }, "onboarding_step0Title": { - "message": "Thanks for installing Selection Command!", + "message": "Thanks for installing!", "description": "Onboarding Step0 (intro screen) heading, shown right after install." }, "onboarding_step0Body": { @@ -1341,7 +1341,7 @@ "description": "Onboarding Step1 (Search command) instruction shown before the user selects the sample text." }, "onboarding_step1TargetText": { - "message": "Selection Command lets you search, ask AI, or preview links in a single click.", + "message": "An extension that lets you search selected text in a single click.", "description": "Onboarding Step1 sample text the user is asked to select to trigger the real popup menu." }, "onboarding_selectHint": { @@ -1368,6 +1368,10 @@ "message": "Switch back to this tab and this message disappears automatically.", "description": "Onboarding Step1 hint shown while waiting for the user to return from the search command's popup/tab." }, + "onboarding_step1Callout_2": { + "message": "Click anywhere on the screen", + "description": "Onboarding Step1 callout/speech-bubble text prompting the user to click anywhere on the original page to dismiss the return hint and continue." + }, "onboarding_step1ValueMessage": { "message": "That's it - you can now search for anything you select in an instant.", "description": "Onboarding Step1 value message shown after the user runs the search command and returns to the onboarding tab." diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index ab8ebc56..41ee1d95 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1303,7 +1303,7 @@ "message": "Iniciar sesión en Hub" }, "onboarding_step0Title": { - "message": "¡Gracias por instalar Selection Command!" + "message": "¡Gracias por instalarlo!" }, "onboarding_step0Body": { "message": "Probémoslo durante un minuto, usándolo sobre la marcha." @@ -1318,7 +1318,7 @@ "message": "Selecciona el siguiente texto para probar una búsqueda al instante." }, "onboarding_step1TargetText": { - "message": "Con Selection Command puedes buscar, preguntar a la IA o previsualizar enlaces con un solo clic." + "message": "Una extensión que te permite buscar el texto seleccionado con un solo clic." }, "onboarding_selectHint": { "message": "Arrastra para seleccionar el texto de abajo" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "Vuelve a esta pestaña y este mensaje desaparecerá automáticamente." }, + "onboarding_step1Callout_2": { + "message": "Haz clic en cualquier parte de la pantalla" + }, "onboarding_step1ValueMessage": { "message": "Listo: ahora puedes buscar al instante cualquier texto que selecciones." }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 949b8ac8..e49c0ead 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1303,7 +1303,7 @@ "message": "Se connecter au Hub" }, "onboarding_step0Title": { - "message": "Merci d'avoir installé Selection Command !" + "message": "Merci pour l'installation !" }, "onboarding_step0Body": { "message": "Prenons une minute pour l'essayer en pratique." @@ -1318,7 +1318,7 @@ "message": "Sélectionnez le texte ci-dessous pour essayer une recherche immédiatement." }, "onboarding_step1TargetText": { - "message": "Avec Selection Command, vous pouvez rechercher, interroger l'IA ou prévisualiser des liens en un clic." + "message": "Une extension qui permet de rechercher le texte sélectionné en un clic." }, "onboarding_selectHint": { "message": "Faites glisser pour sélectionner le texte ci-dessous" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "Revenez à cet onglet et ce message disparaîtra automatiquement." }, + "onboarding_step1Callout_2": { + "message": "Cliquez n'importe où sur l'écran" + }, "onboarding_step1ValueMessage": { "message": "Et voilà — vous pouvez désormais rechercher instantanément tout ce que vous sélectionnez." }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index ef8b47d1..d2f636d3 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1303,7 +1303,7 @@ "message": "Hub में लॉगिन करें" }, "onboarding_step0Title": { - "message": "Selection Command इंस्टॉल करने के लिए धन्यवाद!" + "message": "इंस्टॉल करने के लिए धन्यवाद!" }, "onboarding_step0Body": { "message": "चलिए, इसे इस्तेमाल करते हुए लगभग एक मिनट में आज़माते हैं।" @@ -1318,7 +1318,7 @@ "message": "तुरंत सर्च आज़माने के लिए नीचे दिए गए टेक्स्ट को चुनें।" }, "onboarding_step1TargetText": { - "message": "Selection Command से आप एक क्लिक में सर्च कर सकते हैं, AI से पूछ सकते हैं, या लिंक का पूर्वावलोकन देख सकते हैं।" + "message": "एक एक्सटेंशन जिससे आप चुने हुए टेक्स्ट को एक क्लिक में सर्च कर सकते हैं।" }, "onboarding_selectHint": { "message": "नीचे दिए गए टेक्स्ट को खींचकर चुनें" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "इस टैब पर वापस आएं और यह संदेश अपने आप गायब हो जाएगा।" }, + "onboarding_step1Callout_2": { + "message": "स्क्रीन पर कहीं भी क्लिक करें" + }, "onboarding_step1ValueMessage": { "message": "बस इतना ही — अब आप जो भी टेक्स्ट चुनें, उसे तुरंत सर्च कर सकते हैं।" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index e1a1482c..c2af47c7 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1306,7 +1306,7 @@ "message": "Masuk ke Hub" }, "onboarding_step0Title": { - "message": "Terima kasih telah menginstal Selection Command!" + "message": "Terima kasih telah menginstal!" }, "onboarding_step0Body": { "message": "Mari coba selama sekitar satu menit sambil langsung mempraktikkannya." @@ -1321,7 +1321,7 @@ "message": "Pilih teks di bawah ini untuk langsung mencoba pencarian." }, "onboarding_step1TargetText": { - "message": "Dengan Selection Command, Anda bisa mencari, bertanya ke AI, atau melihat pratinjau tautan hanya dengan satu klik." + "message": "Ekstensi yang memungkinkan Anda mencari teks yang dipilih hanya dengan satu klik." }, "onboarding_selectHint": { "message": "Seret untuk memilih teks di bawah" @@ -1341,6 +1341,9 @@ "onboarding_step1ReturnHint": { "message": "Kembali ke tab ini dan pesan ini akan hilang secara otomatis." }, + "onboarding_step1Callout_2": { + "message": "Klik di mana saja pada layar" + }, "onboarding_step1ValueMessage": { "message": "Selesai — sekarang Anda bisa langsung mencari apa pun yang Anda pilih." }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 6d36b2ef..b4f2e956 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1303,7 +1303,7 @@ "message": "Accedi all'Hub" }, "onboarding_step0Title": { - "message": "Grazie per aver installato Selection Command!" + "message": "Grazie per averlo installato!" }, "onboarding_step0Body": { "message": "Proviamolo per circa un minuto, usandolo direttamente." @@ -1318,7 +1318,7 @@ "message": "Seleziona il testo qui sotto per provare subito una ricerca." }, "onboarding_step1TargetText": { - "message": "Con Selection Command puoi cercare, chiedere all'IA o visualizzare l'anteprima dei link con un solo clic." + "message": "Un'estensione che ti permette di cercare il testo selezionato con un solo clic." }, "onboarding_selectHint": { "message": "Trascina per selezionare il testo qui sotto" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "Torna a questa scheda e il messaggio scomparirà automaticamente." }, + "onboarding_step1Callout_2": { + "message": "Fai clic in un punto qualsiasi dello schermo" + }, "onboarding_step1ValueMessage": { "message": "Fatto: ora puoi cercare all'istante qualsiasi cosa selezioni." }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 580466ee..48ff4fce 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1300,7 +1300,7 @@ "message": "Hubへログイン" }, "onboarding_step0Title": { - "message": "Selection Commandのインストール、ありがとうございます!" + "message": "インストールありがとうございます!" }, "onboarding_step0Body": { "message": "まずは1分で、実際に使いながら試してみましょう。" @@ -1315,7 +1315,7 @@ "message": "気になるテキストを選択して、すぐに検索してみましょう。" }, "onboarding_step1TargetText": { - "message": "Selection Commandを使うと、検索・AIへの質問・リンクのプレビューをワンクリックで実行できます。" + "message": "選択テキストをワンクリックで検索できる拡張機能" }, "onboarding_selectHint": { "message": "下のテキストをドラッグして選択" @@ -1335,6 +1335,9 @@ "onboarding_step1ReturnHint": { "message": "もとの画面を選択すると、自動的に消えます" }, + "onboarding_step1Callout_2": { + "message": "画面のどこかをクリック" + }, "onboarding_step1ValueMessage": { "message": "これで、気になった情報をすぐに検索できました。" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 01b05f96..80eb4b27 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1303,7 +1303,7 @@ "message": "Hub에 로그인" }, "onboarding_step0Title": { - "message": "Selection Command을 설치해 주셔서 감사합니다!" + "message": "설치해 주셔서 감사합니다!" }, "onboarding_step0Body": { "message": "먼저 1분 동안 직접 사용해보며 체험해 봅시다." @@ -1318,7 +1318,7 @@ "message": "아래 텍스트를 선택해서 바로 검색해 보세요." }, "onboarding_step1TargetText": { - "message": "Selection Command를 사용하면 검색, AI에게 질문, 링크 미리보기를 클릭 한 번으로 실행할 수 있습니다." + "message": "선택한 텍스트를 클릭 한 번으로 검색할 수 있는 확장 프로그램" }, "onboarding_selectHint": { "message": "아래 텍스트를 드래그하여 선택하세요" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "이 탭으로 돌아오면 이 메시지는 자동으로 사라집니다." }, + "onboarding_step1Callout_2": { + "message": "화면 아무 곳이나 클릭" + }, "onboarding_step1ValueMessage": { "message": "이제 궁금했던 정보를 바로 검색할 수 있게 되었습니다." }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 8885be47..def34239 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1306,7 +1306,7 @@ "message": "Log masuk ke Hub" }, "onboarding_step0Title": { - "message": "Terima kasih kerana memasang Selection Command!" + "message": "Terima kasih kerana memasangnya!" }, "onboarding_step0Body": { "message": "Jom cuba selama lebih kurang seminit sambil terus menggunakannya." @@ -1321,7 +1321,7 @@ "message": "Pilih teks di bawah untuk cuba carian dengan segera." }, "onboarding_step1TargetText": { - "message": "Dengan Selection Command, anda boleh mencari, bertanya kepada AI, atau pratonton pautan hanya dengan satu klik." + "message": "Sambungan yang membolehkan anda mencari teks yang dipilih hanya dengan satu klik." }, "onboarding_selectHint": { "message": "Seret untuk pilih teks di bawah" @@ -1341,6 +1341,9 @@ "onboarding_step1ReturnHint": { "message": "Kembali ke tab ini dan mesej ini akan hilang secara automatik." }, + "onboarding_step1Callout_2": { + "message": "Klik di mana-mana pada skrin" + }, "onboarding_step1ValueMessage": { "message": "Selesai — kini anda boleh mencari dengan segera apa sahaja yang anda pilih." }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 0b0fe1f4..ea8951fb 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1306,7 +1306,7 @@ "message": "Entrar no Hub" }, "onboarding_step0Title": { - "message": "Obrigado por instalar o Selection Command!" + "message": "Obrigado por instalar!" }, "onboarding_step0Body": { "message": "Vamos experimentar por cerca de um minuto, usando na prática." @@ -1321,7 +1321,7 @@ "message": "Selecione o texto abaixo para testar uma pesquisa agora mesmo." }, "onboarding_step1TargetText": { - "message": "Com o Selection Command, você pode pesquisar, perguntar à IA ou pré-visualizar links com um clique." + "message": "Uma extensão que permite pesquisar o texto selecionado com um clique." }, "onboarding_selectHint": { "message": "Arraste para selecionar o texto abaixo" @@ -1341,6 +1341,9 @@ "onboarding_step1ReturnHint": { "message": "Volte para esta aba e esta mensagem desaparece automaticamente." }, + "onboarding_step1Callout_2": { + "message": "Clique em qualquer lugar da tela" + }, "onboarding_step1ValueMessage": { "message": "Pronto — agora você pode pesquisar instantaneamente qualquer coisa que selecionar." }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index ae3f2b91..f1c97ee5 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1306,7 +1306,7 @@ "message": "Iniciar sessão no Hub" }, "onboarding_step0Title": { - "message": "Obrigado por instalar o Selection Command!" + "message": "Obrigado por instalar!" }, "onboarding_step0Body": { "message": "Vamos experimentá-lo durante cerca de um minuto, na prática." @@ -1321,7 +1321,7 @@ "message": "Selecione o texto abaixo para experimentar uma pesquisa de imediato." }, "onboarding_step1TargetText": { - "message": "Com o Selection Command, pode pesquisar, perguntar à IA ou pré-visualizar links com um único clique." + "message": "Uma extensão que permite pesquisar o texto selecionado com um único clique." }, "onboarding_selectHint": { "message": "Arraste para selecionar o texto abaixo" @@ -1341,6 +1341,9 @@ "onboarding_step1ReturnHint": { "message": "Volte a este separador e esta mensagem desaparece automaticamente." }, + "onboarding_step1Callout_2": { + "message": "Clique em qualquer parte do ecrã" + }, "onboarding_step1ValueMessage": { "message": "Pronto — agora já pode pesquisar instantaneamente tudo o que selecionar." }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index bbc6b8ef..abca8128 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1303,7 +1303,7 @@ "message": "Войти в Hub" }, "onboarding_step0Title": { - "message": "Спасибо за установку Selection Command!" + "message": "Спасибо за установку!" }, "onboarding_step0Body": { "message": "Давайте потратим примерно минуту, чтобы попробовать его в деле." @@ -1318,7 +1318,7 @@ "message": "Выделите текст ниже, чтобы сразу попробовать поиск." }, "onboarding_step1TargetText": { - "message": "С Selection Command вы можете искать, спрашивать ИИ или просматривать ссылки одним щелчком." + "message": "Расширение, позволяющее искать выделенный текст одним щелчком." }, "onboarding_selectHint": { "message": "Перетащите, чтобы выделить текст ниже" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "Вернитесь на эту вкладку, и сообщение исчезнет автоматически." }, + "onboarding_step1Callout_2": { + "message": "Нажмите в любом месте экрана" + }, "onboarding_step1ValueMessage": { "message": "Готово — теперь вы можете мгновенно искать всё, что выделите." }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 942652a5..2c770dfc 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1303,7 +1303,7 @@ "message": "登录 Hub" }, "onboarding_step0Title": { - "message": "感谢您安装 Selection Command!" + "message": "感谢您的安装!" }, "onboarding_step0Body": { "message": "让我们花一分钟时间,边操作边体验一下吧。" @@ -1318,7 +1318,7 @@ "message": "选择下面的文本,立即试试搜索功能。" }, "onboarding_step1TargetText": { - "message": "使用 Selection Command,只需一键即可搜索、询问 AI 或预览链接。" + "message": "一款可以一键搜索所选文本的扩展程序" }, "onboarding_selectHint": { "message": "拖动以选择下方文本" @@ -1338,6 +1338,9 @@ "onboarding_step1ReturnHint": { "message": "切回此标签页,该提示会自动消失。" }, + "onboarding_step1Callout_2": { + "message": "点击屏幕上的任意位置" + }, "onboarding_step1ValueMessage": { "message": "就是这样——现在您可以立即搜索所选的任何内容。" }, diff --git a/packages/extension/src/components/App.tsx b/packages/extension/src/components/App.tsx index c71f54e3..5afe96c6 100644 --- a/packages/extension/src/components/App.tsx +++ b/packages/extension/src/components/App.tsx @@ -26,7 +26,6 @@ type Props = { export function App({ rootElm }: Props) { const [positionElm, setPositionElm] = useState(null) - const [isHover, setIsHover] = useState(false) useEffect(() => { const handleShowToast = ( @@ -72,12 +71,9 @@ export function App({ rootElm }: Props) { return ( - + - setIsHover(v)} - /> + diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx index a8ea5f04..c5247514 100644 --- a/packages/extension/src/components/Popup.tsx +++ b/packages/extension/src/components/Popup.tsx @@ -6,6 +6,7 @@ import { useDetectStartup } from "@/hooks/useDetectStartup" import { useTabCommandReceiver } from "@/hooks/useTabCommandReceiver" import { useSidePanelNavigation } from "@/hooks/useSidePanelNavigation" import { useSidePanelAutoClose } from "@/hooks/useSidePanelAutoClose" +import { useSelectContext } from "@/hooks/useSelectContext" import { popupContext } from "@/hooks/usePopupContext" import { hexToHsl, isMac, onHover, cn } from "@/lib/utils" import { t } from "@/services/i18n" @@ -16,7 +17,7 @@ import css from "./Popup.module.css" export type PopupProps = { positionElm: Element | null isPreview?: boolean - onHover?: (hover: boolean) => void + inOnboarding?: boolean } export const Popup = forwardRef( @@ -33,6 +34,7 @@ export const Popup = forwardRef( ...props, isHover, }) + const { setDetectSelectionEnabled } = useSelectContext() const isPreview = props.isPreview === true const placement = userSettings?.popupPlacement const side = isPreview ? SIDE.bottom : (placement?.side ?? SIDE.top) @@ -93,15 +95,29 @@ export const Popup = forwardRef( clearTimeout(transitionTimer) clearTimeout(delayTimer) } - }, [visible]) + }, [visible, userSettings?.userStyles]) + + useEffect(() => { + if (!visible || props.positionElm == null) { + setDetectSelectionEnabled(true) + } + }, [visible, props.positionElm, setDetectSelectionEnabled]) const handleOnHover = (hover: boolean) => { setIsHover(hover) - props.onHover?.(hover) + setDetectSelectionEnabled(!hover) } return ( - + {isPreview && } diff --git a/packages/extension/src/components/Tooltip.tsx b/packages/extension/src/components/Tooltip.tsx index ba6c6c06..0dee55da 100644 --- a/packages/extension/src/components/Tooltip.tsx +++ b/packages/extension/src/components/Tooltip.tsx @@ -18,7 +18,7 @@ type PopupProps = { } export function Tooltip(props: PopupProps) { - const { side } = usePopupContext() + const { side, inTransition, inOnboarding } = usePopupContext() const delay = props.delay ?? 300 const [isOpen, setIsOpen] = useState(false) const [shouldRender, setShouldRender] = useState(false) @@ -51,7 +51,7 @@ export function Tooltip(props: PopupProps) { return () => clearTimeout(timer) }, [isOpen, delay]) - if (props.disabled || !elm) { + if (inTransition || inOnboarding || props.disabled || !elm) { return null } @@ -61,7 +61,7 @@ export function Tooltip(props: PopupProps) { {shouldRender && ( false) is always immediate. + openDelay?: number + // Extra classes merged onto the popover content, e.g. to override the + // default enter-animation duration for a specific caller. + contentClassName?: string side?: SIDE align?: ALIGN children: ReactNode @@ -24,24 +32,46 @@ type Props = { export function OnboardingCallout({ targetElm, open, - side = SIDE.bottom, + openDelay = 0, + contentClassName, + side = SIDE.top, align = ALIGN.center, children, }: Props) { + const [shown, setShown] = useState(false) + + useEffect(() => { + if (!open) { + setShown(false) + return + } + if (openDelay <= 0) { + setShown(true) + return + } + const timer = window.setTimeout(() => setShown(true), openDelay) + return () => window.clearTimeout(timer) + }, [open, openDelay]) + if (!targetElm) return null return ( - + - {open && ( + {shown && ( -

{children}

- +
+

{children}

+
+
)}
diff --git a/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx index 84ccff7c..b48a30a7 100644 --- a/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx +++ b/packages/extension/src/components/onboarding/OnboardingFadeIn.tsx @@ -4,19 +4,21 @@ import clsx from "clsx" type Props = { children: ReactNode className?: string + delay?: number // in milliseconds; optional delay before the fade-in starts } // Wraps a block of onboarding explanation text so it fades in from slightly // below, per the PRD's "説明テキストは少し下からフェードイン" rule. Uses a // `key`-less CSS animation (re-triggered by React remounting the element, // e.g. when the step/phase changes) rather than a JS animation library. -export function OnboardingFadeIn({ children, className }: Props) { +export function OnboardingFadeIn({ children, className, delay }: Props) { return (
{children}
diff --git a/packages/extension/src/components/onboarding/OnboardingLayout.tsx b/packages/extension/src/components/onboarding/OnboardingLayout.tsx index cc2e2988..26b29743 100644 --- a/packages/extension/src/components/onboarding/OnboardingLayout.tsx +++ b/packages/extension/src/components/onboarding/OnboardingLayout.tsx @@ -3,7 +3,7 @@ import { t } from "@/services/i18n" import { getProgress, showsSkip } from "./onboardingProgress" import type { OnboardingStep } from "@/types/onboarding" -const ICON_URL = chrome.runtime.getURL("icon128.png") +const ICON_URL = chrome.runtime.getURL("SelectionCommandLogo.png") type Props = { step: OnboardingStep @@ -28,23 +28,24 @@ export function OnboardingLayout({ step, onSkip, children }: Props) { />
-
- - - Selection Command - -
- {progress && ( -
+ Logo of selection command + )} + {progress && ( +
{progress.pills.map((pill, i) => ( ))} @@ -56,7 +57,7 @@ export function OnboardingLayout({ step, onSkip, children }: Props) { )}
-
+
{children}
@@ -64,7 +65,7 @@ export function OnboardingLayout({ step, onSkip, children }: Props) { diff --git a/packages/extension/src/components/onboarding/OnboardingPage.tsx b/packages/extension/src/components/onboarding/OnboardingPage.tsx index 63d4d87b..fa68c51d 100644 --- a/packages/extension/src/components/onboarding/OnboardingPage.tsx +++ b/packages/extension/src/components/onboarding/OnboardingPage.tsx @@ -18,10 +18,9 @@ import "@/components/App.css" export function OnboardingPage() { const onboarding = useOnboardingState() const [positionElm, setPositionElm] = useState(null) - const [isHover, setIsHover] = useState(false) return ( - + {onboarding.step === OnboardingStep.INTRO && ( @@ -44,7 +43,7 @@ export function OnboardingPage() { - + ) diff --git a/packages/extension/src/components/onboarding/OnboardingRail.tsx b/packages/extension/src/components/onboarding/OnboardingRail.tsx index 053ba84d..f87bda83 100644 --- a/packages/extension/src/components/onboarding/OnboardingRail.tsx +++ b/packages/extension/src/components/onboarding/OnboardingRail.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronRight } from "lucide-react" +import { Check, ChevronsRight } from "lucide-react" import { cn } from "@/lib/utils" // Reinforces the PRD's "select -> command -> result" mental model @@ -41,9 +41,7 @@ export function OnboardingRail({
{beats.map((label, i) => { @@ -59,14 +57,14 @@ export function OnboardingRail({ return (
{i > 0 && ( - )}
{state === "done" && ( @@ -93,10 +93,12 @@ export function OnboardingRail({ /> )} {state === "current" && ( - + + + )} - + {label}
diff --git a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx index 6bdc5608..1c108b08 100644 --- a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx +++ b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx @@ -1,55 +1,56 @@ -import { useRef } from "react" +import { useRef, useState } from "react" import { TextCursor } from "lucide-react" import { t } from "@/services/i18n" -import { cn } from "@/lib/utils" +import { cn, isEmpty } from "@/lib/utils" import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion" +import { useSelectContext } from "@/hooks/useSelectContext" import { useSelectionDemo } from "./useSelectionDemo" +import { OnboardingCallout } from "./OnboardingCallout" type Props = { text: string - // True only pre-selection, while the step is still explaining what to do - // (StepPhase.EXPLAIN with no real selection yet) - drives both the - // "drag to select" caption and the animated demo. - demoActive: boolean // True once the user has actually selected the text (any later phase), // rendering it as a static, already-selected highlight instead. selected: boolean } // The sample text card shown on Steps 1-2. Demonstrates the "drag to -// select" gesture on a loop via useSelectionDemo() so first-time users -// recognize this as something to select rather than something to read - -// see onboarding_selectHint and the animated highlight/cursor below. -export function OnboardingTargetText({ text, demoActive, selected }: Props) { +// select" gesture on a loop via useSelectionDemo(), and points an +// OnboardingCallout at the card while nothing is selected - matching every +// other "do this next" hint in the onboarding flow (see OnboardingCallout) +// so first-time users recognize this as something to select rather than +// something to read. +export function OnboardingTargetText({ text, selected }: Props) { const prefersReducedMotion = usePrefersReducedMotion() + const { selectionText } = useSelectContext() const textRef = useRef(null) const cursorRef = useRef(null) - const runDemo = demoActive && !prefersReducedMotion + const [cardElm, setCardElm] = useState(null) + // Nothing currently selected -> still prompting the user to select the + // sample text. Tracks live selection (not the latched `selected` prop) so + // the hint and demo both reappear if the user deselects without running + // a command yet. + const showSelectHint = isEmpty(selectionText) + const runDemo = !prefersReducedMotion && showSelectHint useSelectionDemo(textRef, cursorRef, runDemo) return (
- {demoActive && ( - - - {t("onboarding_selectHint")} - - )} -
)}
+ + + + + {t("onboarding_selectHint")} + +
) } diff --git a/packages/extension/src/components/onboarding/OnboardingValueShown.tsx b/packages/extension/src/components/onboarding/OnboardingValueShown.tsx index 86823498..2b94fe97 100644 --- a/packages/extension/src/components/onboarding/OnboardingValueShown.tsx +++ b/packages/extension/src/components/onboarding/OnboardingValueShown.tsx @@ -1,42 +1,54 @@ -import { Check } from "lucide-react" +import { ChevronRight } from "lucide-react" import { t } from "@/services/i18n" import { OnboardingRail } from "./OnboardingRail" +import { OnboardingFadeIn } from "./OnboardingFadeIn" type Props = { message: string resultLabel: string onNext: () => void + titleRenderer?: () => React.ReactNode } // The payoff screen shown once a step's VALUE_SHOWN phase is reached // (Steps 1-3 all end here): a checkmark, the step's value message, and the // select -> command -> result rail with every beat marked done - the // PRD's "説明 → 操作 → 成功 → 理解" loop closing out. -export function OnboardingValueShown({ message, resultLabel, onNext }: Props) { +export function OnboardingValueShown({ + message, + resultLabel, + onNext, + titleRenderer, +}: Props) { return ( -
- - - +
+ {titleRenderer?.()} -

- {message} -

+ +

+ {message} +

- + +
) diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index d319a9e0..5ceedd0b 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -39,21 +39,18 @@ export function StepAiPromptCommand({ onboarding }: Props) { setCalloutElm(null) return } - let cancelled = false - const find = () => { - if (cancelled) return - const elm = document.querySelector( - `[data-command-id="${ONBOARDING_AI_PROMPT_COMMAND_ID}"]`, - ) - if (elm) { - setCalloutElm(elm) - } else { - window.setTimeout(find, 150) - } + // Observe the DOM instead of polling, so the callout both appears once + // the popup menu renders AND disappears if the button is later removed. + const selector = `[data-command-id="${ONBOARDING_AI_PROMPT_COMMAND_ID}"]` + const sync = () => { + const elm = document.querySelector(selector) + setCalloutElm(elm) } - find() + sync() + const observer = new MutationObserver(sync) + observer.observe(document.body, { childList: true, subtree: true }) return () => { - cancelled = true + observer.disconnect() } }, [phase]) @@ -79,6 +76,13 @@ export function StepAiPromptCommand({ onboarding }: Props) { return (
+ + {t("onboarding_step2Callout")} - -
) } diff --git a/packages/extension/src/components/onboarding/steps/StepComplete.tsx b/packages/extension/src/components/onboarding/steps/StepComplete.tsx index 79b1dc90..8d88b489 100644 --- a/packages/extension/src/components/onboarding/steps/StepComplete.tsx +++ b/packages/extension/src/components/onboarding/steps/StepComplete.tsx @@ -30,7 +30,7 @@ export function StepComplete({ onboarding }: Props) { <> - +

@@ -42,7 +42,7 @@ export function StepComplete({ onboarding }: Props) {
- +
+ + Logo of selection command + + +
+ +

+ {t("onboarding_step0Title")} +

+
+ +

+ {t("onboarding_step0Body")} +

+
+
+ + + +
) } diff --git a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx index b77b5b8e..107d1bb2 100644 --- a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx +++ b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx @@ -46,6 +46,13 @@ export function StepLinkPreview({ onboarding }: Props) { return (
+ +
- -
) } diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx index edbcdab0..527f2c96 100644 --- a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react" +import { PartyPopper } from "lucide-react" import { t } from "@/services/i18n" +import { cn } from "@/lib/utils" import { useSelectContext } from "@/hooks/useSelectContext" import { useSection } from "@/hooks/useSettings" import { CACHE_SECTIONS } from "@/services/settings/settingsCache" @@ -36,6 +38,7 @@ export function StepSearchCommand({ onboarding }: Props) { const { selectionText } = useSelectContext() const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) const [calloutElm, setCalloutElm] = useState(null) + const [returnCalloutElm, setReturnCalloutElm] = useState(null) const googleCommand = commands?.find((c) => c.title === "Google") @@ -45,35 +48,43 @@ export function StepSearchCommand({ onboarding }: Props) { if (phase !== StepPhase.EXPLAIN) return if (isEmpty(selectionText)) return onboarding.recordFirstSelection(OnboardingStep.SEARCH) - setPhase(StepPhase.WAIT_EXECUTE) + setPhase(StepPhase.WAIT_EXECUTE, 600) // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, selectionText]) // Find the rendered Google command button (see MenuItem.tsx's // `data-command-id`) to point the callout at, once the menu has had a - // chance to render. + // chance to render. Re-runs on every selection change so that redoing the + // text selection (which closes and reopens the real popup menu) hides the + // stale callout and re-anchors it to the freshly rendered button instead + // of staying frozen at the old position. useEffect(() => { if (phase !== StepPhase.WAIT_EXECUTE || !googleCommand) { setCalloutElm(null) return } - let cancelled = false - const find = () => { - if (cancelled) return - const elm = document.querySelector( - `[data-command-id="${googleCommand.id}"]`, - ) - if (elm) { - setCalloutElm(elm) - } else { - window.setTimeout(find, 150) - } + if (isEmpty(selectionText)) { + // Selection was cleared - the popup menu is gone, hide the callout + // until a new selection reopens it. + setCalloutElm(null) + return } - find() + + // Observe the DOM instead of polling, so the callout both appears once + // the popup menu renders AND disappears if the button is later removed + // (e.g. the popup menu closes without a selection change in between). + const selector = `[data-command-id="${googleCommand.id}"]` + const sync = () => { + const elm = document.querySelector(selector) + setCalloutElm(elm) + } + sync() + const observer = new MutationObserver(sync) + observer.observe(document.body, { childList: true, subtree: true }) return () => { - cancelled = true + observer.disconnect() } - }, [phase, googleCommand]) + }, [phase, googleCommand, selectionText]) // WAIT_EXECUTE -> WAIT_RETURN: any search-type command counts (the // callout points at Google specifically, but the default set has several @@ -94,18 +105,33 @@ export function StepSearchCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - // WAIT_RETURN -> VALUE_SHOWN: the search command opens a separate - // popup/tab, so "coming back" is detected via the onboarding tab - // regaining visibility. + // WAIT_RETURN -> VALUE_SHOWN: the search command opens in a separate + // browser window (see Popup.execute -> BgCommand.openPopup -> + // chrome.windows.create), not a tab in this same window. Switching focus + // between two on-screen windows doesn't reliably fire `visibilitychange` + // (that API tracks tab occlusion/minimization, not window focus), so a + // `focus` listener on this window is needed to reliably detect the user + // clicking back onto the onboarding window. useEffect(() => { if (phase !== StepPhase.WAIT_RETURN) return + + const elm = document.querySelector( + "[data-testid='onboarding-step1-callout-anchor']", + ) + setReturnCalloutElm(elm) + const onVisible = () => { if (document.visibilityState === "visible") { setPhase(StepPhase.VALUE_SHOWN) + setReturnCalloutElm(null) } } document.addEventListener("visibilitychange", onVisible) - return () => document.removeEventListener("visibilitychange", onVisible) + window.addEventListener("focus", onVisible) + return () => { + document.removeEventListener("visibilitychange", onVisible) + window.removeEventListener("focus", onVisible) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) @@ -115,18 +141,40 @@ export function StepSearchCommand({ onboarding }: Props) { message={t("onboarding_step1ValueMessage")} resultLabel={t("onboarding_railResultSearch")} onNext={() => onboarding.goToStep(OnboardingStep.AI_PROMPT)} + titleRenderer={() => ( +

+ + + + {t("Option_commandType_search_title")} +

+ )} /> ) } return ( -
+
+ +

+ 1.{" "} + {t("Option_commandType_search_title")} +

+
+ {phase === StepPhase.WAIT_RETURN ? ( -

+

{t("onboarding_step1ReturnHint")}

) : ( @@ -134,26 +182,49 @@ export function StepSearchCommand({ onboarding }: Props) { {t("onboarding_step1Explain")}

)} + +
- {phase !== StepPhase.WAIT_RETURN && ( - )} - + + {phase !== StepPhase.WAIT_RETURN && ( + + )} + + + {t("onboarding_step1Callout")} - + + {t("onboarding_step1Callout_2")} +
) } diff --git a/packages/extension/src/components/onboarding/useOnboardingState.ts b/packages/extension/src/components/onboarding/useOnboardingState.ts index 93a82d8a..82c5f6a1 100644 --- a/packages/extension/src/components/onboarding/useOnboardingState.ts +++ b/packages/extension/src/components/onboarding/useOnboardingState.ts @@ -37,7 +37,7 @@ export function useOnboardingState() { const [step, setStep] = useState( () => readE2eOverride()?.step ?? OnboardingStep.INTRO, ) - const [phase, setPhase] = useState( + const [phase, _setPhase] = useState( () => readE2eOverride()?.phase ?? StepPhase.EXPLAIN, ) const startedAtRef = useRef(Date.now()) @@ -52,9 +52,17 @@ export function useOnboardingState() { }) }, []) + const setPhase = useCallback((next: StepPhase, delay?: number) => { + if (delay) { + setTimeout(() => _setPhase(next), delay) + return + } + _setPhase(next) + }, []) + const goToStep = useCallback((next: OnboardingStep) => { setStep(next) - setPhase(StepPhase.EXPLAIN) + _setPhase(StepPhase.EXPLAIN) }, []) // Records the first text/link selection observed within a given step. diff --git a/packages/extension/src/components/option/Option.tsx b/packages/extension/src/components/option/Option.tsx index 87b3d1f0..af785e07 100644 --- a/packages/extension/src/components/option/Option.tsx +++ b/packages/extension/src/components/option/Option.tsx @@ -7,6 +7,7 @@ import { APP_ID, VERSION, SCREEN } from "@/const" import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics" import { Popup } from "@/components/Popup" +import { SelectContextProvider } from "@/providers/SelectContextProvider" import { TableOfContents } from "@/components/option/TableOfContents" import { ImportExport } from "@/components/option/ImportExport" import { UserSupport } from "@/components/option/UserSupport" @@ -93,11 +94,13 @@ export function Option() { ref={setPreviewElm} style={{ marginBottom: Math.max(popupHeight + 5, 30) }} > - + + +
diff --git a/packages/extension/src/hooks/pageAction/usePageActionRunner.ts b/packages/extension/src/hooks/pageAction/usePageActionRunner.ts index fb1872da..3a97f6ea 100644 --- a/packages/extension/src/hooks/pageAction/usePageActionRunner.ts +++ b/packages/extension/src/hooks/pageAction/usePageActionRunner.ts @@ -15,11 +15,13 @@ export function usePageActionRunner() { useEffect(() => { const listener = (param: any, _sender: any, response: any) => { + console.debug("Received TabCommand.execPageAction:", param) execute(param).then((result) => { response(result) }) return true } + console.debug("Adding listener for TabCommand.execPageAction") Ipc.addListener(TabCommand.execPageAction, listener) return () => { Ipc.removeListener(TabCommand.execPageAction) diff --git a/packages/extension/src/hooks/usePopupContext.tsx b/packages/extension/src/hooks/usePopupContext.tsx index 5294080b..d531f7f1 100644 --- a/packages/extension/src/hooks/usePopupContext.tsx +++ b/packages/extension/src/hooks/usePopupContext.tsx @@ -4,6 +4,7 @@ import { SIDE, ALIGN } from "@/const" export type ContextType = { isPreview?: boolean inTransition?: boolean + inOnboarding?: boolean side: SIDE align: ALIGN } diff --git a/packages/extension/src/hooks/useSelectContext.tsx b/packages/extension/src/hooks/useSelectContext.tsx index 8b938a25..bbf5ee09 100644 --- a/packages/extension/src/hooks/useSelectContext.tsx +++ b/packages/extension/src/hooks/useSelectContext.tsx @@ -4,6 +4,7 @@ export type ContextType = { selectionText: string target: Element | null setTarget: (elm: Element | null) => void + setDetectSelectionEnabled: (enabled: boolean) => void } export const selectContext = createContext({} as ContextType) diff --git a/packages/extension/src/providers/SelectContextProvider.tsx b/packages/extension/src/providers/SelectContextProvider.tsx index d9572828..e0424e7f 100644 --- a/packages/extension/src/providers/SelectContextProvider.tsx +++ b/packages/extension/src/providers/SelectContextProvider.tsx @@ -5,13 +5,12 @@ import { ContextType, selectContext } from "@/hooks/useSelectContext" export const SelectContextProvider = ({ children, - isPopupHover, }: { children: ReactNode - isPopupHover: boolean }) => { const [selectionText, _setSelectionText] = useState("") const [target, setTarget] = useState(null) + const [detectSelectionEnabled, setDetectSelectionEnabled] = useState(true) useEffect(() => { const setSelectionText = async (text: string) => { @@ -20,7 +19,7 @@ export const SelectContextProvider = ({ } const onSelectionchange = async () => { - if (isPopupHover) return + if (!detectSelectionEnabled) return const text = getSelectionText() await setSelectionText(text) } @@ -29,12 +28,13 @@ export const SelectContextProvider = ({ return () => { document.removeEventListener("selectionchange", onSelectionchange) } - }, [isPopupHover]) + }, [detectSelectionEnabled]) const value: ContextType = { selectionText, target, setTarget, + setDetectSelectionEnabled, } return ( diff --git a/packages/extension/src/services/pageAction/dispatcher.ts b/packages/extension/src/services/pageAction/dispatcher.ts index 6d310a91..ba75cd9e 100644 --- a/packages/extension/src/services/pageAction/dispatcher.ts +++ b/packages/extension/src/services/pageAction/dispatcher.ts @@ -15,6 +15,7 @@ export const PageActionDispatcher = { }, click: async (param: PageAction.Click): ActionReturn => { + console.debug(`Clicking element: ${param.label} (${param.selector})`) if (param.condition) { const { skip, error } = await resolveClickCondition( param.condition, diff --git a/packages/extension/src/services/pageAction/elementWait.ts b/packages/extension/src/services/pageAction/elementWait.ts index 19d6d4ea..757b2bc3 100644 --- a/packages/extension/src/services/pageAction/elementWait.ts +++ b/packages/extension/src/services/pageAction/elementWait.ts @@ -103,6 +103,7 @@ export function evaluateCondition( conditionType: PAGE_ACTION_CONDITION_TYPE, element: HTMLElement | null, ): boolean { + console.debug(`Evaluating condition ${conditionType} for element`, element) switch (conditionType) { case PAGE_ACTION_CONDITION_TYPE.empty: return element == null || isEmpty(getElementText(element)) diff --git a/packages/extension/tailwind.config.js b/packages/extension/tailwind.config.js index 418389a0..ed9ed539 100644 --- a/packages/extension/tailwind.config.js +++ b/packages/extension/tailwind.config.js @@ -68,6 +68,16 @@ module.exports = { "0%": { opacity: "0", transform: "scale(0.72)" }, "100%": { opacity: "1", transform: "scale(1)" }, }, + // Gentle vertical bob used to draw extra attention to the onboarding + // callout bubble. Uses the standalone `translate` property (not + // `transform`) so it composes with, instead of overriding, the + // `transform` Radix Popper uses to rotate/position PopoverArrow - + // both the bubble and its arrow share this animation and must move + // in sync. + "onboarding-float": { + "0%, 100%": { translate: "0 0" }, + "50%": { translate: "0 -4px" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -77,12 +87,13 @@ module.exports = { "spin-slow": "spin 3s linear infinite", popup: "popup 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both", "onboarding-rise": - "onboarding-rise 0.38s cubic-bezier(0.16, 1, 0.3, 1) both", + "onboarding-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both", "onboarding-ring": "onboarding-ring 2.4s ease-in-out 0.6s infinite", - "onboarding-blink": "onboarding-blink 2.4s ease-in-out 0.3s infinite", + "onboarding-blink": "onboarding-blink 2s ease-in-out 0.3s infinite", "onboarding-highlight": "onboarding-highlight 4s ease-in-out infinite", "onboarding-pop": "onboarding-pop 0.36s cubic-bezier(0.34, 1.4, 0.64, 1) both", + "onboarding-float": "onboarding-float 1.4s ease-in-out infinite", }, padding: { 0.5: "2px", From ee3de9852af42cc5c6b130b74ec1010f10468010 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 1 Sep 2026 10:04:51 +0900 Subject: [PATCH 07/21] Update: Keep the onboarding rail's beat animation continuous through payoff Steps 1-3 each switched their whole render tree to a separate once the payoff phase was reached, mounting a second, independent OnboardingRail and restarting its beat animation instead of carrying it through from the first "select" beat. Unify each step's JSX into a single return so the rail (and its wrapping fade-in) stays mounted at the same position across every phase, with only its activeBeat prop changing - OnboardingValueShown is now dead code and removed. Also folds in a few small, related touch-ups already in progress on this branch: an IPC broadcast fix so onboarding tabs receive sendAllTab commands, a return-hint copy tweak, and minor button/timing adjustments. Co-Authored-By: Claude Sonnet 5 --- .../public/_locales/ja/messages.json | 2 +- packages/extension/src/components/Popup.tsx | 4 +- .../onboarding/OnboardingValueShown.tsx | 55 ------ .../onboarding/steps/StepAiPromptCommand.tsx | 116 +++++++---- .../components/onboarding/steps/StepIntro.tsx | 2 +- .../onboarding/steps/StepLinkPreview.tsx | 125 +++++++----- .../onboarding/steps/StepSearchCommand.tsx | 180 ++++++++++-------- packages/extension/src/services/ipc.ts | 3 +- packages/extension/tailwind.config.js | 2 +- 9 files changed, 268 insertions(+), 221 deletions(-) delete mode 100644 packages/extension/src/components/onboarding/OnboardingValueShown.tsx diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 48ff4fce..e474aaa0 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1333,7 +1333,7 @@ "message": "このコマンドを実行すると、Googleで検索できます。" }, "onboarding_step1ReturnHint": { - "message": "もとの画面を選択すると、自動的に消えます" + "message": "もとの画面を選択すると、ウィンドウが自動的に消えます" }, "onboarding_step1Callout_2": { "message": "画面のどこかをクリック" diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx index c5247514..18ac857f 100644 --- a/packages/extension/src/components/Popup.tsx +++ b/packages/extension/src/components/Popup.tsx @@ -27,6 +27,8 @@ export const Popup = forwardRef( useSidePanelAutoClose() const { userSettings } = useUserSettings() + const { setDetectSelectionEnabled } = useSelectContext() + const [inTransition, setInTransition] = useState(false) const [shouldRender, setShouldRender] = useState(false) const [isHover, setIsHover] = useState(false) @@ -34,7 +36,7 @@ export const Popup = forwardRef( ...props, isHover, }) - const { setDetectSelectionEnabled } = useSelectContext() + const isPreview = props.isPreview === true const placement = userSettings?.popupPlacement const side = isPreview ? SIDE.bottom : (placement?.side ?? SIDE.top) diff --git a/packages/extension/src/components/onboarding/OnboardingValueShown.tsx b/packages/extension/src/components/onboarding/OnboardingValueShown.tsx deleted file mode 100644 index 2b94fe97..00000000 --- a/packages/extension/src/components/onboarding/OnboardingValueShown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ChevronRight } from "lucide-react" -import { t } from "@/services/i18n" -import { OnboardingRail } from "./OnboardingRail" -import { OnboardingFadeIn } from "./OnboardingFadeIn" - -type Props = { - message: string - resultLabel: string - onNext: () => void - titleRenderer?: () => React.ReactNode -} - -// The payoff screen shown once a step's VALUE_SHOWN phase is reached -// (Steps 1-3 all end here): a checkmark, the step's value message, and the -// select -> command -> result rail with every beat marked done - the -// PRD's "説明 → 操作 → 成功 → 理解" loop closing out. -export function OnboardingValueShown({ - message, - resultLabel, - onNext, - titleRenderer, -}: Props) { - return ( -
- {titleRenderer?.()} - - -

- {message} -

- - -
- - -
- ) -} diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index 5ceedd0b..1e9b9434 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react" +import { ChevronRight } from "lucide-react" import { t } from "@/services/i18n" import { useSelectContext } from "@/hooks/useSelectContext" import { isEmpty } from "@/lib/utils" @@ -8,7 +9,6 @@ import { OnboardingCallout } from "../OnboardingCallout" import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingTargetText } from "../OnboardingTargetText" import { OnboardingRail } from "../OnboardingRail" -import { OnboardingValueShown } from "../OnboardingValueShown" import { OnboardingStep, StepPhase } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" @@ -64,48 +64,90 @@ export function StepAiPromptCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - if (phase === StepPhase.VALUE_SHOWN) { - return ( - onboarding.goToStep(OnboardingStep.LINK_PREVIEW)} - /> - ) - } + const valueShown = phase === StepPhase.VALUE_SHOWN + // Renders through this single return for every phase, including + // VALUE_SHOWN, instead of branching into a separate + // subtree there - see StepSearchCommand.tsx for why: that would mount a + // second, independent OnboardingRail, losing the "select -> command -> + // result" beat animation's continuity right as the payoff screen appears. return ( -
- +
+ + {!valueShown && ( +

+ 2.{" "} + {t("Option_commandType_aiPrompt_title")} +

+ )} +
- -

+ - {t("onboarding_step2Explain")} -

-
+

+ {valueShown + ? t("onboarding_step2ValueMessage") + : t("onboarding_step2Explain")} +

+ + + + + +
- + {valueShown ? ( + + + + ) : ( + <> + + + - - {t("onboarding_step2Callout")} - + + {t("onboarding_step2Callout")} + + + )}
) } diff --git a/packages/extension/src/components/onboarding/steps/StepIntro.tsx b/packages/extension/src/components/onboarding/steps/StepIntro.tsx index 17bd90ff..16428525 100644 --- a/packages/extension/src/components/onboarding/steps/StepIntro.tsx +++ b/packages/extension/src/components/onboarding/steps/StepIntro.tsx @@ -38,7 +38,7 @@ export function StepIntro({ onboarding }: Props) { + + ) : ( + + + +
+ + + Shift + + + + + + {t("onboarding_clickKeycap")} + +
+
+ )}
) } diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx index 527f2c96..15937942 100644 --- a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import { PartyPopper } from "lucide-react" +import { ChevronRight, PartyPopper } from "lucide-react" import { t } from "@/services/i18n" import { cn } from "@/lib/utils" import { useSelectContext } from "@/hooks/useSelectContext" @@ -12,7 +12,6 @@ import { OnboardingCallout } from "../OnboardingCallout" import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingTargetText } from "../OnboardingTargetText" import { OnboardingRail } from "../OnboardingRail" -import { OnboardingValueShown } from "../OnboardingValueShown" import { OnboardingStep, StepPhase } from "@/types/onboarding" import type { UseOnboardingState } from "../useOnboardingState" @@ -135,96 +134,119 @@ export function StepSearchCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - if (phase === StepPhase.VALUE_SHOWN) { - return ( - onboarding.goToStep(OnboardingStep.AI_PROMPT)} - titleRenderer={() => ( -

- - - - {t("Option_commandType_search_title")} -

- )} - /> - ) - } + const valueShown = phase === StepPhase.VALUE_SHOWN return (
-

- 1.{" "} - {t("Option_commandType_search_title")} -

-
- - - {phase === StepPhase.WAIT_RETURN ? ( -

- {t("onboarding_step1ReturnHint")} -

+ {valueShown ? ( +

+ + + + {t("Option_commandType_search_title")} +

) : ( -

- {t("onboarding_step1Explain")} -

+

+ 1.{" "} + {t("Option_commandType_search_title")} +

)} - -
- {phase === StepPhase.WAIT_RETURN && ( - - )} - - - {phase !== StepPhase.WAIT_RETURN && ( - + +

+ {valueShown + ? t("onboarding_step1ValueMessage") + : phase === StepPhase.WAIT_RETURN + ? t("onboarding_step1ReturnHint") + : t("onboarding_step1Explain")} +

+
+ + + - )} - - - - {t("onboarding_step1Callout")} - - - - {t("onboarding_step1Callout_2")} - +
+
+ + {valueShown ? ( + + + + ) : ( + <> + {phase === StepPhase.WAIT_RETURN && ( + + )} + + + {phase !== StepPhase.WAIT_RETURN && ( + + )} + + + + {t("onboarding_step1Callout")} + + + + {t("onboarding_step1Callout_2")} + + + )}

) } diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index e2819ea1..e974f34c 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -381,8 +381,9 @@ export const Ipc = { * @returns Array of responses from each tab */ async sendAllTab(command: IpcCommand, param?: unknown): Promise { + const onboardingUrl = chrome.runtime.getURL("src/onboarding_page.html") const tabs = await chrome.tabs.query({ - url: ["http://*/*", "https://*/*"], + url: ["http://*/*", "https://*/*", onboardingUrl], }) const ps = tabs .filter( diff --git a/packages/extension/tailwind.config.js b/packages/extension/tailwind.config.js index ed9ed539..efd03deb 100644 --- a/packages/extension/tailwind.config.js +++ b/packages/extension/tailwind.config.js @@ -89,7 +89,7 @@ module.exports = { "onboarding-rise": "onboarding-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both", "onboarding-ring": "onboarding-ring 2.4s ease-in-out 0.6s infinite", - "onboarding-blink": "onboarding-blink 2s ease-in-out 0.3s infinite", + "onboarding-blink": "onboarding-blink 1.5s ease-in-out 0.3s infinite", "onboarding-highlight": "onboarding-highlight 4s ease-in-out infinite", "onboarding-pop": "onboarding-pop 0.36s cubic-bezier(0.34, 1.4, 0.64, 1) both", From ca814c4b28b44ee73ebb2abc47b2beaf0f28a68c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 1 Sep 2026 10:26:09 +0900 Subject: [PATCH 08/21] refactoring. --- .../onboarding/steps/StepSearchCommand.tsx | 128 +++++++++--------- 1 file changed, 63 insertions(+), 65 deletions(-) diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx index 15937942..6f50d905 100644 --- a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -19,19 +19,16 @@ type Props = { onboarding: UseOnboardingState } -// Maps this step's phase onto the select -> command -> result rail's -// 0/1/2 beat index (see OnboardingRail). WAIT_RETURN still counts as the -// "result" beat - the search already ran, we're just waiting for the user -// to switch back. +// WAIT_RETURN still counts as the rail's "result" beat (see +// OnboardingRail) - the search already ran, we're just waiting for the +// user to switch back. function railBeat(phase: StepPhase): 0 | 1 | 2 { if (phase === StepPhase.WAIT_EXECUTE) return 1 if (phase === StepPhase.WAIT_RETURN) return 2 return 0 } -// Step1: the user's first hands-on experience - select the sample text, -// see the real popup menu, run the (default) Google search command, and -// come back to see the value message. This is the PRD's "First Value". +// Step1 - the PRD's "First Value" moment. export function StepSearchCommand({ onboarding }: Props) { const { phase, setPhase } = onboarding const { selectionText } = useSelectContext() @@ -41,10 +38,15 @@ export function StepSearchCommand({ onboarding }: Props) { const googleCommand = commands?.find((c) => c.title === "Google") - // EXPLAIN -> WAIT_EXECUTE: once the user selects the sample text, the real - // popup menu opens on its own (default startup method is text selection). + const isExplain = phase === StepPhase.EXPLAIN + const isWaitExecute = phase === StepPhase.WAIT_EXECUTE + const isWaitReturn = phase === StepPhase.WAIT_RETURN + const isValueShown = phase === StepPhase.VALUE_SHOWN + + // The popup menu opens on its own once the sample text is selected + // (default startup method is text selection). useEffect(() => { - if (phase !== StepPhase.EXPLAIN) return + if (!isExplain) return if (isEmpty(selectionText)) return onboarding.recordFirstSelection(OnboardingStep.SEARCH) setPhase(StepPhase.WAIT_EXECUTE, 600) @@ -58,13 +60,11 @@ export function StepSearchCommand({ onboarding }: Props) { // stale callout and re-anchors it to the freshly rendered button instead // of staying frozen at the old position. useEffect(() => { - if (phase !== StepPhase.WAIT_EXECUTE || !googleCommand) { + if (!isWaitExecute || !googleCommand) { setCalloutElm(null) return } if (isEmpty(selectionText)) { - // Selection was cleared - the popup menu is gone, hide the callout - // until a new selection reopens it. setCalloutElm(null) return } @@ -83,14 +83,14 @@ export function StepSearchCommand({ onboarding }: Props) { return () => { observer.disconnect() } - }, [phase, googleCommand, selectionText]) + }, [isWaitExecute, googleCommand, selectionText]) - // WAIT_EXECUTE -> WAIT_RETURN: any search-type command counts (the - // callout points at Google specifically, but the default set has several - // search commands - being lenient here matches the PRD's "選択→コマンド→ - // 検索" mental model rather than requiring one exact command). + // Any search-type command counts here (the callout points at Google + // specifically, but the default set has several search commands - being + // lenient matches the PRD's "選択→コマンド→検索" mental model rather than + // requiring one exact command). useEffect(() => { - if (phase !== StepPhase.WAIT_EXECUTE) return + if (!isWaitExecute) return return subscribeCommandExecuted(({ commandType }) => { onboarding.recordCommandExecuted(OnboardingStep.SEARCH, commandType) if ( @@ -104,15 +104,15 @@ export function StepSearchCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - // WAIT_RETURN -> VALUE_SHOWN: the search command opens in a separate - // browser window (see Popup.execute -> BgCommand.openPopup -> - // chrome.windows.create), not a tab in this same window. Switching focus - // between two on-screen windows doesn't reliably fire `visibilitychange` - // (that API tracks tab occlusion/minimization, not window focus), so a - // `focus` listener on this window is needed to reliably detect the user - // clicking back onto the onboarding window. + // The search command opens in a separate browser window (see + // Popup.execute -> BgCommand.openPopup -> chrome.windows.create), not a + // tab in this same window. Switching focus between two on-screen windows + // doesn't reliably fire `visibilitychange` (that API tracks tab + // occlusion/minimization, not window focus), so a `focus` listener on + // this window is needed to reliably detect the user clicking back onto + // the onboarding window. useEffect(() => { - if (phase !== StepPhase.WAIT_RETURN) return + if (!isWaitReturn) return const elm = document.querySelector( "[data-testid='onboarding-step1-callout-anchor']", @@ -134,22 +134,20 @@ export function StepSearchCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - const valueShown = phase === StepPhase.VALUE_SHOWN - return (
- {valueShown ? ( + {isValueShown ? (

@@ -166,9 +164,9 @@ export function StepSearchCommand({ onboarding }: Props) {

- {valueShown + {isValueShown ? t("onboarding_step1ValueMessage") - : phase === StepPhase.WAIT_RETURN + : isWaitReturn ? t("onboarding_step1ReturnHint") : t("onboarding_step1Explain")}

@@ -187,47 +185,26 @@ export function StepSearchCommand({ onboarding }: Props) {
- {valueShown ? ( - - - - ) : ( + {(isExplain || isWaitExecute) && ( <> - {phase === StepPhase.WAIT_RETURN && ( - - )} - - {phase !== StepPhase.WAIT_RETURN && ( - - )} + - {t("onboarding_step1Callout")} + + )} + {isWaitReturn && ( + <> + )} + + {isValueShown && ( + + + + )}

) } From a45a8b9ed153042d730d2ae53ff901b148aecc93 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 1 Sep 2026 10:42:14 +0900 Subject: [PATCH 09/21] Update: Localize onboarding copy tweaks to all supported languages Propagates four ja/messages.json wording changes (step1 return-hint clarifying the popup window auto-closes, step2 target/callout text, and a trimmed step3 explain string) to the other 13 locales. Co-Authored-By: Claude Sonnet 5 --- packages/extension/public/_locales/de/messages.json | 8 ++++---- packages/extension/public/_locales/en/messages.json | 8 ++++---- packages/extension/public/_locales/es/messages.json | 8 ++++---- packages/extension/public/_locales/fr/messages.json | 8 ++++---- packages/extension/public/_locales/hi/messages.json | 8 ++++---- packages/extension/public/_locales/id/messages.json | 8 ++++---- packages/extension/public/_locales/it/messages.json | 8 ++++---- packages/extension/public/_locales/ja/messages.json | 6 +++--- packages/extension/public/_locales/ko/messages.json | 8 ++++---- packages/extension/public/_locales/ms/messages.json | 8 ++++---- packages/extension/public/_locales/pt_BR/messages.json | 8 ++++---- packages/extension/public/_locales/pt_PT/messages.json | 8 ++++---- packages/extension/public/_locales/ru/messages.json | 8 ++++---- packages/extension/public/_locales/zh_CN/messages.json | 8 ++++---- 14 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 0e4387cc..55a50d3c 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1336,7 +1336,7 @@ "message": "Klicke auf diesen Befehl, um mit Google zu suchen." }, "onboarding_step1ReturnHint": { - "message": "Wechsle zurück zu diesem Tab, dann verschwindet diese Meldung automatisch." + "message": "Wechsle zurück zu diesem Tab, dann schließt sich das Fenster automatisch." }, "onboarding_step1Callout_2": { "message": "Klicke irgendwo auf den Bildschirm" @@ -1348,10 +1348,10 @@ "message": "Als Nächstes fragen wir die KI zum folgenden Text." }, "onboarding_step2TargetText": { - "message": "Dies ist eine Erweiterung, mit der du markierten Text von einer Webseite in eine Prompt-Vorlage einfügen und ausführen kannst. Überlege dir ein paar Ideen, wofür du sie nutzen könntest." + "message": "Dies ist eine Chrome-Erweiterung, mit der du Text aus dem Web in einen Prompt einfügen und ausführen kannst - überlege dir ein paar Ideen, wofür du sie nutzen könntest." }, "onboarding_step2Callout": { - "message": "Führe diesen Befehl aus, um deine Auswahl über einen voreingestellten KI-Prompt zu senden." + "message": "Führe diesen Befehl aus, um deine Auswahl + einen Prompt an die KI zu senden." }, "onboarding_railResultAi": { "message": "KI" @@ -1360,7 +1360,7 @@ "message": "Jetzt kannst du die KI direkt auf der Seite, die du liest, zu allem fragen, was du markierst." }, "onboarding_step3Explain": { - "message": "Zum Schluss schauen wir uns einen Link an, ohne einen neuen Tab zu öffnen. Klicke bei gedrückter Umschalttaste (Shift) auf den folgenden Link." + "message": "Zum Schluss schauen wir uns einen Link an, ohne einen neuen Tab zu öffnen." }, "onboarding_step3LinkLabel": { "message": "Beispiel-Link" diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index a8bad7a4..0fe0bc2d 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1365,7 +1365,7 @@ "description": "Onboarding Step1 callout/speech-bubble text pointing at the default Google search command in the popup menu." }, "onboarding_step1ReturnHint": { - "message": "Switch back to this tab and this message disappears automatically.", + "message": "Switch back to this tab and the window closes automatically.", "description": "Onboarding Step1 hint shown while waiting for the user to return from the search command's popup/tab." }, "onboarding_step1Callout_2": { @@ -1381,11 +1381,11 @@ "description": "Onboarding Step2 (AiPrompt command) instruction shown before the user selects the sample text." }, "onboarding_step2TargetText": { - "message": "This is an extension that lets you embed selected text from a web page into a prompt template and run it. Think of some ideas for what you could use it for.", + "message": "Think of some ideas for what you could do with a Chrome extension that lets you embed text from the web into a prompt and run it.", "description": "Onboarding Step2 sample text the user is asked to select to trigger the AiPrompt command." }, "onboarding_step2Callout": { - "message": "Click this command to send your selection through a pre-set AI prompt.", + "message": "Run this command to send your selected text + a prompt to the AI.", "description": "Onboarding Step2 callout/speech-bubble text pointing at the onboarding's dedicated AiPrompt command in the popup menu." }, "onboarding_railResultAi": { @@ -1397,7 +1397,7 @@ "description": "Onboarding Step2 value message shown after the user runs the AiPrompt command." }, "onboarding_step3Explain": { - "message": "Finally, let's preview a link without opening a new tab. Shift+click the link below.", + "message": "Finally, let's preview a link without opening a new tab.", "description": "Onboarding Step3 (Link Preview) instruction." }, "onboarding_step3LinkLabel": { diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 41ee1d95..c2d7d7ae 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1336,7 +1336,7 @@ "message": "Haz clic en este comando para buscar en Google." }, "onboarding_step1ReturnHint": { - "message": "Vuelve a esta pestaña y este mensaje desaparecerá automáticamente." + "message": "Vuelve a esta pestaña y la ventana se cerrará automáticamente." }, "onboarding_step1Callout_2": { "message": "Haz clic en cualquier parte de la pantalla" @@ -1348,10 +1348,10 @@ "message": "A continuación, preguntemos a la IA sobre el siguiente texto." }, "onboarding_step2TargetText": { - "message": "Esta es una extensión que te permite insertar el texto seleccionado de una página web en una plantilla de prompt y ejecutarlo. Piensa en algunas ideas de para qué podrías usarla." + "message": "Piensa en algunas ideas de lo que podrías hacer con una extensión de Chrome que te permite insertar texto de la web en un prompt y ejecutarlo." }, "onboarding_step2Callout": { - "message": "Haz clic en este comando para enviar tu selección a través de un prompt de IA preconfigurado." + "message": "Ejecuta este comando para enviar tu texto seleccionado + un prompt a la IA." }, "onboarding_railResultAi": { "message": "IA" @@ -1360,7 +1360,7 @@ "message": "Ahora puedes preguntarle a la IA sobre cualquier cosa que selecciones, sin salir de la página que estás leyendo." }, "onboarding_step3Explain": { - "message": "Por último, previsualicemos un enlace sin abrir una nueva pestaña. Haz clic en el siguiente enlace manteniendo pulsada la tecla Shift." + "message": "Por último, previsualicemos un enlace sin abrir una nueva pestaña." }, "onboarding_step3LinkLabel": { "message": "Enlace de ejemplo" diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index e49c0ead..9da215cf 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1336,7 +1336,7 @@ "message": "Cliquez sur cette commande pour rechercher sur Google." }, "onboarding_step1ReturnHint": { - "message": "Revenez à cet onglet et ce message disparaîtra automatiquement." + "message": "Revenez à cet onglet et la fenêtre se fermera automatiquement." }, "onboarding_step1Callout_2": { "message": "Cliquez n'importe où sur l'écran" @@ -1348,10 +1348,10 @@ "message": "Ensuite, interrogeons l'IA à propos du texte ci-dessous." }, "onboarding_step2TargetText": { - "message": "Il s'agit d'une extension qui permet d'intégrer le texte sélectionné d'une page web dans un modèle de prompt et de l'exécuter. Réfléchissez à quelques idées d'utilisation." + "message": "Réfléchissez à ce que vous pourriez faire avec une extension Chrome qui permet d'intégrer du texte du web dans un prompt et de l'exécuter." }, "onboarding_step2Callout": { - "message": "Cliquez sur cette commande pour envoyer votre sélection via un prompt IA préconfiguré." + "message": "Exécutez cette commande pour envoyer votre texte sélectionné + un prompt à l'IA." }, "onboarding_railResultAi": { "message": "IA" @@ -1360,7 +1360,7 @@ "message": "Vous pouvez désormais interroger l'IA sur tout ce que vous sélectionnez, directement depuis la page que vous lisez." }, "onboarding_step3Explain": { - "message": "Enfin, prévisualisons un lien sans ouvrir un nouvel onglet. Cliquez sur le lien ci-dessous en maintenant la touche Maj (Shift)." + "message": "Enfin, prévisualisons un lien sans ouvrir un nouvel onglet." }, "onboarding_step3LinkLabel": { "message": "Lien d'exemple" diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index d2f636d3..85bf3a7d 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1336,7 +1336,7 @@ "message": "इस कमांड को चलाने पर Google पर सर्च होगा।" }, "onboarding_step1ReturnHint": { - "message": "इस टैब पर वापस आएं और यह संदेश अपने आप गायब हो जाएगा।" + "message": "इस टैब पर वापस आएं और विंडो अपने आप बंद हो जाएगी।" }, "onboarding_step1Callout_2": { "message": "स्क्रीन पर कहीं भी क्लिक करें" @@ -1348,10 +1348,10 @@ "message": "अब, नीचे दिए गए टेक्स्ट के बारे में AI से पूछते हैं।" }, "onboarding_step2TargetText": { - "message": "यह एक ऐसा एक्सटेंशन है जो वेब पेज पर चुने गए टेक्स्ट को एक प्रॉम्प्ट टेम्पलेट में डालकर चला सकता है। सोचिए, इससे आप क्या-क्या कर सकते हैं।" + "message": "सोचिए कि आप एक ऐसे Chrome एक्सटेंशन से क्या-क्या कर सकते हैं, जो वेब पर मौजूद टेक्स्ट को प्रॉम्प्ट में डालकर चला सकता है।" }, "onboarding_step2Callout": { - "message": "इस कमांड को चलाने पर आपका चयन पहले से सेट किए गए प्रॉम्प्ट के ज़रिए AI को भेजा जाता है।" + "message": "इस कमांड को चलाने पर आपका चुना हुआ टेक्स्ट + एक प्रॉम्प्ट AI को भेजा जाता है।" }, "onboarding_railResultAi": { "message": "AI" @@ -1360,7 +1360,7 @@ "message": "अब आप जो पेज पढ़ रहे हैं, वहीं से चुनी हुई किसी भी चीज़ के बारे में AI से पूछ सकते हैं।" }, "onboarding_step3Explain": { - "message": "आखिर में, बिना नया टैब खोले किसी लिंक का पूर्वावलोकन करते हैं। नीचे दिए गए लिंक पर Shift दबाकर क्लिक करें।" + "message": "आखिर में, बिना नया टैब खोले किसी लिंक का पूर्वावलोकन करते हैं।" }, "onboarding_step3LinkLabel": { "message": "नमूना लिंक" diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index c2af47c7..7a8fd20e 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1339,7 +1339,7 @@ "message": "Klik perintah ini untuk mencari di Google." }, "onboarding_step1ReturnHint": { - "message": "Kembali ke tab ini dan pesan ini akan hilang secara otomatis." + "message": "Kembali ke tab ini dan jendela akan tertutup secara otomatis." }, "onboarding_step1Callout_2": { "message": "Klik di mana saja pada layar" @@ -1351,10 +1351,10 @@ "message": "Selanjutnya, mari tanyakan ke AI tentang teks di bawah ini." }, "onboarding_step2TargetText": { - "message": "Ini adalah ekstensi yang memungkinkan Anda menyematkan teks yang dipilih dari halaman web ke dalam templat prompt dan menjalankannya. Pikirkan beberapa ide tentang apa yang bisa Anda lakukan dengannya." + "message": "Pikirkan ide-ide tentang apa yang bisa Anda lakukan dengan ekstensi Chrome yang dapat menyematkan teks dari web ke dalam prompt dan menjalankannya." }, "onboarding_step2Callout": { - "message": "Klik perintah ini untuk mengirim teks yang dipilih melalui prompt AI yang sudah diatur sebelumnya." + "message": "Jalankan perintah ini untuk mengirim teks yang dipilih + sebuah prompt ke AI." }, "onboarding_railResultAi": { "message": "AI" @@ -1363,7 +1363,7 @@ "message": "Sekarang Anda bisa bertanya ke AI tentang apa pun yang Anda pilih, langsung dari halaman yang sedang Anda baca." }, "onboarding_step3Explain": { - "message": "Terakhir, mari pratinjau sebuah tautan tanpa membuka tab baru. Klik tautan di bawah ini sambil menekan tombol Shift." + "message": "Terakhir, mari pratinjau sebuah tautan tanpa membuka tab baru." }, "onboarding_step3LinkLabel": { "message": "Tautan Contoh" diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index b4f2e956..6af54d49 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1336,7 +1336,7 @@ "message": "Fai clic su questo comando per cercare su Google." }, "onboarding_step1ReturnHint": { - "message": "Torna a questa scheda e il messaggio scomparirà automaticamente." + "message": "Torna a questa scheda e la finestra si chiuderà automaticamente." }, "onboarding_step1Callout_2": { "message": "Fai clic in un punto qualsiasi dello schermo" @@ -1348,10 +1348,10 @@ "message": "Ora chiediamo all'IA il testo qui sotto." }, "onboarding_step2TargetText": { - "message": "Questa è un'estensione che ti permette di inserire il testo selezionato da una pagina web in un modello di prompt ed eseguirlo. Pensa a qualche idea su come potresti usarla." + "message": "Pensa a qualche idea su cosa potresti fare con un'estensione Chrome che ti permette di inserire testo dal web in un prompt ed eseguirlo." }, "onboarding_step2Callout": { - "message": "Fai clic su questo comando per inviare la selezione tramite un prompt IA preimpostato." + "message": "Esegui questo comando per inviare il testo selezionato + un prompt all'IA." }, "onboarding_railResultAi": { "message": "IA" @@ -1360,7 +1360,7 @@ "message": "Ora puoi chiedere all'IA qualsiasi cosa selezioni, direttamente dalla pagina che stai leggendo." }, "onboarding_step3Explain": { - "message": "Infine, visualizziamo l'anteprima di un link senza aprire una nuova scheda. Fai clic sul link qui sotto tenendo premuto Shift." + "message": "Infine, visualizziamo l'anteprima di un link senza aprire una nuova scheda." }, "onboarding_step3LinkLabel": { "message": "Link di esempio" diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index e474aaa0..f43b3a6b 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1345,10 +1345,10 @@ "message": "次は、選択したテキストをAIに聞いてみましょう。" }, "onboarding_step2TargetText": { - "message": "Webページ上のテキストをプロンプトテンプレートに埋め込んで実行できる拡張機能で、どんなことが出来るか、アイデアを考えてください。" + "message": "Web上のテキストをプロンプトに埋め込んで実行できるChrome拡張機能によって、どんなことが出来るかアイデアを考えてください。" }, "onboarding_step2Callout": { - "message": "このコマンドを実行すると、選択したテキストをあらかじめ設定したプロンプトでAIに送れます。" + "message": "このコマンドを実行すると、選択したテキスト + プロンプトをAIに送れます。" }, "onboarding_railResultAi": { "message": "AI" @@ -1357,7 +1357,7 @@ "message": "Webページを読みながら、その場でAIに質問できました。" }, "onboarding_step3Explain": { - "message": "最後に、リンク先をタブを開かずに確認してみましょう。以下のリンクをShift+クリックしてください。" + "message": "最後に、リンク先をタブを開かずに確認してみましょう。" }, "onboarding_step3LinkLabel": { "message": "サンプルリンク" diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 80eb4b27..ff781db4 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1336,7 +1336,7 @@ "message": "이 명령을 실행하면 Google로 검색할 수 있습니다." }, "onboarding_step1ReturnHint": { - "message": "이 탭으로 돌아오면 이 메시지는 자동으로 사라집니다." + "message": "이 탭으로 돌아오면 창이 자동으로 닫힙니다." }, "onboarding_step1Callout_2": { "message": "화면 아무 곳이나 클릭" @@ -1348,10 +1348,10 @@ "message": "다음으로 아래 텍스트에 대해 AI에게 물어봅시다." }, "onboarding_step2TargetText": { - "message": "웹페이지의 텍스트를 프롬프트 템플릿에 넣어 실행할 수 있는 확장 프로그램으로 무엇을 할 수 있을지 아이디어를 생각해 보세요." + "message": "웹의 텍스트를 프롬프트에 넣어 실행할 수 있는 Chrome 확장 프로그램으로 무엇을 할 수 있을지 아이디어를 생각해 보세요." }, "onboarding_step2Callout": { - "message": "이 명령을 실행하면 선택한 텍스트를 미리 설정된 프롬프트로 AI에 보낼 수 있습니다." + "message": "이 명령을 실행하면 선택한 텍스트 + 프롬프트를 AI에 보낼 수 있습니다." }, "onboarding_railResultAi": { "message": "AI" @@ -1360,7 +1360,7 @@ "message": "이제 페이지를 읽으면서 바로 AI에게 무엇이든 물어볼 수 있습니다." }, "onboarding_step3Explain": { - "message": "마지막으로 새 탭을 열지 않고 링크를 미리 볼까요? 아래 링크를 Shift+클릭하세요." + "message": "마지막으로 새 탭을 열지 않고 링크를 미리 볼까요?" }, "onboarding_step3LinkLabel": { "message": "샘플 링크" diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index def34239..b5f4f230 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1339,7 +1339,7 @@ "message": "Klik arahan ini untuk mencari di Google." }, "onboarding_step1ReturnHint": { - "message": "Kembali ke tab ini dan mesej ini akan hilang secara automatik." + "message": "Kembali ke tab ini dan tetingkap akan tertutup secara automatik." }, "onboarding_step1Callout_2": { "message": "Klik di mana-mana pada skrin" @@ -1351,10 +1351,10 @@ "message": "Seterusnya, mari tanya AI tentang teks di bawah ini." }, "onboarding_step2TargetText": { - "message": "Ini adalah sambungan yang membolehkan anda menyisipkan teks yang dipilih daripada halaman web ke dalam templat gesaan dan menjalankannya. Fikirkan beberapa idea tentang apa yang boleh anda lakukan dengannya." + "message": "Fikirkan beberapa idea tentang apa yang boleh anda lakukan dengan sambungan Chrome yang membolehkan anda menyisipkan teks daripada web ke dalam gesaan dan menjalankannya." }, "onboarding_step2Callout": { - "message": "Klik perintah ini untuk menghantar pilihan anda melalui gesaan AI yang telah ditetapkan." + "message": "Jalankan arahan ini untuk menghantar teks yang dipilih + gesaan kepada AI." }, "onboarding_railResultAi": { "message": "AI" @@ -1363,7 +1363,7 @@ "message": "Kini anda boleh bertanya kepada AI tentang apa sahaja yang anda pilih, terus daripada halaman yang sedang anda baca." }, "onboarding_step3Explain": { - "message": "Akhir sekali, mari pratonton pautan tanpa membuka tab baharu. Klik pautan di bawah sambil menekan Shift." + "message": "Akhir sekali, mari pratonton pautan tanpa membuka tab baharu." }, "onboarding_step3LinkLabel": { "message": "Pautan Contoh" diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index ea8951fb..f4850b71 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1339,7 +1339,7 @@ "message": "Clique neste comando para pesquisar no Google." }, "onboarding_step1ReturnHint": { - "message": "Volte para esta aba e esta mensagem desaparece automaticamente." + "message": "Volte para esta aba e a janela se fecha automaticamente." }, "onboarding_step1Callout_2": { "message": "Clique em qualquer lugar da tela" @@ -1351,10 +1351,10 @@ "message": "A seguir, vamos perguntar à IA sobre o texto abaixo." }, "onboarding_step2TargetText": { - "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página da web em um modelo de prompt e executá-lo. Pense em algumas ideias do que você poderia fazer com ela." + "message": "Pense em algumas ideias do que você poderia fazer com uma extensão do Chrome que permite inserir texto da web em um prompt e executá-lo." }, "onboarding_step2Callout": { - "message": "Clique neste comando para enviar sua seleção por um prompt de IA pré-configurado." + "message": "Execute este comando para enviar seu texto selecionado + um prompt para a IA." }, "onboarding_railResultAi": { "message": "IA" @@ -1363,7 +1363,7 @@ "message": "Agora você pode perguntar à IA sobre qualquer coisa que selecionar, direto da página que está lendo." }, "onboarding_step3Explain": { - "message": "Por fim, vamos pré-visualizar um link sem abrir uma nova aba. Clique no link abaixo segurando a tecla Shift." + "message": "Por fim, vamos pré-visualizar um link sem abrir uma nova aba." }, "onboarding_step3LinkLabel": { "message": "Link de exemplo" diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index f1c97ee5..f5816710 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1339,7 +1339,7 @@ "message": "Clique neste comando para pesquisar no Google." }, "onboarding_step1ReturnHint": { - "message": "Volte a este separador e esta mensagem desaparece automaticamente." + "message": "Volte a este separador e a janela fecha-se automaticamente." }, "onboarding_step1Callout_2": { "message": "Clique em qualquer parte do ecrã" @@ -1351,10 +1351,10 @@ "message": "De seguida, vamos perguntar à IA sobre o texto abaixo." }, "onboarding_step2TargetText": { - "message": "Esta é uma extensão que permite inserir o texto selecionado de uma página web num modelo de prompt e executá-lo. Pense em algumas ideias para o que poderia usá-la." + "message": "Pense em algumas ideias sobre o que poderia fazer com uma extensão do Chrome que permite inserir texto da web num prompt e executá-lo." }, "onboarding_step2Callout": { - "message": "Clique neste comando para enviar a sua seleção através de um prompt de IA pré-configurado." + "message": "Execute este comando para enviar o texto selecionado + um prompt para a IA." }, "onboarding_railResultAi": { "message": "IA" @@ -1363,7 +1363,7 @@ "message": "Agora já pode perguntar à IA sobre o que selecionar, diretamente a partir da página que está a ler." }, "onboarding_step3Explain": { - "message": "Por fim, vamos pré-visualizar uma ligação sem abrir um novo separador. Clique na ligação abaixo enquanto mantém premida a tecla Shift." + "message": "Por fim, vamos pré-visualizar uma ligação sem abrir um novo separador." }, "onboarding_step3LinkLabel": { "message": "Ligação de exemplo" diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index abca8128..50708475 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1336,7 +1336,7 @@ "message": "Нажмите эту команду, чтобы выполнить поиск в Google." }, "onboarding_step1ReturnHint": { - "message": "Вернитесь на эту вкладку, и сообщение исчезнет автоматически." + "message": "Вернитесь на эту вкладку, и окно автоматически закроется." }, "onboarding_step1Callout_2": { "message": "Нажмите в любом месте экрана" @@ -1348,10 +1348,10 @@ "message": "Далее давайте спросим ИИ о тексте ниже." }, "onboarding_step2TargetText": { - "message": "Это расширение позволяет вставлять выделенный текст с веб-страницы в шаблон запроса и выполнять его. Подумайте, для чего это можно использовать." + "message": "Подумайте, что можно сделать с помощью расширения Chrome, которое позволяет вставлять текст из интернета в запрос и выполнять его." }, "onboarding_step2Callout": { - "message": "Нажмите эту команду, чтобы отправить выделенный текст через заранее заданный запрос к ИИ." + "message": "Выполните эту команду, чтобы отправить выделенный текст + запрос в ИИ." }, "onboarding_railResultAi": { "message": "ИИ" @@ -1360,7 +1360,7 @@ "message": "Теперь вы можете спрашивать ИИ о чём угодно прямо на странице, которую читаете." }, "onboarding_step3Explain": { - "message": "Наконец, давайте просмотрим ссылку без открытия новой вкладки. Щёлкните по ссылке ниже, удерживая Shift." + "message": "Наконец, давайте просмотрим ссылку без открытия новой вкладки." }, "onboarding_step3LinkLabel": { "message": "Пример ссылки" diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 2c770dfc..2dce2ab2 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1336,7 +1336,7 @@ "message": "点击此命令即可使用 Google 搜索。" }, "onboarding_step1ReturnHint": { - "message": "切回此标签页,该提示会自动消失。" + "message": "切回此标签页,窗口会自动关闭。" }, "onboarding_step1Callout_2": { "message": "点击屏幕上的任意位置" @@ -1348,10 +1348,10 @@ "message": "接下来,让我们向 AI 询问下面这段文字。" }, "onboarding_step2TargetText": { - "message": "这是一个可以将网页上选中的文字嵌入提示词模板并执行的扩展程序,想想它能帮你做些什么。" + "message": "想想你能用这个可以把网页文字嵌入提示词并执行的 Chrome 扩展程序做些什么。" }, "onboarding_step2Callout": { - "message": "点击此命令,即可通过预设的提示词将选中内容发送给 AI。" + "message": "运行此命令,即可将选中文本 + 提示词发送给 AI。" }, "onboarding_railResultAi": { "message": "AI" @@ -1360,7 +1360,7 @@ "message": "现在,您可以在阅读网页的同时,就您选择的任何内容向 AI 提问。" }, "onboarding_step3Explain": { - "message": "最后,让我们在不打开新标签页的情况下预览链接。请按住 Shift 键点击下面的链接。" + "message": "最后,让我们在不打开新标签页的情况下预览链接。" }, "onboarding_step3LinkLabel": { "message": "示例链接" From 01635b461069ecd5a936accf2f5f9217fb3ed9a2 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 2 Sep 2026 14:26:54 +0900 Subject: [PATCH 10/21] Update: Refine onboarding step transitions, menu placement, and copy Simplify onboarding step phase checks into named booleans, position the onboarding menu item toward the screen edge during onboarding, skip analytics for commands run from extension pages, add a completion emoji to the search step's value message, and drop the trailing punctuation from the search step's value message across all locales. Co-Authored-By: Claude Sonnet 5 --- .../public/_locales/de/messages.json | 2 +- .../public/_locales/en/messages.json | 2 +- .../public/_locales/es/messages.json | 2 +- .../public/_locales/fr/messages.json | 2 +- .../public/_locales/hi/messages.json | 2 +- .../public/_locales/id/messages.json | 2 +- .../public/_locales/it/messages.json | 2 +- .../public/_locales/ja/messages.json | 2 +- .../public/_locales/ko/messages.json | 2 +- .../public/_locales/ms/messages.json | 2 +- .../public/_locales/pt_BR/messages.json | 2 +- .../public/_locales/pt_PT/messages.json | 2 +- .../public/_locales/ru/messages.json | 2 +- .../public/_locales/zh_CN/messages.json | 2 +- packages/extension/src/action/executor.ts | 15 +++--- .../src/components/menu/MenuItem.tsx | 13 +++++- .../onboarding/steps/StepAiPromptCommand.tsx | 44 +++++++++--------- .../onboarding/steps/StepLinkPreview.tsx | 19 ++++---- .../onboarding/steps/StepSearchCommand.tsx | 46 +++++++++---------- .../src/services/option/defaultSettings.ts | 30 ++++++------ packages/extension/tailwind.config.js | 12 ++++- 21 files changed, 113 insertions(+), 94 deletions(-) diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 55a50d3c..d99ee1ca 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1342,7 +1342,7 @@ "message": "Klicke irgendwo auf den Bildschirm" }, "onboarding_step1ValueMessage": { - "message": "Fertig – jetzt kannst du alles, was du markierst, sofort durchsuchen." + "message": "Fertig – jetzt kannst du alles, was du markierst, sofort durchsuchen" }, "onboarding_step2Explain": { "message": "Als Nächstes fragen wir die KI zum folgenden Text." diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 0fe0bc2d..18e6a027 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1373,7 +1373,7 @@ "description": "Onboarding Step1 callout/speech-bubble text prompting the user to click anywhere on the original page to dismiss the return hint and continue." }, "onboarding_step1ValueMessage": { - "message": "That's it - you can now search for anything you select in an instant.", + "message": "That's it - you can now search for anything you select in an instant", "description": "Onboarding Step1 value message shown after the user runs the search command and returns to the onboarding tab." }, "onboarding_step2Explain": { diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index c2d7d7ae..02039c2f 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1342,7 +1342,7 @@ "message": "Haz clic en cualquier parte de la pantalla" }, "onboarding_step1ValueMessage": { - "message": "Listo: ahora puedes buscar al instante cualquier texto que selecciones." + "message": "Listo: ahora puedes buscar al instante cualquier texto que selecciones" }, "onboarding_step2Explain": { "message": "A continuación, preguntemos a la IA sobre el siguiente texto." diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 9da215cf..ec4d6a98 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1342,7 +1342,7 @@ "message": "Cliquez n'importe où sur l'écran" }, "onboarding_step1ValueMessage": { - "message": "Et voilà — vous pouvez désormais rechercher instantanément tout ce que vous sélectionnez." + "message": "Et voilà — vous pouvez désormais rechercher instantanément tout ce que vous sélectionnez" }, "onboarding_step2Explain": { "message": "Ensuite, interrogeons l'IA à propos du texte ci-dessous." diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 85bf3a7d..859a6e0c 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1342,7 +1342,7 @@ "message": "स्क्रीन पर कहीं भी क्लिक करें" }, "onboarding_step1ValueMessage": { - "message": "बस इतना ही — अब आप जो भी टेक्स्ट चुनें, उसे तुरंत सर्च कर सकते हैं।" + "message": "बस इतना ही — अब आप जो भी टेक्स्ट चुनें, उसे तुरंत सर्च कर सकते हैं" }, "onboarding_step2Explain": { "message": "अब, नीचे दिए गए टेक्स्ट के बारे में AI से पूछते हैं।" diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 7a8fd20e..f3b945aa 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1345,7 +1345,7 @@ "message": "Klik di mana saja pada layar" }, "onboarding_step1ValueMessage": { - "message": "Selesai — sekarang Anda bisa langsung mencari apa pun yang Anda pilih." + "message": "Selesai — sekarang Anda bisa langsung mencari apa pun yang Anda pilih" }, "onboarding_step2Explain": { "message": "Selanjutnya, mari tanyakan ke AI tentang teks di bawah ini." diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 6af54d49..ed9a603c 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1342,7 +1342,7 @@ "message": "Fai clic in un punto qualsiasi dello schermo" }, "onboarding_step1ValueMessage": { - "message": "Fatto: ora puoi cercare all'istante qualsiasi cosa selezioni." + "message": "Fatto: ora puoi cercare all'istante qualsiasi cosa selezioni" }, "onboarding_step2Explain": { "message": "Ora chiediamo all'IA il testo qui sotto." diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index f43b3a6b..b3f95540 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1339,7 +1339,7 @@ "message": "画面のどこかをクリック" }, "onboarding_step1ValueMessage": { - "message": "これで、気になった情報をすぐに検索できました。" + "message": "これで、気になった情報をすぐに検索できました" }, "onboarding_step2Explain": { "message": "次は、選択したテキストをAIに聞いてみましょう。" diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index ff781db4..b6ce67ee 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1342,7 +1342,7 @@ "message": "화면 아무 곳이나 클릭" }, "onboarding_step1ValueMessage": { - "message": "이제 궁금했던 정보를 바로 검색할 수 있게 되었습니다." + "message": "이제 궁금했던 정보를 바로 검색할 수 있게 되었습니다" }, "onboarding_step2Explain": { "message": "다음으로 아래 텍스트에 대해 AI에게 물어봅시다." diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index b5f4f230..0e956e7f 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1345,7 +1345,7 @@ "message": "Klik di mana-mana pada skrin" }, "onboarding_step1ValueMessage": { - "message": "Selesai — kini anda boleh mencari dengan segera apa sahaja yang anda pilih." + "message": "Selesai — kini anda boleh mencari dengan segera apa sahaja yang anda pilih" }, "onboarding_step2Explain": { "message": "Seterusnya, mari tanya AI tentang teks di bawah ini." diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index f4850b71..efb65969 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1345,7 +1345,7 @@ "message": "Clique em qualquer lugar da tela" }, "onboarding_step1ValueMessage": { - "message": "Pronto — agora você pode pesquisar instantaneamente qualquer coisa que selecionar." + "message": "Pronto — agora você pode pesquisar instantaneamente qualquer coisa que selecionar" }, "onboarding_step2Explain": { "message": "A seguir, vamos perguntar à IA sobre o texto abaixo." diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index f5816710..bb8ef5ce 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1345,7 +1345,7 @@ "message": "Clique em qualquer parte do ecrã" }, "onboarding_step1ValueMessage": { - "message": "Pronto — agora já pode pesquisar instantaneamente tudo o que selecionar." + "message": "Pronto — agora já pode pesquisar instantaneamente tudo o que selecionar" }, "onboarding_step2Explain": { "message": "De seguida, vamos perguntar à IA sobre o texto abaixo." diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 50708475..8edea6b0 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1342,7 +1342,7 @@ "message": "Нажмите в любом месте экрана" }, "onboarding_step1ValueMessage": { - "message": "Готово — теперь вы можете мгновенно искать всё, что выделите." + "message": "Готово — теперь вы можете мгновенно искать всё, что выделите" }, "onboarding_step2Explain": { "message": "Далее давайте спросим ИИ о тексте ниже." diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 2dce2ab2..eb022d66 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1342,7 +1342,7 @@ "message": "点击屏幕上的任意位置" }, "onboarding_step1ValueMessage": { - "message": "就是这样——现在您可以立即搜索所选的任何内容。" + "message": "就是这样——现在您可以立即搜索所选的任何内容" }, "onboarding_step2Explain": { "message": "接下来,让我们向 AI 询问下面这段文字。" diff --git a/packages/extension/src/action/executor.ts b/packages/extension/src/action/executor.ts index d64a2eaa..23acc88d 100644 --- a/packages/extension/src/action/executor.ts +++ b/packages/extension/src/action/executor.ts @@ -37,12 +37,15 @@ export async function executeAction({ const { sourceType, sourceId } = resolveCommandSource(command) - sendEvent(ANALYTICS_EVENTS.SELECTION_COMMAND, { - event_label: mode, - command_id: command.id, - source_type: sourceType, - source_id: sourceId, - }) + // Don't send analytics events for commands executed from the extension's own pages (e.g., onBoarding, etc.) + if (!pageUrl?.startsWith("chrome-extension://")) { + sendEvent(ANALYTICS_EVENTS.SELECTION_COMMAND, { + event_label: mode, + command_id: command.id, + source_type: sourceType, + source_id: sourceId, + }) + } dispatchCommandExecuted({ commandId: command.id, commandType: mode }) diff --git a/packages/extension/src/components/menu/MenuItem.tsx b/packages/extension/src/components/menu/MenuItem.tsx index a2da1ab2..c425a3a2 100644 --- a/packages/extension/src/components/menu/MenuItem.tsx +++ b/packages/extension/src/components/menu/MenuItem.tsx @@ -7,6 +7,7 @@ import { ResultPopup } from "@/components/result/ResultPopup" import { useSelectContext } from "@/hooks/useSelectContext" import { useCommandExecutor } from "@/hooks/useCommandExecutor" import { getCommandEnabled } from "@/lib/commandEnabled" +import { PopupOption } from "@/services/option/defaultSettings" import { ExecState } from "@/const" import type { Command } from "@/types" @@ -24,8 +25,8 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { useCommandExecutor() const onlyIcon = props.onlyIcon const { iconUrl, title } = props.command + const { isPreview, inTransition, inOnboarding } = usePopupContext() const { selectionText, target } = useSelectContext() - const { isPreview, inTransition } = usePopupContext() const { enabled, message: defaultMessage } = getCommandEnabled(props.command) const message = itemState.message || defaultMessage @@ -38,11 +39,19 @@ export function MenuItem(props: MenuItemProps): React.ReactNode { } const rect = props.menuRef.current.getBoundingClientRect() + let position = { x: rect.right + 10, y: rect.top } + + // オンボーディング中は画面上の説明を表示するために、右端に寄せる + if (inOnboarding) { + const screenWidth = window.screen.width + position = { x: screenWidth - PopupOption.width - 20, y: rect.top } + } + const useSecondary = e.metaKey || e.ctrlKey executeCommand({ command: props.command, - position: { x: rect.right + 10, y: rect.top }, + position, selectionText, target, useSecondary, diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index 1e9b9434..08dea6c8 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -26,8 +26,12 @@ export function StepAiPromptCommand({ onboarding }: Props) { const { selectionText } = useSelectContext() const [calloutElm, setCalloutElm] = useState(null) + const isExplain = phase === StepPhase.EXPLAIN + const isWaitExecute = phase === StepPhase.WAIT_EXECUTE + const isValueShown = phase === StepPhase.VALUE_SHOWN + useEffect(() => { - if (phase !== StepPhase.EXPLAIN) return + if (!isExplain) return if (isEmpty(selectionText)) return onboarding.recordFirstSelection(OnboardingStep.AI_PROMPT) setPhase(StepPhase.WAIT_EXECUTE) @@ -35,7 +39,7 @@ export function StepAiPromptCommand({ onboarding }: Props) { }, [phase, selectionText]) useEffect(() => { - if (phase !== StepPhase.WAIT_EXECUTE) { + if (!isWaitExecute) { setCalloutElm(null) return } @@ -52,10 +56,10 @@ export function StepAiPromptCommand({ onboarding }: Props) { return () => { observer.disconnect() } - }, [phase]) + }, [isWaitExecute]) useEffect(() => { - if (phase !== StepPhase.WAIT_EXECUTE) return + if (!isWaitExecute) return return subscribeCommandExecuted(({ commandId, commandType }) => { if (commandId !== ONBOARDING_AI_PROMPT_COMMAND_ID) return onboarding.recordCommandExecuted(OnboardingStep.AI_PROMPT, commandType) @@ -64,8 +68,6 @@ export function StepAiPromptCommand({ onboarding }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) - const valueShown = phase === StepPhase.VALUE_SHOWN - // Renders through this single return for every phase, including // VALUE_SHOWN, instead of branching into a separate // subtree there - see StepSearchCommand.tsx for why: that would mount a @@ -74,7 +76,7 @@ export function StepAiPromptCommand({ onboarding }: Props) { return (
- {!valueShown && ( + {!isValueShown && (

2.{" "} {t("Option_commandType_aiPrompt_title")} @@ -84,18 +86,18 @@ export function StepAiPromptCommand({ onboarding }: Props) {
-

- {valueShown +

+ {isValueShown ? t("onboarding_step2ValueMessage") : t("onboarding_step2Explain")}

@@ -104,21 +106,19 @@ export function StepAiPromptCommand({ onboarding }: Props) {
- {valueShown ? ( + {isValueShown ? ( - - ) : ( + {!isValueShown && ( <> )} + + {isValueShown && ( + + + + )}

) } diff --git a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx index 5f1f21cb..44404e2d 100644 --- a/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx +++ b/packages/extension/src/components/onboarding/steps/StepLinkPreview.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef } from "react" -import { ArrowBigUp, ChevronRight, MousePointerClick } from "lucide-react" +import { useEffect, useState } from "react" +import { ChevronRight, ArrowBigUp, MousePointerClick } from "lucide-react" import { t } from "@/services/i18n" import { DRAG_OPEN_MODE } from "@/const" import { subscribeCommandExecuted } from "../onboardingEvents" +import { OnboardingCallout } from "../OnboardingCallout" import { OnboardingFadeIn } from "../OnboardingFadeIn" import { OnboardingRail } from "../OnboardingRail" import { OnboardingStep, StepPhase } from "@/types/onboarding" @@ -14,28 +15,70 @@ type Props = { const DRAG_OPEN_MODES: readonly string[] = Object.values(DRAG_OPEN_MODE) +// WAIT_RETURN counts as the rail's "result" beat, same as StepSearchCommand - +// the preview already opened, we're just waiting for the user to switch back. +function railBeat(phase: StepPhase): 0 | 2 { + if (phase === StepPhase.WAIT_RETURN) return 2 + return 0 +} + // Step3: Shift+click the sample link to trigger Link Preview. Unlike // Steps 1-2 this isn't triggered via text selection, so it only ever uses -// the EXPLAIN and VALUE_SHOWN phases - the rail stays on beat 0 the whole -// time it's visible, per the design note that "select" here stands in for -// "pick the target" even though the gesture is a click, not a drag. +// the EXPLAIN, WAIT_RETURN and VALUE_SHOWN phases - per the design note +// that "select" here stands in for "pick the target" even though the +// gesture is a click, not a drag. export function StepLinkPreview({ onboarding }: Props) { const { phase, setPhase } = onboarding - const linkRef = useRef(null) + const [linkElm, setLinkElm] = useState(null) + const [returnCalloutElm, setReturnCalloutElm] = useState(null) const isExplain = phase === StepPhase.EXPLAIN + const isWaitReturn = phase === StepPhase.WAIT_RETURN const isValueShown = phase === StepPhase.VALUE_SHOWN useEffect(() => { + if (isWaitReturn) { + console.log("onboarding: StepLinkPreview subscribing to command executed") + const elm = document.querySelector( + "[data-testid='onboarding-step3-callout-anchor']", + ) + setReturnCalloutElm(elm) + } else { + setReturnCalloutElm(null) + } + if (!isExplain) return + return subscribeCommandExecuted(({ commandType }) => { if (!DRAG_OPEN_MODES.includes(commandType)) return onboarding.recordCommandExecuted(OnboardingStep.LINK_PREVIEW, commandType) - setPhase(StepPhase.VALUE_SHOWN) + setPhase(StepPhase.WAIT_RETURN) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]) + // The preview opens in a separate browser window/tab, so switching focus + // back doesn't reliably fire `visibilitychange` alone (that API tracks tab + // occlusion/minimization, not window focus) - a `focus` listener on this + // window is needed too, to reliably detect the user clicking back onto the + // onboarding window. See StepSearchCommand.tsx for the same pattern. + useEffect(() => { + if (!isWaitReturn) return + + const onVisible = () => { + if (document.visibilityState === "visible") { + setPhase(StepPhase.VALUE_SHOWN, 100) + } + } + document.addEventListener("visibilitychange", onVisible) + window.addEventListener("focus", onVisible) + return () => { + document.removeEventListener("visibilitychange", onVisible) + window.removeEventListener("focus", onVisible) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]) + // Renders through this single return for every phase, including // VALUE_SHOWN, instead of branching into a separate // subtree there - see StepSearchCommand.tsx for why: that would mount a @@ -44,80 +87,123 @@ export function StepLinkPreview({ onboarding }: Props) { return (
- {!isValueShown && ( -

- 3. {t("Option_linkCommand")} -

- )} +

+ 3. {t("Option_linkCommand")} +

- {isValueShown - ? t("onboarding_step3ValueMessage") - : t("onboarding_step3Explain")} + {isValueShown ? ( + <> + {t("onboarding_step3ValueMessage")} + + 🎉 + + + ) : isWaitReturn ? ( + t("onboarding_step3ReturnHint") + ) : ( + t("onboarding_step3Explain") + )}

- +
- {isValueShown ? ( - - - - ) : ( + {isExplain && ( -
+ -
- - - Shift - - + - - - {t("onboarding_clickKeycap")} - -
+ +
+ {t("onboarding_step3Callout")} +
+ + + Shift + + + + + + {t("onboarding_clickKeycap")} + +
+
+
+ + )} + + {isWaitReturn && ( + <> + + + {t("onboarding_step1Callout_2")} + + + )} + + {isValueShown && ( + + )}
diff --git a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx index e7c4b3e6..f15c8af7 100644 --- a/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepSearchCommand.tsx @@ -121,7 +121,7 @@ export function StepSearchCommand({ onboarding }: Props) { const onVisible = () => { if (document.visibilityState === "visible") { - setPhase(StepPhase.VALUE_SHOWN) + setPhase(StepPhase.VALUE_SHOWN, 100) setReturnCalloutElm(null) } } @@ -143,7 +143,7 @@ export function StepSearchCommand({ onboarding }: Props) { )} > -

+

1.{" "} {t("Option_commandType_search_title")}

diff --git a/packages/extension/src/components/onboarding/useOnboardingState.ts b/packages/extension/src/components/onboarding/useOnboardingState.ts index 82c5f6a1..dab1c0a4 100644 --- a/packages/extension/src/components/onboarding/useOnboardingState.ts +++ b/packages/extension/src/components/onboarding/useOnboardingState.ts @@ -9,13 +9,14 @@ import { closeOnboardingTab } from "./onboardingWindow" export type UseOnboardingState = ReturnType -// e2e-build-only escape hatch (`?step=SEARCH&phase=value_shown`) so +// development or e2e build-only escape hatch (`?step=SEARCH&phase=value_shown`) so // screenshot/manual-QA tooling can land directly on any step/phase without -// driving the whole real flow. `import.meta.env.MODE` is only "e2e" for -// `vite build --mode e2e` (see package.json's build:e2e), so this is -// entirely inert in production and dev builds. -function readE2eOverride(): { step: OnboardingStep; phase: StepPhase } | null { - if (import.meta.env.MODE !== "e2e") return null +// driving the whole real flow. +function readStepAndPhaseOverride(): { + step: OnboardingStep + phase: StepPhase +} | null { + if (!["e2e", "development"].includes(import.meta.env.MODE)) return null const params = new URLSearchParams(window.location.search) const stepParam = params.get("step") if (!stepParam) return null @@ -35,10 +36,10 @@ function readE2eOverride(): { step: OnboardingStep; phase: StepPhase } | null { export function useOnboardingState() { const [step, setStep] = useState( - () => readE2eOverride()?.step ?? OnboardingStep.INTRO, + () => readStepAndPhaseOverride()?.step ?? OnboardingStep.INTRO, ) const [phase, _setPhase] = useState( - () => readE2eOverride()?.phase ?? StepPhase.EXPLAIN, + () => readStepAndPhaseOverride()?.phase ?? StepPhase.EXPLAIN, ) const startedAtRef = useRef(Date.now()) const firstValueSentRef = useRef(false) @@ -53,6 +54,7 @@ export function useOnboardingState() { }, []) const setPhase = useCallback((next: StepPhase, delay?: number) => { + console.log(`onboarding: setPhase(${next}, ${delay})`) if (delay) { setTimeout(() => _setPhase(next), delay) return diff --git a/packages/extension/src/hooks/useSelectContext.tsx b/packages/extension/src/hooks/useSelectContext.tsx index bbf5ee09..0dccca81 100644 --- a/packages/extension/src/hooks/useSelectContext.tsx +++ b/packages/extension/src/hooks/useSelectContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext } from "react" export type ContextType = { selectionText: string + setSelectionText: (text: string) => void target: Element | null setTarget: (elm: Element | null) => void setDetectSelectionEnabled: (enabled: boolean) => void diff --git a/packages/extension/src/providers/SelectContextProvider.tsx b/packages/extension/src/providers/SelectContextProvider.tsx index e0424e7f..4d32291c 100644 --- a/packages/extension/src/providers/SelectContextProvider.tsx +++ b/packages/extension/src/providers/SelectContextProvider.tsx @@ -1,4 +1,4 @@ -import { useState, ReactNode, useEffect } from "react" +import { useState, ReactNode, useEffect, useCallback } from "react" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import { getSelectionText } from "@/services/dom" import { ContextType, selectContext } from "@/hooks/useSelectContext" @@ -12,12 +12,12 @@ export const SelectContextProvider = ({ const [target, setTarget] = useState(null) const [detectSelectionEnabled, setDetectSelectionEnabled] = useState(true) - useEffect(() => { - const setSelectionText = async (text: string) => { - _setSelectionText(text) - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, text) - } + const setSelectionText = useCallback(async (text: string) => { + _setSelectionText(text) + await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, text) + }, []) + useEffect(() => { const onSelectionchange = async () => { if (!detectSelectionEnabled) return const text = getSelectionText() @@ -28,10 +28,11 @@ export const SelectContextProvider = ({ return () => { document.removeEventListener("selectionchange", onSelectionchange) } - }, [detectSelectionEnabled]) + }, [detectSelectionEnabled, setSelectionText]) const value: ContextType = { selectionText, + setSelectionText, target, setTarget, setDetectSelectionEnabled, From 17ec60246d67fe5c125b3c0f0910775c2fa24d4c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 3 Sep 2026 21:58:20 +0900 Subject: [PATCH 12/21] =?UTF-8?q?Update:=20Storyboo=E3=81=AE=E5=B0=8E?= =?UTF-8?q?=E5=85=A5=E3=81=8C=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=8B=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AE=E8=AA=BF=E6=9F=BB=E8=A8=98?= =?UTF-8?q?=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/storybook-investigation.md | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/design/storybook-investigation.md diff --git a/docs/design/storybook-investigation.md b/docs/design/storybook-investigation.md new file mode 100644 index 00000000..2b3b0d00 --- /dev/null +++ b/docs/design/storybook-investigation.md @@ -0,0 +1,99 @@ +# Storybook導入 調査記録 + +## 目的 + +`packages/extension` へ Storybook を導入し、`OnboardingPage.tsx` の各ステップ +(`OnboardingStep` × `StepPhase` の組み合わせ)を個別に確認できるようにする +試みを行った。作業時間の制約により完了に至らなかったため、原因と得られた +知見を記録する。 + +## 採用した方針(概要) + +- `OnboardingPage` は自身の状態を `useOnboardingState` フックで管理しており、 + Props を受け取らない。そのため、各ステップ用の Story を個別に自作する + のではなく、`useOnboardingState.ts` に既に存在する e2e 用の URL 上書き + 機構(`?step=SEARCH&phase=value_shown`、`e2e/onboarding-shots.spec.ts` が + スクリーンショット撮影に使用しているもの)を Storybook からも流用する + 方針を取った。 +- `chrome.*` API は Storybook のプレビュー用に独自モック(`chrome.storage` + のメモリ実装、`chrome.i18n.getMessage` の実メッセージ引き当て等)を用意 + し、`Storage.setCommands(getDefaultCommands(locale))` を Story 読み込み時 + に実行して、本番の初回インストール時と同じ状態を再現する設計とした。 + +## 遭遇した問題と原因 + +### 1. Storybook の Vite ビルダーが、拡張機能用の `vite.config.ts` を自動で読み込んでしまう + +- `@storybook/react-vite` は既定で `.storybook` の親ディレクトリにある + `vite.config.ts` を自動検出し、Storybook 用の設定にマージする仕様になっ + ている(`@storybook/builder-vite` 内部の `getBuilderOptions()` / + `loadConfigFromFile()`)。 +- 本プロジェクトの `vite.config.ts` は Chrome 拡張機能のビルド専用で、 + `@crxjs/vite-plugin`(manifest.json の解釈やコンテンツスクリプトの特別 + なバンドリング)や、Shadow DOM 環境向けにカスタムした + `vite-plugin-css-injected-by-js` の `injectCodeFunction` + (`document.getElementById("selection-command")?.shadowRoot ?? document.head` + への注入)を含んでいる。 +- これが Storybook のプレビュー(通常の DOM ページ)に対しても適用されて + しまい、 + - `@crxjs/vite-plugin` の HMR ハンドラがエラーを送出してオーバーレイ + 表示になる + - CSS が期待通り `