From 99e2f875eb66a947e4620fba163eb1936a75dc91 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:43:22 -0400 Subject: [PATCH 1/5] Improve welcome card visuals --- apps/desktop/package-lock.json | 10 + apps/desktop/package.json | 1 + .../onboarding/WelcomeVideoGate.test.tsx | 109 ++- .../onboarding/WelcomeVideoGate.tsx | 739 +++++++++++++++--- apps/desktop/src/renderer/index.css | 96 +++ .../src/renderer/public/welcome/ade-icon.webp | Bin 0 -> 39000 bytes .../src/renderer/public/welcome/desktop.webp | Bin 0 -> 65358 bytes .../src/renderer/public/welcome/mobile.webp | Bin 0 -> 59116 bytes .../src/renderer/public/welcome/tui.webp | Bin 0 -> 40814 bytes .../renderer/public/welcome/video-poster.jpg | Bin 0 -> 35741 bytes docs/ARCHITECTURE.md | 2 +- .../onboarding-and-settings/README.md | 9 +- 12 files changed, 820 insertions(+), 146 deletions(-) create mode 100644 apps/desktop/src/renderer/public/welcome/ade-icon.webp create mode 100644 apps/desktop/src/renderer/public/welcome/desktop.webp create mode 100644 apps/desktop/src/renderer/public/welcome/mobile.webp create mode 100644 apps/desktop/src/renderer/public/welcome/tui.webp create mode 100644 apps/desktop/src/renderer/public/welcome/video-poster.jpg diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 6c014ca29..fe2000954 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -64,6 +64,7 @@ "papaparse": "^5.5.3", "path-browserify": "^1.0.1", "pdfjs-dist": "^4.10.38", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -17144,6 +17145,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2ff37d6a7..82fb68b9e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -111,6 +111,7 @@ "papaparse": "^5.5.3", "path-browserify": "^1.0.1", "pdfjs-dist": "^4.10.38", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.test.tsx b/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.test.tsx index e203ce793..4612fa1c5 100644 --- a/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.test.tsx @@ -10,6 +10,11 @@ import { ADE_WELCOME_VIDEO_VERSION, } from "../../../shared/welcomeVideo"; import { ADE_MOBILE_TESTFLIGHT_URL } from "../../../shared/productLinks"; +import { docs } from "../../onboarding/docsLinks"; + +// The dialog's accessible name comes from its title, which is now just the ADE +// logo image (alt="ADE"). +const DIALOG_NAME = /^ade$/i; describe("WelcomeVideoGate", () => { const originalAde = window.ade; @@ -43,7 +48,7 @@ describe("WelcomeVideoGate", () => { window.ade = originalAde; }); - it("opens for an unseen video and marks it completed from Continue", async () => { + it("opens for an unseen video and dismisses from the close button", async () => { getWelcomeVideoState.mockResolvedValue({ videoId: ADE_WELCOME_VIDEO_ID, version: ADE_WELCOME_VIDEO_VERSION, @@ -53,22 +58,39 @@ describe("WelcomeVideoGate", () => { render(); - expect(await screen.findByRole("dialog", { name: /welcome to ade/i })).toBeTruthy(); - expect(screen.queryByText(/start here/i)).toBeNull(); - expect(screen.queryByText(/quick orientation/i)).toBeNull(); + expect(await screen.findByRole("dialog", { name: DIALOG_NAME })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /close welcome/i })); + + await waitFor(() => { + expect(markWelcomeVideoSeen).toHaveBeenCalledWith("dismissed"); + }); + expect(screen.queryByRole("dialog", { name: DIALOG_NAME })).toBeNull(); + }); + + it("lazily loads the sandboxed video iframe only after the poster is clicked", async () => { + getWelcomeVideoState.mockResolvedValue({ + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }); + + render(); + + await screen.findByRole("dialog", { name: DIALOG_NAME }); + + // No iframe up front; the poster stands in until the user opts to play. + expect(screen.queryByTitle("Welcome to ADE video")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /play the ade intro video/i })); + const video = screen.getByTitle("Welcome to ADE video"); - expect(video).toBeTruthy(); expect(video.getAttribute("sandbox")).toBe( "allow-scripts allow-same-origin allow-presentation allow-popups", ); expect(video.getAttribute("allow")).toBe("autoplay; encrypted-media; picture-in-picture"); - - fireEvent.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(markWelcomeVideoSeen).toHaveBeenCalledWith("completed"); - }); - expect(screen.queryByRole("dialog", { name: /welcome to ade/i })).toBeNull(); + expect(video.getAttribute("src")).toContain("autoplay=1"); }); it("stays hidden after a seen video until the replay event opens it", async () => { @@ -84,20 +106,20 @@ describe("WelcomeVideoGate", () => { await waitFor(() => { expect(getWelcomeVideoState).toHaveBeenCalledTimes(1); }); - expect(screen.queryByRole("dialog", { name: /welcome to ade/i })).toBeNull(); + expect(screen.queryByRole("dialog", { name: DIALOG_NAME })).toBeNull(); window.dispatchEvent(new Event(ADE_WELCOME_VIDEO_REPLAY_EVENT)); - expect(await screen.findByRole("dialog", { name: /welcome to ade/i })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: /close welcome video/i })); + expect(await screen.findByRole("dialog", { name: DIALOG_NAME })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /close welcome/i })); await waitFor(() => { expect(markWelcomeVideoSeen).toHaveBeenCalledWith("dismissed"); }); - expect(screen.queryByRole("dialog", { name: /welcome to ade/i })).toBeNull(); + expect(screen.queryByRole("dialog", { name: DIALOG_NAME })).toBeNull(); }); - it("opens the mobile install link from the welcome actions", async () => { + it("downloads the mobile app from the QR panel", async () => { getWelcomeVideoState.mockResolvedValue({ videoId: ADE_WELCOME_VIDEO_ID, version: ADE_WELCOME_VIDEO_VERSION, @@ -107,8 +129,59 @@ describe("WelcomeVideoGate", () => { render(); - fireEvent.click(await screen.findByRole("button", { name: /install mobile app/i })); + fireEvent.click(await screen.findByRole("button", { name: /download for ios/i })); expect(openExternal).toHaveBeenCalledWith(ADE_MOBILE_TESTFLIGHT_URL); }); + + it("copies the install link and confirms it in place", async () => { + getWelcomeVideoState.mockResolvedValue({ + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /copy install link/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(ADE_MOBILE_TESTFLIGHT_URL); + }); + expect(await screen.findByRole("button", { name: /link copied/i })).toBeTruthy(); + }); + + it("reports copy failure when the Clipboard API is unavailable", async () => { + getWelcomeVideoState.mockResolvedValue({ + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }); + Object.assign(navigator, { clipboard: undefined }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /copy install link/i })); + + expect(await screen.findByRole("button", { name: /copy failed/i })).toBeTruthy(); + }); + + it("opens the matching docs section from a surface tile", async () => { + getWelcomeVideoState.mockResolvedValue({ + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /^desktop:/i })); + + expect(openExternal).toHaveBeenCalledWith(docs.home); + }); }); diff --git a/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.tsx b/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.tsx index 9648bef50..cecb3f58b 100644 --- a/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.tsx +++ b/apps/desktop/src/renderer/components/onboarding/WelcomeVideoGate.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; +import { QRCodeSVG } from "qrcode.react"; import { ArrowSquareOut, - DeviceMobile, + Check, + Copy, GithubLogo, - PlayCircle, + Play, X, } from "@phosphor-icons/react"; import { docs } from "../../onboarding/docsLinks"; @@ -12,7 +14,6 @@ import { openExternalUrl } from "../../lib/openExternal"; import { ADE_WELCOME_VIDEO_EMBED_URL, ADE_WELCOME_VIDEO_REPLAY_EVENT, - ADE_WELCOME_VIDEO_WATCH_URL, } from "../../../shared/welcomeVideo"; import { ADE_GITHUB_URL, @@ -23,9 +24,53 @@ type WelcomeVideoGateProps = { onVisibilityChange?: (visible: boolean, checking: boolean) => void; }; +type DeviceKind = "macbook" | "phone" | "terminal"; +type CopyState = "idle" | "copied" | "failed"; + +type Surface = { + key: string; + label: string; + blurb: string; + img: string; + url: string; + device: DeviceKind; +}; + +// The three surfaces ADE runs on. Each tile frames a real screenshot in its +// device chrome and doubles as navigation into the matching docs section. +const SURFACES: ReadonlyArray = [ + { + key: "desktop", + label: "Desktop", + blurb: "The full lane workspace", + img: "./welcome/desktop.webp", + url: docs.home, + device: "macbook", + }, + { + key: "mobile", + label: "Mobile", + blurb: "Review & approve on the go", + img: "./welcome/mobile.webp", + url: docs.syncMultiDevice, + device: "phone", + }, + { + key: "tui", + label: "Terminal", + blurb: "ADE Code in your shell", + img: "./welcome/tui.webp", + url: docs.terminals, + device: "terminal", + }, +]; + export function WelcomeVideoGate({ onVisibilityChange }: WelcomeVideoGateProps) { const [visible, setVisible] = useState(false); const [checking, setChecking] = useState(true); + const [playing, setPlaying] = useState(false); + const [copyState, setCopyState] = useState("idle"); + const copyTimer = useRef | null>(null); useEffect(() => { onVisibilityChange?.(visible, checking); @@ -48,24 +93,51 @@ export function WelcomeVideoGate({ onVisibilityChange }: WelcomeVideoGateProps) const replay = () => { setChecking(false); + setPlaying(false); + setCopyState("idle"); setVisible(true); }; window.addEventListener(ADE_WELCOME_VIDEO_REPLAY_EVENT, replay); return () => { cancelled = true; window.removeEventListener(ADE_WELCOME_VIDEO_REPLAY_EVENT, replay); + if (copyTimer.current) clearTimeout(copyTimer.current); }; }, []); - const close = useCallback((reason: "completed" | "dismissed") => { + const dismissWelcome = useCallback(() => { setVisible(false); - void window.ade.app.markWelcomeVideoSeen(reason).catch(() => {}); + setPlaying(false); + setCopyState("idle"); + void window.ade.app.markWelcomeVideoSeen("dismissed").catch(() => {}); }, []); const openGitHub = useCallback(() => openExternalUrl(ADE_GITHUB_URL), []); const openDocs = useCallback(() => openExternalUrl(docs.home), []); - const openMobileApp = useCallback(() => openExternalUrl(ADE_MOBILE_TESTFLIGHT_URL), []); - const openVideo = useCallback(() => openExternalUrl(ADE_WELCOME_VIDEO_WATCH_URL), []); + const openMobileApp = useCallback( + () => openExternalUrl(ADE_MOBILE_TESTFLIGHT_URL), + [], + ); + + const copyMobileLink = useCallback(() => { + const finish = (state: CopyState) => { + setCopyState(state); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopyState("idle"), 1800); + }; + try { + const write = navigator.clipboard?.writeText; + if (!write) { + finish("failed"); + return; + } + void write + .call(navigator.clipboard, ADE_MOBILE_TESTFLIGHT_URL) + .then(() => finish("copied"), () => finish("failed")); + } catch { + finish("failed"); + } + }, []); const handleOpenChange = useCallback( (next: boolean) => { @@ -73,9 +145,9 @@ export function WelcomeVideoGate({ onVisibilityChange }: WelcomeVideoGateProps) setVisible(true); return; } - if (visible) close("dismissed"); + if (visible) dismissWelcome(); }, - [close, visible], + [dismissWelcome, visible], ); return ( @@ -94,16 +166,17 @@ export function WelcomeVideoGate({ onVisibilityChange }: WelcomeVideoGateProps) /> -
-
- - Welcome to ADE - -
- -
+ {/* Ambient brand glow behind the header (static) */} +